diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index b350cc0ec..551eb65cc 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -1,4 +1,4 @@ -name: deploy dev docs +name: Deploy dev docs to nyxx.l7ssha.xyz on: push: diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 0329025b5..a99b14e5b 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -1,4 +1,4 @@ -name: unit tests +name: Integration tests on: push: @@ -6,64 +6,13 @@ on: - main jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - env: - TEST_TOKEN: ${{ secrets.TEST_TOKEN }} - steps: - - name: Setup Dart Action - uses: dart-lang/setup-dart@v1 - - - name: Checkout - uses: actions/checkout@v2.3.4 - - - name: Cache - uses: actions/cache@v2 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} - restore-keys: | - ${{ runner.os }}-pubspec- - - - name: Install dependencies - run: dart pub get - - - name: Analyze project source - run: dart analyze - - format: - name: Format - runs-on: ubuntu-latest - env: - TEST_TOKEN: ${{ secrets.TEST_TOKEN }} - steps: - - name: Setup Dart Action - uses: dart-lang/setup-dart@v1 - - - name: Checkout - uses: actions/checkout@v2.3.4 - - - name: Cache - uses: actions/cache@v2 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} - restore-keys: | - ${{ runner.os }}-pubspec- - - - name: Install dependencies - run: dart pub get - - - name: Format - run: dart format --set-exit-if-changed -l 160 ./lib - tests: - needs: [ format, analyze ] - name: Tests + name: Run integration tests runs-on: ubuntu-latest env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + TEST_TEXT_CHANNEL: ${{ secrets.TEST_TEXT_CHANNEL }} + TEST_GUILD: ${{ secrets.TEST_GUILD }} steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a4ed848f3..9573a04b2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: publish +name: Publish nyxx to pub.dev on: push: diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 6aee520af..5a7f564ee 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -1,4 +1,4 @@ -name: unit tests +name: Run unit tests on: push: @@ -8,10 +8,8 @@ on: jobs: analyze: - name: Analyze + name: Analyze project source runs-on: ubuntu-latest - env: - TEST_TOKEN: ${{ secrets.TEST_TOKEN }} steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 @@ -34,10 +32,8 @@ jobs: run: dart analyze format: - name: Format + name: Check project formatting runs-on: ubuntu-latest - env: - TEST_TOKEN: ${{ secrets.TEST_TOKEN }} steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 @@ -61,10 +57,12 @@ jobs: tests: needs: [ format, analyze ] - name: Tests + name: Unit tests runs-on: ubuntu-latest env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + TEST_TEXT_CHANNEL: ${{ secrets.TEST_TEXT_CHANNEL }} + TEST_GUILD: ${{ secrets.TEST_GUILD }} steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 diff --git a/.gitignore b/.gitignore index 04ab2398c..a5b616259 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,53 @@ -local/ -.atom/ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# Pubspec overrides for local testing +pubspec_overrides.yaml + +# Directory created by dartdoc +doc/api/ + +# dotenv environment variables file +.env* + +# Avoid committing generated Javascript files: +*.dart.js +*.info.json # Produced by the --dump-info flag. +*.js # When generated by dart2js. Don't specify *.js if your + # project includes source files written in JavaScript. +*.js_ +*.js.deps +*.js.map + +.flutter-plugins +.flutter-plugins-dependencies + +# IDE configuration folders .vscode/ +.atom/ +.idea/ +*.iml + +# Test output and coverage +log.txt +coverage/ + +local/ index.html docs/ .buildlog -.packages .project .pub -**/build **/packages -*.dart.js -*.part.js -*.js.deps -*.js.map -*.info.json -doc/api/ -pubspec.lock -*.iml -.idea *~ *# .#* -.dart_tool/ /README.html -/log.txt /nyxx.wiki/ /test/private.dart /publish_docs.sh @@ -32,8 +56,3 @@ pubspec.lock private-*.dart test-*.dart [Rr]pc* -**/doc/api/** -**/coverage/** -coverage.json -lcov.info -.vscode/ diff --git a/.pubignore b/.pubignore deleted file mode 100644 index 8c082ddfe..000000000 --- a/.pubignore +++ /dev/null @@ -1,40 +0,0 @@ -local/ -.atom/ -.vscode/ -index.html -docs/ -.buildlog -.packages -.project -.pub -**/build -**/packages -*.dart.js -*.part.js -*.js.deps -*.js.map -*.info.json -doc/api/ -pubspec.lock -*.iml -.idea -*~ -*# -.#* -.dart_tool/ -/README.html -/log.txt -/nyxx.wiki/ -/test/private.dart -/publish_docs.sh -/test/mirrors.dart -/private -private-*.dart -test-*.dart -[Rr]pc* -**/doc/api/** -**/coverage/** -coverage.json -lcov.info -.github/ -.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ccde219e..810a8c13b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +## 6.0.0-dev.2 +__24.08.2023__ + +- rewrite: Changed `MessageBuilder.embeds` and `MessageUpdateBuilder.embeds` to use a new `EmbedBuilder` instead of `Embed` objects. +- rewrite: Changed all builders to be mutable. +- rewrite: Implement the interactions & message components API. +- rewrite: `ActivityBuilder` is now exported. +- rewrite: Fixed some typos: `ChannelManager.parseForumChanel` -> `ChannelManager.parseForumChannel` and `chanel` -> `channel` in the docs for `VoiceChannel.videoQualityMode`. +- rewrite: Added wrappers around CDN endpoints and assets. +- feat: Added `Permissions.allPermissions`, the set of permission flags with all permissions. +- feat: Added `HttpHandler.latency`, `HttpHandler.realLatency`, `Gateway.latency` and `Shard.latency` for tracking the client's latency. +- feat: `Flags` now has the `~` and the `^` operators. +- feat: Added `HttpHandler.onRequest` and `HttpHandler.onResponse` streams for tracking HTTP requests and responses. +- bug: Fixed `MessageUpdateEvent`s causing a parsing error. +- bug: Fixed classes creating uncaught async errors when `toString()` was invoked on them. +- bug: Empty caches are no longer stored. +- bug: Fixed stickers causing a parsing error. +- bug: Fixed rate limits not applying correctly when multiple requests were queued. +- bug: Fixed `applyGlobalRatelimit` in `HttpRequest` not doing anything. + +## 6.0.0-dev.1 +__03.07.2023__ + +- rewrite: The entire library has been rewritten from the ground up. No pre-`6.0.0-dev.1` code is compatible. + Join our Discord server for updates concerning the migration path and help upgrading. + For now, check out the new examples and play around with the rewrite to get a feel for it. + +## 5.0.1 +__18.03.2023__ + +- documentation: Channel invites (#448) +- bug: Correctly dispose all resources on bot stop (#451) + +## 4.5.1 +__19.03.2023__ + +- bug: Correctly dispose all resources on bot stop (#451) + ## 5.0.0 __04.03.2023__ diff --git a/README.md b/README.md index e13a151b7..3efffa197 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,28 @@ # nyxx -[![Discord Shield](https://discordapp.com/api/guilds/846136758470443069/widget.png?style=shield)](https://discord.gg/nyxx) -[![pub](https://img.shields.io/pub/v/nyxx.svg)](https://pub.dartlang.org/packages/nyxx) -[![documentation](https://img.shields.io/badge/Documentation-nyxx-yellow.svg)](https://www.dartdocs.org/documentation/nyxx/latest/) +[![Discord](https://discordapp.com/api/guilds/846136758470443069/widget.png?style=shield)](https://discord.gg/nyxx) +[![pub](https://img.shields.io/pub/v/nyxx.svg)](https://pub.dev/packages/nyxx) +[![documentation](https://img.shields.io/badge/Documentation-nyxx-yellow.svg)](https://pub.dev/documentation/nyxx/latest/) -Simple, robust framework for creating discord bots for Dart language. +A complete, robust and efficient wrapper around Discord's API for bots & applications. -
+To get started using nyxx, follow our [getting started guide](https://nyxx.l7ssha.xyz/docs/guides/writing_your_first_bot) to write your first bot. -### Features - -- **Slash commands support**
- Supports and provides easy API for creating and handling slash commands -- **Commands framework included**
- A fast way to create a bot with command support. Implementing the framework is simple - and everything is done automatically. -- **Cross Platform**
- Nyxx works on the command line, in the browser, and on mobile devices. -- **Fine Control**
- Nyxx allows you to control every outgoing HTTP request or WebSocket message. -- **Complete**
- Nyxx supports nearly all Discord API endpoints. +If you're already familiar with Discord's API, here's a quick example to get you started: +```dart +import 'package:nyxx/nyxx.dart'; +void main() async { + final client = await Nyxx.connectWebsocket('', GatewayIntents.allUnprivileged); -## Quick example + final botUser = await client.users.fetchCurrentUser(); -Basic usage: -```dart -void main() { - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen for message events - bot.eventsWs.onMessageReceived.listen((event) async { - if (event.message.content == "!ping") { - await event.message.channel.sendMessage(MessageBuilder.content("Pong!")); + client.onMessageCreate.listen((event) async { + if (event.mentions.contains(botUser)) { + await event.message.channel.sendMessage(MessageBuilder( + content: 'You mentioned me!', + replyId: event.message.id, + )); } }); } @@ -44,43 +30,39 @@ void main() { ## Other nyxx packages -- [nyxx_interactions](https://github.com/nyxx-discord/nyxx_interactions) -- [nyxx_commander](https://github.com/nyxx-discord/nyxx_commander) -- [nyxx_extensions](https://github.com/nyxx-discord/nyxx_extensions) -- [nyxx_lavalink](https://github.com/nyxx-discord/nyxx_lavalink) -- [nyxx_pagination](https://github.com/nyxx-discord/nyxx_pagination) +- [nyxx_commands](https://pub.dev/packages/nyxx_commands): A command framework for handling both simple & complex commands. +- [nyxx_pagination](https://pub.dev/packages/nyxx_pagination): Pagination support for nyxx. +- [nyxx_lavalink](https://pub.dev/packages/nyxx_lavalink): Lavalink support for playing audio in voice channels. +- [nyxx_extensions](https://pub.dev/packages/nyxx_extensions): Miscellaneous helpers for common situations when developing bots. ## More examples -Nyxx examples can be found [here](https://github.com/nyxx-discord/nyxx/tree/dev/example). - -### Example bots -- [Running on Dart](https://github.com/l7ssha/running_on_dart) +- More examples can be found in our GitHub repository [here](https://github.com/nyxx-discord/nyxx/tree/main/example). +- [Running on Dart](https://github.com/nyxx-discord/running_on_dart) is a complete example of a bot written with nyxx. -## Documentation, help and examples +## Additional documentation & help -**Dartdoc documentation for latest stable version is hosted on [pub](https://www.dartdocs.org/documentation/nyxx/latest/)** +The API documentation for the latest stable version can be found on [pub](https://pub.dev/documentation/nyxx). -#### [Docs and wiki](https://nyxx.l7ssha.xyz) -You can read docs and wiki articles for latest stable version on my website. This website also hosts docs for latest -dev changes to framework (`dev` branch) +### [Docs and wiki](https://nyxx.l7ssha.xyz) +Tutorials and wiki articles are hosted here, as well as API documentation for development versions from GitHub. -#### [Official nyxx discord server](https://discord.gg/nyxx) -If you need assistance in developing bot using nyxx you can join official nyxx discord guild. +### [Official nyxx Discord server](https://discord.gg/nyxx) +Our Discord server is where you can get help for any nyxx packages, as well as release announcements and discussions about the library. -#### [Discord API docs](https://discordapp.com/developers/docs/intro) -Discord API documentation features rich descriptions about all topics that nyxx covers. +### [Discord API docs](https://discord.dev/) +Discord's API documentation details what nyxx implements & provides more detailed explanations of certain topics. -#### [Discord API Guild](https://discord.gg/discord-api) +### [Discord API Server](https://discord.gg/discord-api) The unofficial guild for Discord Bot developers. To get help with nyxx check `#dart_nyxx` channel. -#### [Dartdocs](https://www.dartdocs.org/documentation/nyxx/latest/) +### [Pub.dev docs](https://pub.dev/documentation/nyxx) The dartdocs page will always have the documentation for the latest release. ## Contributing to Nyxx -Read [contributing document](https://github.com/nyxx-discord/nyxx/blob/dev/CONTRIBUTING.md) +Read the [contributing document](https://github.com/nyxx-discord/nyxx/blob/dev/CONTRIBUTING.md) ## Credits - * [Hackzzila's](https://github.com/Hackzzila) for [nyx](https://github.com/Hackzzila/nyx). +- Thanks to [Hackzzila's](https://github.com/Hackzzila) for [nyx](https://github.com/Hackzzila/nyx), the original project nyxx was forked from. diff --git a/example/adding_roles.dart b/example/adding_roles.dart new file mode 100644 index 000000000..5a708338b --- /dev/null +++ b/example/adding_roles.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; + +void main() async { + final client = await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged | GatewayIntents.messageContent, + options: GatewayClientOptions(plugins: [logging, cliIntegration]), + ); + + client.onMessageCreate.listen((event) async { + if (!event.message.content.startsWith('!new-role')) return; + if (event.guild == null) return; + + final role = await event.guild!.roles.create(RoleBuilder( + name: 'Test role', + color: DiscordColor.fromRgb(66, 165, 245), + )); + + await event.member!.addRole(role.id); + }); +} diff --git a/example/application_commands.dart b/example/application_commands.dart new file mode 100644 index 000000000..178b7cc1c --- /dev/null +++ b/example/application_commands.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; + +void main() async { + final client = await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged, + options: GatewayClientOptions(plugins: [logging, cliIntegration]), + ); + + // Create a new command named "ping" that takes no arguments. + // You don't need to create commands every time your client starts - just once + // when the command is created or updated is sufficient. + await client.commands.create( + ApplicationCommandBuilder.chatInput( + name: 'ping', + description: 'Ping the bot', + options: [], + ), + ); + + // Listen to the interaction stream and handle the ping command. + client.onApplicationCommandInteraction.listen((event) async { + if (event.interaction.data.name == 'ping') { + await event.interaction.respond(MessageBuilder(content: 'Pong!')); + } + }); +} diff --git a/example/channel.dart b/example/channel.dart deleted file mode 100644 index e8fccc5f8..000000000 --- a/example/channel.dart +++ /dev/null @@ -1,42 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -// Main function -void main() async { - // Create new bot instance. Replace string with your token - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((IReadyEvent e) { - print("Ready!"); - }); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((IMessageReceivedEvent e) async { - // Check if message content equals "!embed" - if (e.message.content == "!create_channel") { - // Make sure that the message was sent in a guild and not in a dm, because we can only create channels in guilds - if (e.message.guild == null) { - return; - } - - // Get guild object from message - final guild = e.message.guild!.getFromCache()!; - - // Created text channel. Remember discord will lower the case of name and replace spaces with - and do other sanitization - final channel = await guild.createChannel(TextChannelBuilder.create("Test channel")) as ITextGuildChannel; - - // Send feedback - await e.message.channel.sendMessage(MessageBuilder.content("Crated ${channel.mention}")); - - // Delete channel that we just created - await channel.delete(); - - // Send feedback - await e.message.channel.sendMessage(MessageBuilder.content("Deleted ${channel.mention}")); - } - }); -} diff --git a/example/create_add_role.dart b/example/create_add_role.dart deleted file mode 100644 index 96efd74e6..000000000 --- a/example/create_add_role.dart +++ /dev/null @@ -1,36 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -// Main function -void main() async { - // Create new bot instance. Replace string with your token - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((IReadyEvent e) { - print("Ready!"); - }); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((IMessageReceivedEvent e) async { - // Check if message content equals "!embed" - if (e.message.content == "!role") { - // Make sure that the message was sent in a guild and not in a dm, because we cant add roles in dms - if (e.message.guild == null) { - return; - } - - // Creating a role with RoleBuilder with a given color. - final role = await e.message.guild!.getFromCache()!.createRole(RoleBuilder("testRole")..color = DiscordColor.chartreuse); - - // Add role to member. - await e.message.member!.addRole(role); - - // Send message with confirmation of given action - await e.message.channel.sendMessage(MessageBuilder.content("Added [${role.name}] to user: [${e.message.author.tag}")); - } - }); -} diff --git a/example/creating_channels.dart b/example/creating_channels.dart new file mode 100644 index 000000000..085e44386 --- /dev/null +++ b/example/creating_channels.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; + +void main() async { + final client = await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged | GatewayIntents.messageContent, + options: GatewayClientOptions(plugins: [logging, cliIntegration]), + ); + + client.onMessageCreate.listen((event) async { + if (!event.message.content.startsWith('!create-channel')) return; + + // We can't create channels outside of a guild. + if (event.guild == null) return; + + // Fetch the channel & cast it to a GuildChannel so we can get its parentId. + final channel = await event.message.channel.get() as GuildChannel; + + await event.guild!.createChannel(GuildTextChannelBuilder( + name: 'test-channel', + parentId: channel.parentId, + )); + }); +} diff --git a/example/embeds.dart b/example/embeds.dart deleted file mode 100644 index 890c003be..000000000 --- a/example/embeds.dart +++ /dev/null @@ -1,51 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -DiscordColor getColorForUserFromMessage(IMessage message) { - if (message.guild != null) { - return PermissionsUtils.getMemberHighestRole(message.member!).color; - } - - return DiscordColor.black; -} - -// Main function -void main() async { - // Create new bot instance. Replace string with your token - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()); // Plugin that handles uncaught exceptions that may occur - - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((IReadyEvent e) { - print("Ready!"); - }); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((IMessageReceivedEvent e) async { - // Check if message content equals "!embed" - if (e.message.content == "!embed") { - - // Create embed with author and footer section. - final embed = EmbedBuilder() - ..addField(name: "Example field title", content: "Example value") - ..addField(builder: (field) { - field.content = "Hi"; - field.name = "Example Field"; - }) - ..addAuthor((author) { - author.name = e.message.author.username; - author.iconUrl = e.message.author.avatarUrl(); - }) - ..addFooter((footer) { - footer.text = "Footer example, good"; - }) - ..color = getColorForUserFromMessage(e.message); - - // Sent an embed to channel where message received was sent - await e.message.channel.sendMessage(MessageBuilder.embed(embed)); - } - }); - - await bot.connect(); -} diff --git a/example/emojis.dart b/example/emojis.dart deleted file mode 100644 index ca2886a3a..000000000 --- a/example/emojis.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -void main(List args) { - // Create new bot instance. Replace string with your token - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - bot.eventsWs.onReady.listen((_) { - print('Ready!'); - }); - - // This event is called when a message is received - bot.eventsWs.onMessageReceived.listen((event) async { - if (event.message.content == '!emoji') { - final emoji = event.message.guild?.getFromCache()?.emojis.values.firstWhere((emo) => emo.name == 'nyxx'); - final msg = await event.message.channel.sendMessage(MessageBuilder.content('Look at this emoji: $emoji')); - await msg.createReaction(emoji!); - // For unicode emoji use `UnicodeEmoji` class - await msg.createReaction(UnicodeEmoji('🤔')); - } - }); - - // This event is called when a reaction has been added to a message - bot.eventsWs.onMessageReactionAdded.listen((event) async { - if (event.emoji is UnicodeEmoji) { - await event.message?.channel.sendMessage( - MessageBuilder.content( - 'Woah! This is a unicode emoji: ${event.emoji}', - ), - ); - } else if (event.emoji is IGuildEmojiPartial) { - if (event.emoji is IResolvableGuildEmojiPartial) { - final emoji = (event.emoji as IResolvableGuildEmojiPartial).resolve(); - await event.message?.channel.sendMessage( - MessageBuilder.content( - 'Woah! This is a custom emoji: ${emoji.name}', - ), - ); - } - } - }); -} diff --git a/example/example.dart b/example/example.dart index 845d008dd..b106cadec 100644 --- a/example/example.dart +++ b/example/example.dart @@ -1,25 +1,15 @@ -import "package:nyxx/nyxx.dart"; +import 'dart:io'; -// Main function -void main() { - // Create new bot instance - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); +import 'package:nyxx/nyxx.dart'; - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((e) { - print("Ready!"); - }); +void main() async { + final client = await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged, + options: GatewayClientOptions(plugins: [logging, cliIntegration]), + ); - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((e) { - // Check if message content equals "!ping" - if (e.message.content == "!ping") { - // Send "Pong!" to channel where message was received - e.message.channel.sendMessage(MessageBuilder.content("Pong!")); - } - }); + await for (final MessageCreateEvent(:message) in client.onMessageCreate) { + print('${message.id} sent by ${message.author.id} in ${message.channelId}!'); + } } diff --git a/example/invite.dart b/example/invite.dart deleted file mode 100644 index 2dce5e00b..000000000 --- a/example/invite.dart +++ /dev/null @@ -1,33 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -// Main function -void main() { - // Create new bot instance. Replace string with your token - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((IReadyEvent e) { - print("Ready!"); - }); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((IMessageReceivedEvent e) async { - // Check if message content equals "!create_channel" - if (e.message.content == "!create_channel") { - // Make sure that the message was sent in a guild and not in a dm, because we cant make invites in dms - if (e.message.guild == null) { - return; - } - - // Create default invite. We have to cast channel to access guild specific functionality. - final invite = await (e.message.channel as ITextGuildChannel).createInvite(); - - // Send back invite url - await e.message.channel.sendMessage(MessageBuilder.content(invite.url)); - } - }); -} diff --git a/example/kick_ban.dart b/example/kick_ban.dart deleted file mode 100644 index 589d2a6d1..000000000 --- a/example/kick_ban.dart +++ /dev/null @@ -1,64 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -// Returns user that can be banned from message. Parses mention or raw id from message -SnowflakeEntity getUserToKickOrBan(IMessage message) { - // If mentions are not empty return first mention - if (message.mentions.isNotEmpty) { - return message.mentions.first.id.toSnowflakeEntity(); - } - - // Otherwise split message by spaces then take last part and parse it to snowflake and return as Snowflake entity - return SnowflakeEntity(message.content.split(" ").last.toSnowflake()); -} - -// Main function -void main() { - // Create new bot instance. Replace string with your token - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((IReadyEvent e) { - print("Ready!"); - }); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((IMessageReceivedEvent e) async { - // Check if message content equals "!ban" - if (e.message.content == "!ban") { - // Make sure that the message was sent in a guild and not in a dm, because we cant ban people in dms - if (e.message.guild == null) { - return; - } - - // Get user to ban - final userToBan = getUserToKickOrBan(e.message); - - // Ban user using variable initialized before - await e.message.guild!.getFromCache()!.ban(userToBan); - - // Send feedback - await e.message.channel.sendMessage(MessageBuilder.content("👍")); - } - - // Check if message content equals "!kick" - if (e.message.content == "!kick") { - // Make sure that the message was sent in a guild and not in a dm, because we cant kick people in dms - if (e.message.guild == null) { - return; - } - - // Get user to kick - final userToBan = getUserToKickOrBan(e.message); - - // Kick user - await e.message.guild!.getFromCache()!.kick(userToBan); - - // Send feedback - await e.message.channel.sendMessage(MessageBuilder.content("👍")); - } - }); -} diff --git a/example/message_components.dart b/example/message_components.dart new file mode 100644 index 000000000..677549b43 --- /dev/null +++ b/example/message_components.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; + +void main() async { + final client = await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged | GatewayIntents.messageContent, + options: GatewayClientOptions(plugins: [logging, cliIntegration]), + ); + + client.onMessageCreate.listen((event) async { + if (!event.message.content.startsWith('!component')) return; + + await event.message.channel.sendMessage(MessageBuilder( + content: 'Here are some components for you to play with!', + components: [ + ActionRowBuilder(components: [ + ButtonBuilder( + label: 'Visit nyxx on pub.dev', + style: ButtonStyle.link, + url: Uri.https('pub.dev', '/packages/nyxx'), + ), + ButtonBuilder( + label: 'A primary button', + style: ButtonStyle.primary, + customId: 'primary_button', + ), + ButtonBuilder( + label: 'A secondary button', + style: ButtonStyle.secondary, + customId: 'secondary_button', + ), + ]), + ActionRowBuilder(components: [ + SelectMenuBuilder( + type: MessageComponentType.stringSelect, + customId: 'a_custom_id', + options: [ + SelectMenuOptionBuilder(label: 'Option 1', value: 'option_1'), + SelectMenuOptionBuilder(label: 'Option 2', value: 'option_2'), + SelectMenuOptionBuilder(label: 'Option 3', value: 'option_3'), + ], + ), + ]), + ], + )); + }); +} diff --git a/example/permissions.dart b/example/permissions.dart deleted file mode 100644 index 0b80cafeb..000000000 --- a/example/permissions.dart +++ /dev/null @@ -1,47 +0,0 @@ -// ignore_for_file: unused_local_variable -import "package:nyxx/nyxx.dart"; - -// Main function -void main() { - // Create new bot instance. Replace string with your token - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((IReadyEvent e) { - print("Ready!"); - }); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((IMessageReceivedEvent e) async { - // Check if message content equals "!embed" - if (e.message.content == "!addReadPerms") { - - // Dont process message when not send in guild context - if(e.message.guild != null) { - return; - } - - // Get current channel - final messageChannel = e.message.channel.getFromCache() as IGuildChannel; - - // Get member from id - final member = e.message.guild!.getFromCache()!.members[302359032612651009.toSnowflake()]!; - - // Get current member permissions in context of channel - final permissions = await messageChannel.effectivePermissions(member); - - // Get current member permissions as builder - final permissionsAsBuilder = permissions.toBuilder()..sendMessages = true; // @ig - - // Get first channel override as builder and edit sendMessages property to allow sending messages for entities included in this override - final channelOverridesAsBuilder = messageChannel.permissionOverrides.first.toBuilder()..sendMessages = true; - - // Create new channel permission override - await messageChannel.editChannelPermissions(PermissionsBuilder()..sendMessages = true, member); - } - }); -} diff --git a/example/ping_pong.dart b/example/ping_pong.dart deleted file mode 100644 index 45c81eb13..000000000 --- a/example/ping_pong.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -// Main function -void main() { - // Create new bot instance - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((e) { - print("Ready!"); - }); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((e) async { - // Check if message content equals "!ping" - if (e.message.content == "!ping") { - // Send "Pong!" to channel where message was received - await e.message.channel.sendMessage(MessageBuilder.content("Pong!")); - } - }); -} diff --git a/example/private_emoji.dart b/example/private_emoji.dart deleted file mode 100644 index ecb5cf5d3..000000000 --- a/example/private_emoji.dart +++ /dev/null @@ -1,40 +0,0 @@ -//<:Pepega:547759324836003842> - -import "package:nyxx/nyxx.dart"; -import 'dart:io'; - -// Main function -void main() { - // Create new bot instance - final bot = NyxxFactory.createNyxxWebsocket(Platform.environment['BOT_TOKEN']!, GatewayIntents.allUnprivileged | GatewayIntents.messageContent) - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((e) async { - // Check if message content equals "!ping" - if (e.message.content == "!ping") { - bot.httpEndpoints.fetchChannel(Snowflake(961916452967944223)); - - e.message.guild?.getFromCache()?.shard; - // Send "Pong!" to channel where message was received - e.message.channel.sendMessage(MessageBuilder.content(IBaseGuildEmoji.fromId(Snowflake(502563517774299156), bot).formatForMessage())); - } - - print(await (await e.message.guild?.getOrDownload())!.getBans().toList()); - - if (e.message.content == "!create-thread") { - bot.httpEndpoints.startForumThread( - Snowflake(961916452967944223), - ForumThreadBuilder( - 'test', - message: MessageBuilder.content( - 'this is test content <@${e.message.author.id}>', - ), - ), - ); - } - }); -} diff --git a/example/reactions.dart b/example/reactions.dart new file mode 100644 index 000000000..dfc10d926 --- /dev/null +++ b/example/reactions.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; + +void main() async { + final client = await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged | GatewayIntents.messageContent, + options: GatewayClientOptions(plugins: [logging, cliIntegration]), + ); + + client.onMessageCreate.listen((event) async { + if (event.message.content.contains('nyxx')) { + await event.message.react(ReactionBuilder(name: '❤️', id: null)); + } + }); +} diff --git a/example/reply_to_message.dart b/example/reply_to_message.dart deleted file mode 100644 index 9a58baada..000000000 --- a/example/reply_to_message.dart +++ /dev/null @@ -1,36 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -// Main function -void main() { - // Create new bot instance. Replace string with your token - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. - bot.eventsWs.onReady.listen((IReadyEvent e) { - print("Ready!"); - }); - - // Listen to all incoming messages - bot.eventsWs.onMessageReceived.listen((IMessageReceivedEvent e) async { - // Check if message content equals "!reply" - if (e.message.content == "!reply") { - // Create message with some content and then add to builder - // additional ReplyBuilder that is created from message we received in event - final replyBuilder = ReplyBuilder.fromMessage(e.message); - final messageBuilder = MessageBuilder.content("This is how replies work") - ..replyBuilder = replyBuilder; - - // If you dont want to mention user that invoked that command, use AllowedMentions - final allowedMentionsBuilder = AllowedMentions() - ..allow(reply: false); - - messageBuilder.allowedMentions = allowedMentionsBuilder; - - await e.message.channel.sendMessage(messageBuilder); - } - }); -} diff --git a/example/sending_file.dart b/example/sending_file.dart deleted file mode 100644 index f1adc39a6..000000000 --- a/example/sending_file.dart +++ /dev/null @@ -1,60 +0,0 @@ -import "dart:io"; - -import "package:nyxx/nyxx.dart"; - -// Main function -void main() { - // Create new bot instance - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - // Listen to ready event. Invoked when bot started listening to events. - bot.eventsWs.onReady.listen((IReadyEvent e) { - print("Ready!"); - }); - - late IMessage message; - - // Listen to all incoming messages via Dart Stream - bot.eventsWs.onMessageReceived.listen((IMessageReceivedEvent e) async { - // When receive specific message send new file to channel - if (e.message.content == "!give-me-file") { - // Files argument needs to be list of AttachmentBuilder object with - // path to file that you want to send. You can also use other - // AttachmentBuilder constructors to send File object or raw bytes - message = await e.message.channel.sendMessage(MessageBuilder()..files = [AttachmentBuilder.path("test-image.png")]); - } - - // You can remove attachment from message by converting `IAttachment` instance to `AttachmentMetaDataBuilder` via `toBuilder` method. - // Also new file can be added to message by adding new file to files property of message builder - // Remember that files can be received out of order. - if (e.message.content == "!edit-with-more-files") { - message.edit( - MessageBuilder.content("Remove first file and add one more") - ..attachments = [message.attachments.first.toBuilder()] - ..files = [AttachmentBuilder.path('test-image2.png')] - ); - } - - // Check if message content equals "!givemeembed" - if (e.message.content == "!givemeembed") { - // Files can be used within embeds as custom images - final attachment = AttachmentBuilder.file(File("test-image.jpg")); - - // use attachUrl getter from AttachmentBuilder class to get reference to uploaded file - final embed = EmbedBuilder() - ..title = "Example Title" - ..thumbnailUrl = attachment.attachUrl; - - // Send everything we created before to channel where message was received. - e.message.channel.sendMessage( - MessageBuilder.content("HEJKA!") - ..embeds = [embed] - ..files = [attachment] - ); - } - }); -} diff --git a/example/simple_command.dart b/example/simple_command.dart new file mode 100644 index 000000000..d112c6631 --- /dev/null +++ b/example/simple_command.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; + +void main() async { + final client = await Nyxx.connectGateway( + // Replace this line with a string containing your bot's token, or set + // the TOKEN environment variable to your token. + Platform.environment['TOKEN']!, + + // Intents specify which events your bot will receive. + // The [messageContent] intent is needed to read the content of messages + // sent on Discord. It is a privileged intent, so you will need to + // activate it in the developer portal for your application. + GatewayIntents.allUnprivileged | GatewayIntents.messageContent, + + // We configure our client with the logging and cliIntegration plugins + // to get logging output and to close the client cleanly when the process + // is killed. + options: GatewayClientOptions(plugins: [logging, cliIntegration]), + ); + + // We listen to the onMessageCreate stream which emits an event when the + // client receives a message. + client.onMessageCreate.listen((event) async { + if (event.message.content.startsWith('!ping')) { + // Send a message with the content "Pong!", replying to the message that + // we received. + await event.message.channel.sendMessage(MessageBuilder( + content: 'Pong!', + replyId: event.message.id, + )); + } + }); +} diff --git a/lib/nyxx.dart b/lib/nyxx.dart index 5c358ac58..8ff513fb7 100644 --- a/lib/nyxx.dart +++ b/lib/nyxx.dart @@ -1,208 +1,308 @@ -/// Nyxx Discord API wrapper for Dart -/// -/// Main library which contains all stuff needed to connect and interact with Discord API. -library nyxx; +export 'src/api_options.dart' show ApiOptions, RestApiOptions, GatewayApiOptions, GatewayCompression, GatewayPayloadFormat, OAuth2ApiOptions; +export 'src/client.dart' show Nyxx, NyxxRest, NyxxGateway, NyxxOAuth2; +export 'src/client_options.dart' show ClientOptions, RestClientOptions, GatewayClientOptions; +export 'src/errors.dart' + show + NyxxException, + InvalidEventException, + MemberAlreadyExistsException, + ShardDisconnectedError, + RoleNotFoundException, + AuditLogEntryNotFoundException, + OutOfRemainingSessionsError, + IntegrationNotFoundException, + AlreadyAcknowledgedError, + AlreadyRespondedError; + +export 'src/builders/builder.dart' show Builder, CreateBuilder, UpdateBuilder; +export 'src/builders/image.dart' show ImageBuilder; +export 'src/builders/user.dart' show UserUpdateBuilder; +export 'src/builders/permission_overwrite.dart' show PermissionOverwriteBuilder; +export 'src/builders/channel/channel_position.dart' show ChannelPositionBuilder; +export 'src/builders/channel/forum_tag.dart' show ForumTagBuilder; +export 'src/builders/channel/group_dm.dart' show GroupDmUpdateBuilder; +export 'src/builders/channel/guild_channel.dart' + show + ForumChannelUpdateBuilder, + GuildAnnouncementChannelUpdateBuilder, + GuildChannelUpdateBuilder, + GuildTextChannelUpdateBuilder, + GuildVoiceChannelUpdateBuilder, + GuildStageChannelUpdateBuilder, + ForumChannelBuilder, + GuildAnnouncementChannelBuilder, + GuildCategoryBuilder, + GuildCategoryUpdateBuilder, + GuildChannelBuilder, + GuildStageChannelBuilder, + GuildTextChannelBuilder, + GuildVoiceChannelBuilder; +export 'src/builders/channel/stage_instance.dart' show StageInstanceBuilder, StageInstanceUpdateBuilder; +export 'src/builders/channel/thread.dart' show ThreadUpdateBuilder, ForumThreadBuilder, ThreadBuilder, ThreadFromMessageBuilder; +export 'src/builders/message/allowed_mentions.dart' show AllowedMentions; +export 'src/builders/message/attachment.dart' show AttachmentBuilder; +export 'src/builders/message/embed.dart' show EmbedBuilder, EmbedAuthorBuilder, EmbedFieldBuilder, EmbedFooterBuilder, EmbedImageBuilder, EmbedThumbnailBuilder; +export 'src/builders/message/message.dart' show MessageBuilder, MessageUpdateBuilder; +export 'src/builders/message/component.dart' + show ActionRowBuilder, ButtonBuilder, MessageComponentBuilder, SelectMenuBuilder, SelectMenuOptionBuilder, TextInputBuilder; +export 'src/builders/webhook.dart' show WebhookBuilder, WebhookUpdateBuilder; +export 'src/builders/guild/guild.dart' show GuildBuilder, GuildUpdateBuilder; +export 'src/builders/guild/member.dart' show CurrentMemberUpdateBuilder, MemberBuilder, MemberUpdateBuilder; +export 'src/builders/guild/welcome_screen.dart' show WelcomeScreenUpdateBuilder; +export 'src/builders/guild/widget.dart' show WidgetSettingsUpdateBuilder; +export 'src/builders/guild/scheduled_event.dart' show ScheduledEventBuilder, ScheduledEventUpdateBuilder; +export 'src/builders/guild/template.dart' show GuildTemplateBuilder, GuildTemplateUpdateBuilder; +export 'src/builders/guild/auto_moderation.dart' show AutoModerationRuleBuilder, AutoModerationRuleUpdateBuilder; +export 'src/builders/role.dart' show RoleBuilder, RoleUpdateBuilder; +export 'src/builders/voice.dart' show CurrentUserVoiceStateUpdateBuilder, VoiceStateUpdateBuilder, GatewayVoiceStateBuilder; +export 'src/builders/presence.dart' show PresenceBuilder, CurrentUserStatus, ActivityBuilder; +export 'src/builders/application_role_connection.dart' show ApplicationRoleConnectionUpdateBuilder; +export 'src/builders/emoji/emoji.dart' show EmojiBuilder, EmojiUpdateBuilder; +export 'src/builders/emoji/reaction.dart' show ReactionBuilder; +export 'src/builders/invite.dart' show InviteBuilder; +export 'src/builders/sticker.dart' show StickerBuilder, StickerUpdateBuilder; +export 'src/builders/application_command.dart' + show ApplicationCommandBuilder, ApplicationCommandUpdateBuilder, CommandOptionBuilder, CommandOptionChoiceBuilder; +export 'src/builders/interaction_response.dart' show InteractionResponseBuilder, ModalBuilder, InteractionCallbackType; + +export 'src/cache/cache.dart' show Cache, CacheConfig; -export 'src/client_options.dart' show CacheOptions, ClientOptions, GatewayIntents; -export 'src/nyxx.dart' show INyxx, INyxxRest, INyxxWebsocket, NyxxFactory; -export 'src/typedefs.dart' show RawApiMap, RawApiList, RawApiListOfMaps; -export 'src/core/allowed_mentions.dart' show AllowedMentions; -export 'src/core/discord_color.dart' show DiscordColor; -export 'src/core/snowflake.dart' show Snowflake; -export 'src/core/snowflake_entity.dart' show SnowflakeEntity; -export "src/core/application/app_team.dart" show IAppTeam; -export "src/core/application/app_team_member.dart" show IAppTeamMember; -export "src/core/application/app_team_user.dart" show IAppTeamUser; -export "src/core/application/client_oauth2_application.dart" show IClientOAuth2Application; -export "src/core/application/oauth2_application.dart" show IOAuth2Application; -export 'src/core/audit_logs/audit_log.dart' show IAuditLog; -export 'src/core/audit_logs/audit_log_change.dart' show ChangeKeyType, IAuditLogChange; -export 'src/core/audit_logs/audit_log_entry.dart' show IAuditLogEntry, AuditLogEntryType; -export 'src/core/audit_logs/audit_log_options.dart' show IAuditLogOptions; -export 'src/core/channel/cacheable_text_channel.dart' show ICacheableTextChannel; -export 'src/core/channel/channel.dart' show IChannel, ChannelType; -export 'src/core/channel/dm_channel.dart' show IDMChannel; -export 'src/core/channel/invite.dart' show IInviteWithMeta, IInvite; -export 'src/core/channel/text_channel.dart' show ITextChannel; -export 'src/core/channel/thread_channel.dart' show IThreadMember, IThreadChannel, IThreadMemberWithMember; -export 'src/core/channel/thread_preview_channel.dart' show IThreadPreviewChannel; -export 'src/core/channel/guild/activity_types.dart' show VoiceActivityType; -export 'src/core/channel/guild/category_guild_channel.dart' show ICategoryGuildChannel; -export 'src/core/channel/guild/guild_channel.dart' show IGuildChannel, IMinimalGuildChannel; -export 'src/core/channel/guild/text_guild_channel.dart' show ITextGuildChannel; -export 'src/core/channel/guild/voice_channel.dart' - show IVoiceGuildChannel, IStageChannelInstance, IStageVoiceGuildChannel, ITextVoiceTextChannel, StageChannelInstancePrivacyLevel, VideoQualityMode; -export 'src/core/channel/guild/forum/forum_channel.dart' show IForumChannel, ForumSortOrder, ForumLayout; -export 'src/core/channel/guild/forum/forum_channel_tags.dart' show IForumChannelTags; -export 'src/core/channel/guild/forum/forum_tag.dart' show IForumTag; -export 'src/core/embed/embed.dart' show IEmbed; -export 'src/core/embed/embed_author.dart' show IEmbedAuthor; -export 'src/core/embed/embed_field.dart' show IEmbedField; -export 'src/core/embed/embed_footer.dart' show IEmbedFooter; -export 'src/core/embed/embed_provider.dart' show IEmbedProvider; -export 'src/core/embed/embed_thumbnail.dart' show IEmbedThumbnail; -export 'src/core/embed/embed_video.dart' show IEmbedVideo; -export 'src/core/guild/ban.dart' show IBan; -export 'src/core/guild/auto_moderation.dart' - show IActionMetadata, IActionStructure, IAutoModerationRule, ITriggerMetadata, ActionTypes, EventTypes, TriggerTypes, KeywordPresets; -export 'src/core/guild/client_user.dart' show IClientUser; -export 'src/core/guild/guild.dart' show IGuild; -export 'src/core/guild/guild_feature.dart' show GuildFeature; -export 'src/core/guild/guild_nsfw_level.dart' show GuildNsfwLevel; -export 'src/core/guild/guild_preview.dart' show IGuildPreview; -export 'src/core/guild/premium_tier.dart' show PremiumTier; -export 'src/core/guild/role.dart' show IRole, IRoleTags; -export 'src/core/guild/scheduled_event.dart' show IEntityMetadata, IGuildEvent, IGuildEventUser, GuildEventPrivacyLevel, GuildEventStatus, GuildEventType; -export 'src/core/guild/status.dart' show IClientStatus, UserStatus; -export 'src/core/guild/webhook.dart' show IWebhook, WebhookType; -export 'src/core/guild/guild_welcome_screen.dart' show IGuildWelcomeScreen, IGuildWelcomeChannel; -export 'src/core/guild/system_channel_flags.dart' show SystemChannelFlags; -export 'src/core/message/attachment.dart' show IAttachment; -export 'src/core/message/emoji.dart' show IEmoji; -export 'src/core/message/guild_emoji.dart' show IBaseGuildEmoji, IGuildEmoji, IGuildEmojiPartial, IResolvableGuildEmojiPartial; -export 'src/core/message/message.dart' show IMessage; -export 'src/core/message/message_flags.dart' show MessageFlags; -export 'src/core/message/message_reference.dart' show IMessageReference; -export 'src/core/message/message_time_stamp.dart' show IMessageTimestamp, TimeStampStyle; -export 'src/core/message/message_type.dart' show MessageType; -export 'src/core/message/reaction.dart' show IReaction; -export 'src/core/message/referenced_message.dart' show IReferencedMessage; -export 'src/core/message/sticker.dart' show IStandardSticker, IStickerPack, ISticker, IGuildSticker, IPartialSticker; -export 'src/core/message/unicode_emoji.dart' show IUnicodeEmoji, UnicodeEmoji; -export 'src/core/message/components/component_style.dart' show ButtonStyle; -export 'src/core/message/components/message_component.dart' +export 'src/http/bucket.dart' show HttpBucket; +export 'src/http/handler.dart' show HttpHandler, Oauth2HttpHandler, RateLimitInfo; +export 'src/http/request.dart' show BasicRequest, HttpRequest, MultipartRequest, FormDataRequest; +export 'src/http/response.dart' show FieldError, HttpErrorData, HttpResponse, HttpResponseError, HttpResponseSuccess; +export 'src/http/route.dart' show HttpRoute, HttpRouteParam, HttpRoutePart; +export 'src/http/cdn/cdn_asset.dart' show CdnAsset, CdnFormat; +export 'src/http/cdn/cdn_request.dart' show CdnRequest; +export 'src/http/managers/manager.dart' show Manager, ReadOnlyManager; +export 'src/http/managers/channel_manager.dart' show ChannelManager; +export 'src/http/managers/message_manager.dart' show MessageManager; +export 'src/http/managers/user_manager.dart' show UserManager; +export 'src/http/managers/webhook_manager.dart' show WebhookManager; +export 'src/http/managers/guild_manager.dart' show GuildManager; +export 'src/http/managers/application_manager.dart' show ApplicationManager; +export 'src/http/managers/voice_manager.dart' show VoiceManager; +export 'src/http/managers/invite_manager.dart' show InviteManager; +export 'src/http/managers/member_manager.dart' show MemberManager; +export 'src/http/managers/role_manager.dart' show RoleManager; +export 'src/http/managers/gateway_manager.dart' show GatewayManager; +export 'src/http/managers/scheduled_event_manager.dart' show ScheduledEventManager; +export 'src/http/managers/auto_moderation_manager.dart' show AutoModerationManager; +export 'src/http/managers/integration_manager.dart' show IntegrationManager; +export 'src/http/managers/emoji_manager.dart' show EmojiManager; +export 'src/http/managers/audit_log_manager.dart' show AuditLogManager; +export 'src/http/managers/sticker_manager.dart' show GuildStickerManager, GlobalStickerManager; +export 'src/http/managers/application_command_manager.dart' show ApplicationCommandManager, GlobalApplicationCommandManager, GuildApplicationCommandManager; +export 'src/http/managers/interaction_manager.dart' show InteractionManager; + +export 'src/gateway/gateway.dart' show Gateway; +export 'src/gateway/message.dart' show Disconnecting, Dispose, ErrorReceived, EventReceived, GatewayMessage, Send, ShardData, ShardMessage; +export 'src/gateway/shard.dart' show Shard; + +export 'src/models/discord_color.dart' show DiscordColor; +export 'src/models/locale.dart' show Locale; +export 'src/models/permission_overwrite.dart' show PermissionOverwrite, PermissionOverwriteType; +export 'src/models/snowflake.dart' show Snowflake; +export 'src/models/permissions.dart' show Permissions; +export 'src/models/snowflake_entity/snowflake_entity.dart' show SnowflakeEntity, ManagedSnowflakeEntity, WritableSnowflakeEntity; +export 'src/models/user/application_role_connection.dart' show ApplicationRoleConnection; +export 'src/models/user/connection.dart' show Connection, ConnectionType, ConnectionVisibility; +export 'src/models/user/user.dart' show PartialUser, User, UserFlags, NitroType; +export 'src/models/channel/channel.dart' show Channel, ChannelFlags, PartialChannel, ChannelType; +export 'src/models/channel/followed_channel.dart' show FollowedChannel; +export 'src/models/channel/guild_channel.dart' show GuildChannel; +export 'src/models/channel/has_threads_channel.dart' show HasThreadsChannel; +export 'src/models/channel/text_channel.dart' show PartialTextChannel, TextChannel; +export 'src/models/channel/thread_list.dart' show ThreadList; +export 'src/models/channel/thread.dart' show PartialThreadMember, Thread, ThreadMember; +export 'src/models/channel/voice_channel.dart' show VoiceChannel, VideoQualityMode; +export 'src/models/channel/stage_instance.dart' show StageInstance, PrivacyLevel; +export 'src/models/channel/types/announcement_thread.dart' show AnnouncementThread; +export 'src/models/channel/types/directory.dart' show DirectoryChannel; +export 'src/models/channel/types/dm.dart' show DmChannel; +export 'src/models/channel/types/forum.dart' show DefaultReaction, ForumChannel, ForumTag, ForumLayout, ForumSort; +export 'src/models/channel/types/group_dm.dart' show GroupDmChannel; +export 'src/models/channel/types/guild_announcement.dart' show GuildAnnouncementChannel; +export 'src/models/channel/types/guild_category.dart' show GuildCategory; +export 'src/models/channel/types/guild_stage.dart' show GuildStageChannel; +export 'src/models/channel/types/guild_text.dart' show GuildTextChannel; +export 'src/models/channel/types/guild_voice.dart' show GuildVoiceChannel; +export 'src/models/channel/types/private_thread.dart' show PrivateThread; +export 'src/models/channel/types/public_thread.dart' show PublicThread; +export 'src/models/message/activity.dart' show MessageActivity, MessageActivityType; +export 'src/models/message/attachment.dart' show Attachment; +export 'src/models/message/author.dart' show MessageAuthor; +export 'src/models/message/channel_mention.dart' show ChannelMention; +export 'src/models/message/embed.dart' show Embed, EmbedAuthor, EmbedField, EmbedFooter, EmbedImage, EmbedProvider, EmbedThumbnail, EmbedVideo; +export 'src/models/message/message.dart' show Message, MessageFlags, PartialMessage, MessageType, MessageInteraction; +export 'src/models/message/reaction.dart' show Reaction; +export 'src/models/message/reference.dart' show MessageReference; +export 'src/models/message/role_subscription_data.dart' show RoleSubscriptionData; +export 'src/models/message/component.dart' + show + ActionRowComponent, + ButtonComponent, + MessageComponent, + SelectMenuComponent, + SelectMenuOption, + TextInputComponent, + ButtonStyle, + MessageComponentType, + TextInputStyle; +export 'src/models/invite/invite.dart' show Invite, TargetType; +export 'src/models/invite/invite_metadata.dart' show InviteWithMetadata; +export 'src/models/webhook.dart' show PartialWebhook, Webhook, WebhookType, WebhookAuthor; +export 'src/models/guild/ban.dart' show Ban; +export 'src/models/guild/guild_preview.dart' show GuildPreview; +export 'src/models/guild/guild_widget.dart' show GuildWidget, WidgetSettings, WidgetImageStyle; +export 'src/models/guild/guild.dart' show - IMessageButton, - ILinkMessageButton, - IMessageComponent, - IMessageComponentEmoji, - IMessageMultiselect, - IMessageMultiselectOption, - MessageComponentEmoji, - ComponentType, - IMessageTextInput, - IMessageUserMultiSelect, - IMessageRoleMultiSelect, - IMessageMentionableMultiSelect, - IMessageChannelMultiSelect; -export 'src/core/permissions/permission_overrides.dart' show IPermissionsOverrides; -export 'src/core/permissions/permissions.dart' show IPermissions; -export 'src/core/permissions/permissions_constants.dart' show PermissionsConstants; -export 'src/core/user/member.dart' show IMember; -export 'src/core/user/nitro_type.dart' show NitroType; -export 'src/core/user/presence.dart' - show IActivity, IActivityEmoji, IActivityFlags, IActivityParty, IActivityTimestamps, IGameAssets, IGameSecrets, ActivityType, IPartialPresence; -export 'src/core/user/user.dart' show IUser; -export 'src/core/user/user_flags.dart' show IUserFlags; -export 'src/core/user/member_flags.dart' show IMemberFlags; -export 'src/core/voice/voice_region.dart' show IVoiceRegion; -export 'src/core/voice/voice_state.dart' show IVoiceState; -export 'src/events/channel_events.dart' show IChannelCreateEvent, IChannelDeleteEvent, IChannelPinsUpdateEvent, IChannelUpdateEvent, IStageInstanceEvent; -export 'src/events/disconnect_event.dart' show IDisconnectEvent, DisconnectEventReason; -export 'src/events/guild_events.dart' + Guild, + GuildFeatures, + PartialGuild, + SystemChannelFlags, + ExplicitContentFilterLevel, + MessageNotificationLevel, + MfaLevel, + NsfwLevel, + PremiumTier, + VerificationLevel; +export 'src/models/guild/integration.dart' show PartialIntegration, Integration, IntegrationAccount, IntegrationApplication, IntegrationExpireBehavior; +export 'src/models/guild/member.dart' show Member, MemberFlags, PartialMember; +export 'src/models/guild/onboarding.dart' show Onboarding, OnboardingPrompt, OnboardingPromptOption, OnboardingPromptType; +export 'src/models/guild/welcome_screen.dart' show WelcomeScreen, WelcomeScreenChannel; +export 'src/models/guild/scheduled_event.dart' show EntityMetadata, PartialScheduledEvent, ScheduledEvent, ScheduledEventUser, EventStatus, ScheduledEntityType; +export 'src/models/guild/audit_log.dart' show AuditLogChange, AuditLogEntry, AuditLogEntryInfo, PartialAuditLogEntry, AuditLogEvent; +export 'src/models/application.dart' + show Application, ApplicationFlags, InstallationParameters, PartialApplication, ApplicationRoleConnectionMetadata, ConnectionMetadataType; +export 'src/models/guild/template.dart' show GuildTemplate; +export 'src/models/guild/auto_moderation.dart' show - IGuildBanAddEvent, - IGuildBanRemoveEvent, - IGuildCreateEvent, - IGuildDeleteEvent, - IGuildEmojisUpdateEvent, - IGuildMemberAddEvent, - IGuildMemberRemoveEvent, - IGuildMemberUpdateEvent, - IGuildStickerUpdate, - IGuildUpdateEvent, - IRoleCreateEvent, - IRoleDeleteEvent, - IRoleUpdateEvent, - IAutoModerationRuleCreateEvent, - IAutoModerationRuleUpdateEvent, - IAutoModerationRuleDeleteEvent, - IAutoModerationActionExecutionEvent, - IGuildEventCreateEvent, - IGuildEventUpdateEvent, - IGuildEventDeleteEvent, - IWebhookUpdateEvent; -export 'src/events/http_events.dart' show IHttpResponseEvent, IHttpErrorEvent; -export 'src/events/invite_events.dart' show IInviteCreatedEvent, IInviteDeletedEvent; -export 'src/events/member_chunk_event.dart' show IMemberChunkEvent; -export 'src/events/message_events.dart' + ActionMetadata, + AutoModerationAction, + AutoModerationRule, + PartialAutoModerationRule, + TriggerMetadata, + ActionType, + AutoModerationEventType, + KeywordPresetType, + TriggerType; +export 'src/models/voice/voice_state.dart' show VoiceState; +export 'src/models/voice/voice_region.dart' show VoiceRegion; +export 'src/models/role.dart' show PartialRole, Role, RoleTags; +export 'src/models/gateway/gateway.dart' show GatewayBot, GatewayConfiguration, SessionStartLimit; +export 'src/models/gateway/event.dart' show - IMessageReactionEvent, - IMessageDeleteBulkEvent, - IMessageDeleteEvent, - IMessageReactionAddedEvent, - IMessageReactionRemovedEvent, - IMessageReactionRemoveEmojiEvent, - IMessageReactionsRemovedEvent, - IMessageReceivedEvent, - IMessageUpdateEvent; -export 'src/events/presence_update_event.dart' show IPresenceUpdateEvent; -export 'src/events/ratelimit_event.dart' show IRatelimitEvent; -export 'src/events/raw_event.dart' show IRawEvent; -export 'src/events/ready_event.dart' show IReadyEvent; -export 'src/events/thread_create_event.dart' show IThreadCreateEvent, IThreadUpdateEvent; -export 'src/events/thread_deleted_event.dart' show IThreadDeletedEvent; -export 'src/events/thread_list_sync_event.dart' show IThreadListSyncEvent; -export 'src/events/thread_members_update_event.dart' show IThreadMembersUpdateEvent, IThreadMemberUpdateEvent; -export 'src/events/typing_event.dart' show ITypingEvent; -export 'src/events/user_update_event.dart' show IUserUpdateEvent; -export 'src/events/voice_server_update_event.dart' show IVoiceServerUpdateEvent; -export 'src/events/voice_state_update_event.dart' show IVoiceStateUpdateEvent; -export 'src/internal/constants.dart' show Constants, OPCodes, Encoding, CdnConstants; -export 'src/internal/event_controller.dart' show IWebsocketEventController, IRestEventController; -export 'src/internal/http_endpoints.dart' show IHttpEndpoints; -export 'src/internal/cdn_http_endpoints.dart' show ICdnHttpEndpoints; -export 'src/internal/cache/cache.dart' show SnowflakeCache, ICache, InMemoryCache; -export 'src/internal/cache/cache_policy.dart' - show CachePolicyPredicate, CachePolicyLocation, CachePolicy, ChannelCachePolicy, MemberCachePolicy, MessageCachePolicy; -export 'src/internal/cache/cacheable.dart' show Cacheable; -export 'src/internal/exceptions/embed_builder_argument_exception.dart' show EmbedBuilderArgumentException; -export 'src/internal/exceptions/invalid_shard_exception.dart' show InvalidShardException; -export 'src/internal/exceptions/invalid_snowflake_exception.dart' show InvalidSnowflakeException; -export 'src/internal/exceptions/missing_token_error.dart' show MissingTokenError; -export 'src/internal/exceptions/unrecoverable_nyxx_error.dart' show UnrecoverableNyxxError; -export 'src/internal/http/http_route_param.dart' show HttpRouteParam, CdnHttpRouteParam; -export 'src/internal/http/http_route_part.dart' show HttpRoutePart, CdnHttpRoutePart; -export 'src/internal/http/http_route.dart' show IHttpRoute, ICdnHttpRoute; -export 'src/internal/http/http_response.dart' show IHttpResponse, IHttpResponseError, IHttpResponseSuccess; -export 'src/internal/interfaces/convertable.dart' show Convertable; -export 'src/internal/interfaces/disposable.dart' show Disposable; -export 'src/internal/interfaces/message_author.dart' show IMessageAuthor; -export 'src/internal/interfaces/send.dart' show ISend; -export 'src/internal/interfaces/mentionable.dart' show Mentionable; -export 'src/internal/response_wrapper/error_response_wrapper.dart' show IHttpErrorData, IFieldError; -export 'src/internal/response_wrapper/thread_list_result_wrapper.dart' show IThreadListResultWrapper; -export 'src/internal/shard/shard.dart' show IShard; -export 'src/internal/shard/shard_manager.dart' show IShardManager; -export 'src/utils/enum.dart' show IEnum; -export 'src/utils/builders/attachment_builder.dart' show AttachmentBuilder, AttachmentMetadataBuilder; -export 'src/utils/builders/builder.dart' show Builder; -export 'src/utils/builders/embed_author_builder.dart' show EmbedAuthorBuilder; -export 'src/utils/builders/embed_builder.dart' show EmbedBuilder; -export 'src/utils/builders/embed_field_builder.dart' show EmbedFieldBuilder; -export 'src/utils/builders/embed_footer_builder.dart' show EmbedFooterBuilder; -export 'src/utils/builders/guild_builder.dart' show GuildBuilder, RoleBuilder; -export 'src/utils/builders/channel_builder.dart' show ChannelBuilder, TextChannelBuilder, VoiceChannelBuilder, ForumChannelBuilder; -export 'src/utils/builders/message_builder.dart' show MessageBuilder, MessageDecoration, MessageFlagBuilder; -export 'src/utils/builders/member_builder.dart' show MemberBuilder, MemberFlagsBuilder; -export 'src/utils/builders/permissions_builder.dart' show PermissionOverrideBuilder, PermissionsBuilder; -export 'src/utils/builders/presence_builder.dart' show PresenceBuilder, ActivityBuilder; -export 'src/utils/builders/reply_builder.dart' show ReplyBuilder; -export 'src/utils/builders/sticker_builder.dart' show StickerBuilder; -export 'src/utils/builders/thread_builder.dart' show ThreadArchiveTime, ThreadBuilder; -export 'src/utils/builders/guild_event_builder.dart' show GuildEventBuilder, EntityMetadataBuilder; -export 'src/utils/builders/forum_thread_builder.dart' show ForumThreadBuilder, ForumTagBuilder, AvailableTagBuilder; -export 'src/utils/builders/auto_moderation_builder.dart' show ActionMetadataBuilder, ActionStructureBuilder, AutoModerationRuleBuilder, TriggerMetadataBuilder; -export 'src/utils/extensions.dart' show IntExtensions, SnowflakeEntityListExtensions, StringExtensions; -export 'src/utils/permissions.dart' show PermissionsUtils; -export 'src/utils/utils.dart' show ListSafeFirstWhere; + DispatchEvent, + GatewayEvent, + HeartbeatAckEvent, + HeartbeatEvent, + HelloEvent, + InvalidSessionEvent, + RawDispatchEvent, + ReconnectEvent, + UnknownDispatchEvent; +export 'src/models/gateway/opcode.dart' show Opcode; +export 'src/models/gateway/events/application_command.dart' show ApplicationCommandPermissionsUpdateEvent; +export 'src/models/gateway/events/auto_moderation.dart' + show AutoModerationActionExecutionEvent, AutoModerationRuleCreateEvent, AutoModerationRuleDeleteEvent, AutoModerationRuleUpdateEvent; +export 'src/models/gateway/events/channel.dart' + show + ChannelCreateEvent, + ChannelDeleteEvent, + ChannelPinsUpdateEvent, + ChannelUpdateEvent, + ThreadCreateEvent, + ThreadDeleteEvent, + ThreadListSyncEvent, + ThreadMemberUpdateEvent, + ThreadMembersUpdateEvent, + ThreadUpdateEvent; +export 'src/models/gateway/events/guild.dart' + show + GuildBanAddEvent, + GuildBanRemoveEvent, + GuildCreateEvent, + GuildDeleteEvent, + GuildAuditLogCreateEvent, + GuildEmojisUpdateEvent, + GuildIntegrationsUpdateEvent, + GuildMemberAddEvent, + GuildMemberRemoveEvent, + GuildMemberUpdateEvent, + GuildMembersChunkEvent, + GuildRoleCreateEvent, + GuildRoleDeleteEvent, + GuildRoleUpdateEvent, + GuildScheduledEventCreateEvent, + GuildScheduledEventDeleteEvent, + GuildScheduledEventUpdateEvent, + GuildScheduledEventUserAddEvent, + GuildScheduledEventUserRemoveEvent, + GuildStickersUpdateEvent, + GuildUpdateEvent, + UnavailableGuildCreateEvent; +export 'src/models/gateway/events/integration.dart' show IntegrationCreateEvent, IntegrationDeleteEvent, IntegrationUpdateEvent; +export 'src/models/gateway/events/interaction.dart' show InteractionCreateEvent; +export 'src/models/gateway/events/invite.dart' show InviteCreateEvent, InviteDeleteEvent; +export 'src/models/gateway/events/message.dart' + show + MessageBulkDeleteEvent, + MessageCreateEvent, + MessageDeleteEvent, + MessageReactionAddEvent, + MessageReactionRemoveAllEvent, + MessageReactionRemoveEmojiEvent, + MessageReactionRemoveEvent, + MessageUpdateEvent; +export 'src/models/gateway/events/presence.dart' show PresenceUpdateEvent, TypingStartEvent, UserUpdateEvent; +export 'src/models/gateway/events/ready.dart' show ReadyEvent, ResumedEvent; +export 'src/models/gateway/events/stage_instance.dart' show StageInstanceCreateEvent, StageInstanceDeleteEvent, StageInstanceUpdateEvent; +export 'src/models/gateway/events/voice.dart' show VoiceServerUpdateEvent, VoiceStateUpdateEvent; +export 'src/models/gateway/events/webhook.dart' show WebhooksUpdateEvent; +export 'src/models/presence.dart' + show Activity, ActivityAssets, ActivityButton, ActivityFlags, ActivityParty, ActivitySecrets, ActivityTimestamps, ClientStatus, ActivityType, UserStatus; +export 'src/models/emoji.dart' show Emoji, GuildEmoji, PartialEmoji, TextEmoji; +export 'src/models/sticker/guild_sticker.dart' show GuildSticker, PartialGuildSticker; +export 'src/models/sticker/global_sticker.dart' show GlobalSticker, PartialGlobalSticker; +export 'src/models/sticker/sticker.dart' show Sticker, StickerType, StickerFormatType, StickerItem; +export 'src/models/sticker/sticker_pack.dart' show StickerPack; +export 'src/models/commands/application_command.dart' show ApplicationCommand, PartialApplicationCommand, ApplicationCommandType; +export 'src/models/commands/application_command_option.dart' show CommandOption, CommandOptionChoice, CommandOptionType, CommandOptionMentionable; +export 'src/models/commands/application_command_permissions.dart' show CommandPermission, CommandPermissions, CommandPermissionType; +export 'src/models/team.dart' show Team, TeamMember, TeamMembershipState; +export 'src/models/interaction.dart' + show + ApplicationCommandInteractionData, + Interaction, + InteractionOption, + MessageComponentInteractionData, + MessageResponse, + ModalResponse, + ModalSubmitInteractionData, + ResolvedData, + InteractionType, + ApplicationCommandAutocompleteInteraction, + ApplicationCommandInteraction, + MessageComponentInteraction, + ModalSubmitInteraction, + PingInteraction; + +export 'src/utils/flags.dart' show Flag, Flags; +export 'src/intents.dart' show GatewayIntents; -export 'src/plugin/plugin.dart' show BasePlugin; -export 'src/plugin/plugin_manager.dart' show IPluginManager; -export 'src/plugin/plugins/cli_integration.dart' show CliIntegration; -export 'src/plugin/plugins/ignore_exception.dart' show IgnoreExceptions; -export 'src/plugin/plugins/logging.dart' show Logging; +export 'src/plugin/plugin.dart' show NyxxPlugin; +export 'src/plugin/logging.dart' show Logging, logging; +export 'src/plugin/cli_integration.dart' show CliIntegration, cliIntegration; +export 'src/plugin/ignore_exceptions.dart' show IgnoreExceptions, ignoreExceptions; -// Export classes used in the nyxx API to avoid users having to import the package themselves -export 'package:retry/retry.dart' show RetryOptions; -export 'package:logging/logging.dart' show Level; +// Types also used in the nyxx API from other packages +export 'package:http/http.dart' + // Don't export MultipartRequest as it conflicts with our MultipartRequest + show + BaseRequest, + Request, + MultipartFile, + BaseResponse, + StreamedResponse; +export 'package:logging/logging.dart' show Logger, Level; diff --git a/lib/src/api_options.dart b/lib/src/api_options.dart new file mode 100644 index 000000000..72130c88d --- /dev/null +++ b/lib/src/api_options.dart @@ -0,0 +1,148 @@ +import 'package:nyxx/src/builders/presence.dart'; +import 'package:nyxx/src/intents.dart'; +import 'package:nyxx/src/utils/flags.dart'; +import 'package:oauth2/oauth2.dart'; + +/// Options for connecting to the Discord API. +abstract class ApiOptions { + /// The version of nyxx used in [defaultUserAgent]. + static const nyxxVersion = '6.0.0-dev.2'; + + /// The URL to the nyxx repository used in [defaultUserAgent]. + static const nyxxRepositoryUrl = 'https://github.com/nyxx-discord/nyxx'; + + /// The default value for the `User-Agent` header for bots made with nyxx. + static const defaultUserAgent = 'Nyxx ($nyxxRepositoryUrl, $nyxxVersion)'; + + /// The host at which the API can be found. + /// + /// This is always `discord.com`. + String get host => 'discord.com'; + + /// The base URI relative to the [host] where the API can be found. + String get baseUri => '/api/v$apiVersion'; + + /// The version of the API to use. + int get apiVersion => 10; + + /// The value of the `Authorization` header to use when authenticating requests. + String get authorizationHeader; + + /// The value of the `User-Agent` header to send with each request. + final String userAgent; + + /// The host at which the CDN can be found. + /// + /// This is always `cdn.discordapp.com`. + String get cdnHost => 'cdn.discordapp.com'; + + /// Create a new [ApiOptions]. + ApiOptions({this.userAgent = defaultUserAgent}); +} + +/// Options for connecting to the Discord API to make HTTP requests with a bot token. +class RestApiOptions extends ApiOptions { + /// The token to use. + final String token; + + @override + String get authorizationHeader => 'Bot $token'; + + /// Create a new [RestApiOptions]. + RestApiOptions({required this.token, super.userAgent}); +} + +/// Options for connecting the the Discord API using credentials from an OAuth2 flow. +class OAuth2ApiOptions extends ApiOptions implements RestApiOptions { + /// The credentials to use when connecting to the API. + Credentials credentials; + + @override + String get token => credentials.accessToken; + + @override + String get authorizationHeader => 'Bearer ${credentials.accessToken}'; + + /// Create a new [OAuth2ApiOptions]. + OAuth2ApiOptions({required this.credentials, super.userAgent}); +} + +/// Options for connecting to the Discord API for making HTTP requests and connecting to the Gateway +/// with a bot token. +class GatewayApiOptions extends RestApiOptions { + /// The intents to use. + final Flags intents; + + /// The format of the Gateway payloads. + final GatewayPayloadFormat payloadFormat; + + /// The compression to use on the Gateway connection. + final GatewayCompression compression; + + /// The IDs of the shards to spawn by this client. + /// + /// If this is not set, the client spawns all shards from `0` to [totalShards]. + final List? shards; + + /// The total number of shards in the current session. + /// + /// If this is not set, the client will use the recommended shard count from Discord. + final int? totalShards; + + /// The threshold after which guilds are considered large in the Gateway. + final int? largeThreshold; + + /// The presence the client will set after first connecting to the Gateway. + final PresenceBuilder? initialPresence; + + /// The query parameters to append to the Gateway connection URL. + Map get gatewayConnectionOptions => { + 'v': apiVersion.toString(), + 'encoding': payloadFormat.value, + if (compression == GatewayCompression.transport) 'compress': 'zlib-stream', + }; + + /// Create a new [GatewayApiOptions]. + GatewayApiOptions({ + required super.token, + super.userAgent, + required this.intents, + this.payloadFormat = GatewayPayloadFormat.json, + this.compression = GatewayCompression.transport, + this.shards, + this.totalShards, + this.largeThreshold, + this.initialPresence, + }); +} + +/// The format of Gateway payloads. +enum GatewayPayloadFormat { + /// Payloads are sent as JSON. + json._('json'), + + /// Payloads are sent as ETF. + etf._('etf'); + + /// The value of this [GatewayPayloadFormat]. + final String value; + + const GatewayPayloadFormat._(this.value); + + @override + String toString() => value; +} + +/// The compression of a Gateway connection. +enum GatewayCompression { + /// No compression is used. + none, + + /// The entire connection is compressed. + transport, + + /// Each packet is individually compressed. + /// + /// Cannot be used if [GatewayPayloadFormat.etf] is used. + payload, +} diff --git a/lib/src/builders/application_command.dart b/lib/src/builders/application_command.dart new file mode 100644 index 000000000..8687a06a0 --- /dev/null +++ b/lib/src/builders/application_command.dart @@ -0,0 +1,419 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/commands/application_command.dart'; +import 'package:nyxx/src/models/commands/application_command_option.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +class ApplicationCommandBuilder extends CreateBuilder { + String name; + + Map? nameLocalizations; + + String? description; + + Map? descriptionLocalizations; + + List? options; + + Flags? defaultMemberPermissions; + + bool? hasDmPermission; + + ApplicationCommandType type; + + bool? isNsfw; + + ApplicationCommandBuilder({ + required this.name, + required this.type, + this.nameLocalizations, + this.description, + this.descriptionLocalizations, + this.options, + this.defaultMemberPermissions, + this.hasDmPermission, + this.isNsfw, + }); + + ApplicationCommandBuilder.chatInput({ + required this.name, + this.nameLocalizations, + required String this.description, + this.descriptionLocalizations, + required List this.options, + this.defaultMemberPermissions, + this.hasDmPermission, + this.isNsfw, + }) : type = ApplicationCommandType.chatInput; + + ApplicationCommandBuilder.message({ + required this.name, + this.nameLocalizations, + this.defaultMemberPermissions, + this.hasDmPermission, + this.isNsfw, + }) : type = ApplicationCommandType.message, + description = null, + descriptionLocalizations = null, + options = null; + + ApplicationCommandBuilder.user({ + required this.name, + this.nameLocalizations, + this.defaultMemberPermissions, + this.hasDmPermission, + this.isNsfw, + }) : type = ApplicationCommandType.user, + description = null, + descriptionLocalizations = null, + options = null; + + @override + Map build() => { + 'name': name, + if (nameLocalizations != null) 'name_localizations': {for (final MapEntry(:key, :value) in nameLocalizations!.entries) key.identifier: value}, + if (description != null) 'description': description, + if (descriptionLocalizations != null) + 'description_localizations': {for (final MapEntry(:key, :value) in descriptionLocalizations!.entries) key.identifier: value}, + if (options != null) 'options': options!.map((e) => e.build()).toList(), + if (defaultMemberPermissions != null) 'default_member_permissions': defaultMemberPermissions!.value.toString(), + if (hasDmPermission != null) 'dm_permission': hasDmPermission, + 'type': type.value, + if (isNsfw != null) 'nsfw': isNsfw, + }; +} + +class ApplicationCommandUpdateBuilder extends UpdateBuilder { + String? name; + + Map? nameLocalizations; + + String? description; + + Map? descriptionLocalizations; + + List? options; + + Flags? defaultMemberPermissions; + + bool? hasDmPermission; + + bool? isNsfw; + + ApplicationCommandUpdateBuilder({ + this.name, + this.nameLocalizations = sentinelMap, + this.description, + this.descriptionLocalizations = sentinelMap, + this.options, + this.defaultMemberPermissions = sentinelFlags, + this.hasDmPermission, + this.isNsfw, + }); + + ApplicationCommandUpdateBuilder.chatInput({ + required this.name, + this.nameLocalizations = sentinelMap, + this.description, + this.descriptionLocalizations = sentinelMap, + this.options, + this.defaultMemberPermissions, + this.hasDmPermission, + this.isNsfw, + }); + + ApplicationCommandUpdateBuilder.message({ + this.name, + this.nameLocalizations, + this.defaultMemberPermissions, + this.hasDmPermission, + this.isNsfw, + }) : description = null, + descriptionLocalizations = null, + options = null; + + ApplicationCommandUpdateBuilder.user({ + this.name, + this.nameLocalizations, + this.defaultMemberPermissions, + this.hasDmPermission, + this.isNsfw, + }) : description = null, + descriptionLocalizations = null, + options = null; + + @override + Map build() => { + if (name != null) 'name': name, + if (!identical(nameLocalizations, sentinelMap)) 'name_localizations': nameLocalizations?.map((key, value) => MapEntry(key.toString(), value)), + if (description != null) 'description': description, + if (!identical(descriptionLocalizations, sentinelMap)) + 'description_localizations': descriptionLocalizations?.map((key, value) => MapEntry(key.toString(), value)), + if (options != null) 'options': options!.map((e) => e.build()).toList(), + if (!identical(defaultMemberPermissions, sentinelFlags)) 'default_member_permissions': defaultMemberPermissions?.value.toString(), + if (hasDmPermission != null) 'dm_permission': hasDmPermission, + if (isNsfw != null) 'nsfw': isNsfw, + }; +} + +class CommandOptionBuilder extends CreateBuilder { + CommandOptionType type; + + String name; + + Map? nameLocalizations; + + String description; + + Map? descriptionLocalizations; + + bool? isRequired; + + List>? choices; + + List? options; + + List? channelTypes; + + num? minValue; + + num? maxValue; + + int? minLength; + + int? maxLength; + + bool? hasAutocomplete; + + CommandOptionBuilder({ + required this.type, + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + this.choices, + this.options, + this.channelTypes, + this.minValue, + this.maxValue, + this.minLength, + this.maxLength, + this.hasAutocomplete, + }); + + CommandOptionBuilder.subCommand({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + required List this.options, + }) : type = CommandOptionType.subCommand, + isRequired = null, + choices = null, + channelTypes = null, + minValue = null, + maxValue = null, + minLength = null, + maxLength = null, + hasAutocomplete = null; + + CommandOptionBuilder.subCommandGroup({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + required List this.options, + }) : type = CommandOptionType.subCommandGroup, + isRequired = null, + choices = null, + channelTypes = null, + minValue = null, + maxValue = null, + minLength = null, + maxLength = null, + hasAutocomplete = null; + + CommandOptionBuilder.string({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + List>? this.choices, + this.minLength, + this.maxLength, + this.hasAutocomplete, + }) : type = CommandOptionType.string, + options = null, + channelTypes = null, + minValue = null, + maxValue = null; + + CommandOptionBuilder.integer({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + List>? this.choices, + int? this.minValue, + int? this.maxValue, + this.hasAutocomplete, + }) : type = CommandOptionType.integer, + options = null, + channelTypes = null, + minLength = null, + maxLength = null; + + CommandOptionBuilder.boolean({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + }) : type = CommandOptionType.boolean, + choices = null, + options = null, + channelTypes = null, + minValue = null, + maxValue = null, + minLength = null, + maxLength = null, + hasAutocomplete = null; + + CommandOptionBuilder.user({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + }) : type = CommandOptionType.user, + choices = null, + options = null, + channelTypes = null, + minValue = null, + maxValue = null, + minLength = null, + maxLength = null, + hasAutocomplete = null; + + CommandOptionBuilder.channel({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + this.channelTypes, + }) : type = CommandOptionType.channel, + choices = null, + options = null, + minValue = null, + maxValue = null, + minLength = null, + maxLength = null, + hasAutocomplete = null; + + CommandOptionBuilder.role({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + }) : type = CommandOptionType.role, + choices = null, + options = null, + channelTypes = null, + minValue = null, + maxValue = null, + minLength = null, + maxLength = null, + hasAutocomplete = null; + + CommandOptionBuilder.mentionable({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + }) : type = CommandOptionType.mentionable, + choices = null, + options = null, + channelTypes = null, + minValue = null, + maxValue = null, + minLength = null, + maxLength = null, + hasAutocomplete = null; + + CommandOptionBuilder.number({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + List>? this.choices, + double? this.minValue, + double? this.maxValue, + this.hasAutocomplete, + }) : type = CommandOptionType.number, + options = null, + channelTypes = null, + minLength = null, + maxLength = null; + + CommandOptionBuilder.attachment({ + required this.name, + this.nameLocalizations, + required this.description, + this.descriptionLocalizations, + this.isRequired, + }) : type = CommandOptionType.attachment, + choices = null, + options = null, + channelTypes = null, + minValue = null, + maxValue = null, + minLength = null, + maxLength = null, + hasAutocomplete = null; + + @override + Map build() => { + 'type': type.value, + 'name': name, + if (nameLocalizations != null) 'name_localizations': {for (final MapEntry(:key, :value) in nameLocalizations!.entries) key.identifier: value}, + 'description': description, + if (descriptionLocalizations != null) + 'description_localizations': {for (final MapEntry(:key, :value) in nameLocalizations!.entries) key.identifier: value}, + if (isRequired != null) 'required': isRequired, + if (choices != null) 'choices': choices!.map((e) => e.build()).toList(), + if (options != null) 'options': options!.map((e) => e.build()).toList(), + if (channelTypes != null) 'channel_types': channelTypes!.map((e) => e.value).toList(), + if (minValue != null) 'min_value': minValue, + if (maxValue != null) 'max_value': maxValue, + if (minLength != null) 'min_length': minLength, + if (maxLength != null) 'max_length': maxLength, + if (hasAutocomplete != null) 'autocomplete': hasAutocomplete, + }; +} + +class CommandOptionChoiceBuilder extends CreateBuilder { + String name; + + Map? nameLocalizations; + + T value; + + CommandOptionChoiceBuilder({required this.name, this.nameLocalizations, required this.value}); + + @override + Map build() => { + 'name': name, + if (nameLocalizations != null) 'name_localizations': {for (final MapEntry(:key, :value) in nameLocalizations!.entries) key.identifier: value}, + 'value': value, + }; +} diff --git a/lib/src/builders/application_role_connection.dart b/lib/src/builders/application_role_connection.dart new file mode 100644 index 000000000..4b49e2c66 --- /dev/null +++ b/lib/src/builders/application_role_connection.dart @@ -0,0 +1,20 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/user/application_role_connection.dart'; + +class ApplicationRoleConnectionUpdateBuilder extends UpdateBuilder { + String? platformName; + + String? platformUsername; + + Map? metadata; + + ApplicationRoleConnectionUpdateBuilder({this.platformName = sentinelString, this.platformUsername = sentinelString, this.metadata}); + + @override + Map build() => { + if (!identical(platformName, sentinelString)) 'platform_name': platformName, + if (!identical(platformUsername, sentinelString)) 'platform_username': platformUsername, + if (metadata != null) 'metadata': metadata, + }; +} diff --git a/lib/src/builders/builder.dart b/lib/src/builders/builder.dart new file mode 100644 index 000000000..e5782858c --- /dev/null +++ b/lib/src/builders/builder.dart @@ -0,0 +1,13 @@ +abstract class Builder { + const Builder(); + + Map build(); +} + +abstract class CreateBuilder extends Builder { + const CreateBuilder(); +} + +abstract class UpdateBuilder extends Builder { + const UpdateBuilder(); +} diff --git a/lib/src/builders/channel/channel_position.dart b/lib/src/builders/channel/channel_position.dart new file mode 100644 index 000000000..e41a1d7b1 --- /dev/null +++ b/lib/src/builders/channel/channel_position.dart @@ -0,0 +1,28 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class ChannelPositionBuilder extends UpdateBuilder { + Snowflake channelId; + + int? position; + + bool? lockPermissions; + + Snowflake? parentId; + + ChannelPositionBuilder({ + required this.channelId, + this.position, + this.lockPermissions, + this.parentId, + }); + + @override + Map build() => { + 'id': channelId.toString(), + if (position != null) 'position': position, + if (lockPermissions != null) 'lock_permissions': lockPermissions, + if (parentId != null) 'parent_id': parentId!.toString(), + }; +} diff --git a/lib/src/builders/channel/forum_tag.dart b/lib/src/builders/channel/forum_tag.dart new file mode 100644 index 000000000..3542fca88 --- /dev/null +++ b/lib/src/builders/channel/forum_tag.dart @@ -0,0 +1,23 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/channel/types/forum.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class ForumTagBuilder extends CreateBuilder { + String name; + + bool? isModerated; + + Snowflake? emojiId; + + String? emojiName; + + ForumTagBuilder({required this.name, this.isModerated, this.emojiId, this.emojiName}); + + @override + Map build() => { + 'name': name, + if (isModerated != null) 'moderated': isModerated, + if (emojiId != null) 'emoji_id': emojiId!.toString(), + if (emojiName != null) 'emoji_name': emojiName, + }; +} diff --git a/lib/src/builders/channel/group_dm.dart b/lib/src/builders/channel/group_dm.dart new file mode 100644 index 000000000..99a40ee89 --- /dev/null +++ b/lib/src/builders/channel/group_dm.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/channel/types/group_dm.dart'; + +class GroupDmUpdateBuilder extends UpdateBuilder { + String? name; + + List? icon; + + GroupDmUpdateBuilder({this.name, this.icon}); + + @override + Map build() => { + if (name != null) 'name': name, + if (icon != null) 'icon': base64Encode(icon!), + }; +} diff --git a/lib/src/builders/channel/guild_channel.dart b/lib/src/builders/channel/guild_channel.dart new file mode 100644 index 000000000..6016f440c --- /dev/null +++ b/lib/src/builders/channel/guild_channel.dart @@ -0,0 +1,449 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/types/forum.dart'; +import 'package:nyxx/src/models/channel/types/guild_announcement.dart'; +import 'package:nyxx/src/models/channel/types/guild_category.dart'; +import 'package:nyxx/src/models/channel/types/guild_stage.dart'; +import 'package:nyxx/src/models/channel/types/guild_text.dart'; +import 'package:nyxx/src/models/channel/types/guild_voice.dart'; +import 'package:nyxx/src/models/channel/voice_channel.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +class GuildChannelBuilder extends CreateBuilder { + String name; + + ChannelType type; + + int? position; + + List>? permissionOverwrites; + + GuildChannelBuilder({ + required this.name, + required this.type, + this.position, + this.permissionOverwrites, + }); + + @override + Map build() => { + 'name': name, + 'type': type.value, + if (position != null) 'position': position, + if (permissionOverwrites != null) 'permission_overwrites': permissionOverwrites!.map((e) => e.build()).toList(), + }; +} + +class GuildChannelUpdateBuilder extends UpdateBuilder { + String? name; + + int? position; + + List>? permissionOverwrites; + + GuildChannelUpdateBuilder({this.name, this.position = sentinelInteger, this.permissionOverwrites}); + + @override + Map build() => { + if (name != null) 'name': name, + if (!identical(position, sentinelInteger)) 'position': position, + if (permissionOverwrites != null) 'permission_overwrites': permissionOverwrites!.map((e) => e.build()).toList(), + }; +} + +class GuildTextChannelBuilder extends GuildChannelBuilder { + String? topic; + + Duration? rateLimitPerUser; + + Snowflake? parentId; + + bool? isNsfw; + + Duration? defaultAutoArchiveDuration; + + GuildTextChannelBuilder({ + required super.name, + super.position, + super.permissionOverwrites, + this.topic, + this.rateLimitPerUser, + this.parentId, + this.isNsfw, + this.defaultAutoArchiveDuration, + }) : super(type: ChannelType.guildText); + + @override + Map build() => { + ...super.build(), + if (topic != null) 'topic': topic, + if (rateLimitPerUser != null) 'rate_limit_per_user': rateLimitPerUser!.inSeconds, + if (parentId != null) 'parent_id': parentId!.toString(), + if (isNsfw != null) 'nsfw': isNsfw, + if (defaultAutoArchiveDuration != null) 'default_auto_archive_duration': defaultAutoArchiveDuration!.inMinutes, + }; +} + +class GuildTextChannelUpdateBuilder extends GuildChannelUpdateBuilder { + ChannelType? type; + + String? topic; + + bool? isNsfw; + + Duration? rateLimitPerUser; + + Snowflake? parentId; + + Duration? defaultAutoArchiveDuration; + + Duration? defaultThreadRateLimitPerUser; + + GuildTextChannelUpdateBuilder({ + super.name, + super.position, + super.permissionOverwrites, + this.type, + this.topic = sentinelString, + this.isNsfw, + this.rateLimitPerUser = sentinelDuration, + this.parentId = sentinelSnowflake, + this.defaultAutoArchiveDuration = sentinelDuration, + this.defaultThreadRateLimitPerUser, + }); + + @override + Map build() => { + ...super.build(), + if (type != null) 'type': type!.value, + if (!identical(topic, sentinelString)) 'topic': topic, + if (isNsfw != null) 'nsfw': isNsfw, + if (!identical(rateLimitPerUser, sentinelDuration)) 'rate_limit_per_user': rateLimitPerUser?.inSeconds, + if (!identical(parentId, sentinelSnowflake)) 'parent_id': parentId?.toString(), + if (!identical(defaultAutoArchiveDuration, sentinelDuration)) 'default_auto_archive_duration': defaultAutoArchiveDuration?.inMinutes, + if (defaultThreadRateLimitPerUser != null) 'default_thread_rate_limit_per_user': defaultThreadRateLimitPerUser!.inSeconds, + }; +} + +class GuildAnnouncementChannelBuilder extends GuildChannelBuilder { + String? topic; + + Snowflake? parentId; + + bool? isNsfw; + + Duration? defaultAutoArchiveDuration; + + GuildAnnouncementChannelBuilder({ + required super.name, + super.position, + super.permissionOverwrites, + this.topic, + this.parentId, + this.isNsfw, + this.defaultAutoArchiveDuration, + }) : super(type: ChannelType.guildAnnouncement); + + @override + Map build() => { + ...super.build(), + if (topic != null) 'topic': topic, + if (parentId != null) 'parent_id': parentId!.toString(), + if (isNsfw != null) 'nsfw': isNsfw, + if (defaultAutoArchiveDuration != null) 'default_auto_archive_duration': defaultAutoArchiveDuration!.inMinutes, + }; +} + +class GuildAnnouncementChannelUpdateBuilder extends GuildChannelUpdateBuilder { + ChannelType? type; + + String? topic; + + bool? isNsfw; + + Snowflake? parentId; + + Duration? defaultAutoArchiveDuration; + + GuildAnnouncementChannelUpdateBuilder({ + super.name, + super.position, + super.permissionOverwrites, + this.type, + this.topic = sentinelString, + this.isNsfw, + this.parentId = sentinelSnowflake, + this.defaultAutoArchiveDuration = sentinelDuration, + }); + + @override + Map build() => { + ...super.build(), + if (type != null) 'type': type!.value, + if (!identical(topic, sentinelString)) 'topic': topic, + if (isNsfw != null) 'nsfw': isNsfw, + if (!identical(parentId, sentinelSnowflake)) 'parent_id': parentId?.toString(), + if (!identical(defaultAutoArchiveDuration, sentinelDuration)) 'default_auto_archive_duration': defaultAutoArchiveDuration?.inMinutes, + }; +} + +class ForumChannelBuilder extends GuildChannelBuilder { + String? topic; + + Duration? rateLimitPerUser; + + Snowflake? parentId; + + bool? isNsfw; + + Duration? defaultAutoArchiveDuration; + + DefaultReaction? defaultReaction; + + List>? tags; + + ForumSort? defaultSortOrder; + + ForumChannelBuilder({ + required super.name, + super.position, + super.permissionOverwrites, + this.topic, + this.rateLimitPerUser, + this.parentId, + this.isNsfw, + this.defaultAutoArchiveDuration, + this.defaultReaction, + this.tags, + this.defaultSortOrder, + }) : super(type: ChannelType.guildForum); + + @override + Map build() => { + ...super.build(), + if (topic != null) 'topic': topic, + if (rateLimitPerUser != null) 'rate_limit_per_user': rateLimitPerUser!.inSeconds, + if (parentId != null) 'parent_id': parentId!.toString(), + if (isNsfw != null) 'nsfw': isNsfw, + if (defaultAutoArchiveDuration != null) 'default_auto_archive_duration': defaultAutoArchiveDuration!.inMinutes, + if (!identical(defaultReaction, sentinelDefaultReaction)) + 'default_reaction_emoji': defaultReaction == null + ? null + : { + if (defaultReaction!.emojiId != null) 'emoji_id': defaultReaction!.emojiId!.toString(), + if (defaultReaction!.emojiName != null) 'emoji_name': defaultReaction!.emojiName, + }, + if (tags != null) 'available_tags': tags!.map((e) => e.build()).toList(), + if (defaultSortOrder != null) 'default_sort_order': defaultSortOrder!.value, + }; +} + +class ForumChannelUpdateBuilder extends GuildChannelUpdateBuilder { + String? topic; + + bool? isNsfw; + + Duration? rateLimitPerUser; + + Snowflake? parentId; + + Duration? defaultAutoArchiveDuration; + + Flags? flags; + + List>? tags; + + DefaultReaction? defaultReaction; + + Duration? defaultThreadRateLimitPerUser; + + ForumSort? defaultSortOrder; + + ForumLayout? defaultLayout; + + ForumChannelUpdateBuilder({ + super.name, + super.position, + super.permissionOverwrites, + this.topic, + this.isNsfw, + this.rateLimitPerUser = sentinelDuration, + this.parentId = sentinelSnowflake, + this.defaultAutoArchiveDuration = sentinelDuration, + this.flags, + this.tags, + this.defaultReaction = sentinelDefaultReaction, + this.defaultThreadRateLimitPerUser, + this.defaultSortOrder, + this.defaultLayout, + }); + + @override + Map build() => { + ...super.build(), + if (topic != null) 'topic': topic, + if (isNsfw != null) 'nsfw': isNsfw, + if (!identical(rateLimitPerUser, sentinelDuration)) 'rate_limit_per_user': rateLimitPerUser?.inSeconds, + if (!identical(parentId, sentinelSnowflake)) 'parent_id': parentId?.toString(), + if (!identical(defaultAutoArchiveDuration, sentinelDuration)) 'default_auto_archive_duration': defaultAutoArchiveDuration?.inMinutes, + if (flags != null) 'flags': flags!.value, + if (tags != null) 'available_tags': tags!.map((e) => e.build()).toList(), + if (!identical(defaultReaction, sentinelDefaultReaction)) + 'default_reaction_emoji': defaultReaction == null + ? null + : { + if (defaultReaction!.emojiId != null) 'emoji_id': defaultReaction!.emojiId!.toString(), + if (defaultReaction!.emojiName != null) 'emoji_name': defaultReaction!.emojiName, + }, + if (defaultThreadRateLimitPerUser != null) 'default_thread_rate_limit_per_user': defaultThreadRateLimitPerUser!.inSeconds, + if (defaultSortOrder != null) 'default_sort_order': defaultSortOrder!.value, + if (defaultLayout != null) 'default_forum_layout': defaultLayout!.value, + }; +} + +abstract class _GuildVoiceOrStageChannelBuilder extends GuildChannelBuilder { + int? bitRate; + + int? userLimit; + + Snowflake? parentId; + + bool? isNsfw; + + String? rtcRegion; + + VideoQualityMode? videoQualityMode; + + _GuildVoiceOrStageChannelBuilder({ + required super.name, + required super.type, + super.position, + super.permissionOverwrites, + this.bitRate, + this.userLimit, + this.parentId, + this.isNsfw, + this.rtcRegion, + this.videoQualityMode, + }); + + @override + Map build() => { + ...super.build(), + if (isNsfw != null) 'nsfw': isNsfw, + if (bitRate != null) 'bitrate': bitRate, + if (userLimit != null) 'user_limit': userLimit, + if (parentId != null) 'parent_id': parentId?.toString(), + if (rtcRegion != null) 'rtc_region': rtcRegion, + if (videoQualityMode != null) 'video_quality_mode': videoQualityMode!.value, + }; +} + +class GuildVoiceChannelBuilder extends _GuildVoiceOrStageChannelBuilder { + GuildVoiceChannelBuilder({ + required super.name, + super.position, + super.permissionOverwrites, + super.bitRate, + super.userLimit, + super.parentId, + super.isNsfw, + super.rtcRegion, + super.videoQualityMode, + }) : super(type: ChannelType.guildVoice); +} + +class GuildStageChannelBuilder extends _GuildVoiceOrStageChannelBuilder { + GuildStageChannelBuilder({ + required super.name, + super.position, + super.permissionOverwrites, + super.bitRate, + super.userLimit, + super.parentId, + super.isNsfw, + super.rtcRegion, + super.videoQualityMode, + }) : super(type: ChannelType.guildStageVoice); +} + +class _GuildVoiceOrStageChannelUpdateBuilder extends GuildChannelUpdateBuilder { + bool? isNsfw; + + int? bitRate; + + int? userLimit; + + Snowflake? parentId; + + String? rtcRegion; + + VideoQualityMode? videoQualityMode; + + _GuildVoiceOrStageChannelUpdateBuilder({ + super.name, + super.position, + super.permissionOverwrites, + this.isNsfw, + this.bitRate, + this.userLimit, + this.parentId = sentinelSnowflake, + this.rtcRegion = sentinelString, + this.videoQualityMode, + }); + + @override + Map build() => { + ...super.build(), + if (isNsfw != null) 'nsfw': isNsfw, + if (bitRate != null) 'bitrate': bitRate, + if (userLimit != null) 'user_limit': userLimit, + if (!identical(parentId, sentinelSnowflake)) 'parent_id': parentId?.toString(), + if (!identical(rtcRegion, sentinelString)) 'rtc_region': rtcRegion, + if (videoQualityMode != null) 'video_quality_mode': videoQualityMode!.value, + }; +} + +class GuildVoiceChannelUpdateBuilder extends _GuildVoiceOrStageChannelUpdateBuilder { + GuildVoiceChannelUpdateBuilder({ + super.name, + super.position, + super.permissionOverwrites, + super.isNsfw, + super.bitRate, + super.userLimit, + super.parentId = sentinelSnowflake, + super.rtcRegion = sentinelString, + super.videoQualityMode, + }); +} + +class GuildStageChannelUpdateBuilder extends _GuildVoiceOrStageChannelUpdateBuilder { + GuildStageChannelUpdateBuilder({ + super.name, + super.position, + super.permissionOverwrites, + super.isNsfw, + super.bitRate, + super.userLimit, + super.parentId = sentinelSnowflake, + super.rtcRegion = sentinelString, + super.videoQualityMode, + }); +} + +class GuildCategoryBuilder extends GuildChannelBuilder { + GuildCategoryBuilder({ + required super.name, + super.position, + super.permissionOverwrites, + }) : super(type: ChannelType.guildCategory); +} + +class GuildCategoryUpdateBuilder extends GuildChannelUpdateBuilder { + GuildCategoryUpdateBuilder({super.name, super.position, super.permissionOverwrites}); +} diff --git a/lib/src/builders/channel/stage_instance.dart b/lib/src/builders/channel/stage_instance.dart new file mode 100644 index 000000000..6fa0d3107 --- /dev/null +++ b/lib/src/builders/channel/stage_instance.dart @@ -0,0 +1,37 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/channel/stage_instance.dart'; + +class StageInstanceBuilder extends CreateBuilder { + String topic; + + PrivacyLevel? privacyLevel; + + bool? sendStartNotification; + + StageInstanceBuilder({ + required this.topic, + this.privacyLevel, + this.sendStartNotification, + }); + + @override + Map build() => { + 'topic': topic, + if (privacyLevel != null) 'privacy_level': privacyLevel!.value, + if (sendStartNotification != null) 'send_start_notification': sendStartNotification, + }; +} + +class StageInstanceUpdateBuilder extends UpdateBuilder { + String? topic; + + PrivacyLevel? privacyLevel; + + StageInstanceUpdateBuilder({this.topic, this.privacyLevel}); + + @override + Map build() => { + if (topic != null) 'topic': topic, + if (privacyLevel != null) 'privacy_level': privacyLevel!.value, + }; +} diff --git a/lib/src/builders/channel/thread.dart b/lib/src/builders/channel/thread.dart new file mode 100644 index 000000000..f838d1f10 --- /dev/null +++ b/lib/src/builders/channel/thread.dart @@ -0,0 +1,120 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +class ThreadFromMessageBuilder extends CreateBuilder { + String name; + + Duration? autoArchiveDuration; + + Duration? rateLimitPerUser; + + ThreadFromMessageBuilder({required this.name, this.autoArchiveDuration, this.rateLimitPerUser}); + + @override + Map build() => { + 'name': name, + if (autoArchiveDuration != null) 'auto_archive_duration': autoArchiveDuration!.inMinutes, + if (rateLimitPerUser != null) 'rate_limit_per_user': rateLimitPerUser!.inSeconds, + }; +} + +class ThreadBuilder extends CreateBuilder { + static const archiveOneHour = Duration(minutes: 60); + static const archiveOneDay = Duration(minutes: 1440); + static const archiveThreeDays = Duration(minutes: 4320); + static const archiveOneWeek = Duration(minutes: 10080); + + String name; + + Duration? autoArchiveDuration; + + ChannelType type; + + bool? invitable; + + Duration? rateLimitPerUser; + + ThreadBuilder({required this.name, this.autoArchiveDuration, required this.type, this.invitable, this.rateLimitPerUser}); + + ThreadBuilder.publicThread({required this.name, this.autoArchiveDuration, this.rateLimitPerUser}) : type = ChannelType.publicThread; + + ThreadBuilder.privateThread({required this.name, this.autoArchiveDuration, this.invitable, this.rateLimitPerUser}) : type = ChannelType.privateThread; + + @override + Map build() => { + 'name': name, + if (autoArchiveDuration != null) 'auto_archive_duration': autoArchiveDuration!.inMinutes, + 'type': type.value, + if (invitable != null) 'invitable': invitable, + if (rateLimitPerUser != null) 'rate_limit_per_user': rateLimitPerUser!.inSeconds, + }; +} + +class ForumThreadBuilder extends CreateBuilder { + String name; + + Duration? autoArchiveDuration; + + Duration? rateLimitPerUser; + + MessageBuilder message; + + List? appliedTags; + + ForumThreadBuilder({required this.name, this.autoArchiveDuration, this.rateLimitPerUser, required this.message, this.appliedTags}); + + @override + Map build() => { + 'name': name, + if (autoArchiveDuration != null) 'auto_archive_duration': autoArchiveDuration!.inMinutes, + if (rateLimitPerUser != null) 'rate_limit_per_user': rateLimitPerUser!.inSeconds, + 'message': message.build(), + if (appliedTags != null) 'applied_tags': appliedTags!.map((e) => e.toString()).toList(), + }; +} + +class ThreadUpdateBuilder extends UpdateBuilder { + String? name; + + bool? isArchived; + + Duration? autoArchiveDuration; + + bool? isLocked; + + bool? isInvitable; + + Duration? rateLimitPerUser; + + Flags? flags; + + List? appliedTags; + + ThreadUpdateBuilder({ + this.name, + this.isArchived, + this.autoArchiveDuration, + this.isLocked, + this.isInvitable, + this.rateLimitPerUser = sentinelDuration, + this.flags, + this.appliedTags, + }); + + @override + Map build() => { + if (name != null) 'name': name, + if (isArchived != null) 'archived': isArchived, + if (autoArchiveDuration != null) 'auto_archive_duration': autoArchiveDuration!.inMinutes, + if (isLocked != null) 'locked': isLocked, + if (isInvitable != null) 'invitable': isInvitable, + if (!identical(rateLimitPerUser, sentinelDuration)) 'rate_limit_per_user': rateLimitPerUser?.inSeconds, + if (flags != null) 'flags': flags!.value, + if (appliedTags != null) 'applied_tags': appliedTags!.map((e) => e.toString()).toList(), + }; +} diff --git a/lib/src/builders/emoji/emoji.dart b/lib/src/builders/emoji/emoji.dart new file mode 100644 index 000000000..a0bee4642 --- /dev/null +++ b/lib/src/builders/emoji/emoji.dart @@ -0,0 +1,47 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class EmojiBuilder implements CreateBuilder { + /// The name of the emoji. + String name; + + /// The 128x128 emoji image. + ImageBuilder image; + + /// The roles allowed to use this emoji. + Iterable roles; + + EmojiBuilder({ + required this.name, + required this.image, + required this.roles, + }); + + @override + Map build() => { + 'name': name, + 'image': image.buildDataString(), + 'roles': roles.map((s) => s.toString()).toList(), + }; +} + +class EmojiUpdateBuilder implements UpdateBuilder { + /// The name of the emoji. + String? name; + + /// The roles allowed to use this emoji. + Iterable? roles; + + EmojiUpdateBuilder({ + this.name, + this.roles, + }); + + @override + Map build() => { + if (name != null) 'name': name, + if (roles != null) 'roles': roles!.map((s) => s.toString()).toList(), + }; +} diff --git a/lib/src/builders/emoji/reaction.dart b/lib/src/builders/emoji/reaction.dart new file mode 100644 index 000000000..c86416ea4 --- /dev/null +++ b/lib/src/builders/emoji/reaction.dart @@ -0,0 +1,17 @@ +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class ReactionBuilder { + String name; + + Snowflake? id; + + ReactionBuilder({required this.name, required this.id}); + + factory ReactionBuilder.fromEmoji(Emoji emoji) => ReactionBuilder( + name: emoji.name!, + id: emoji.id == Snowflake.zero ? null : emoji.id, + ); + + String build() => '$name${id == null ? '' : ':$id'}'; +} diff --git a/lib/src/builders/guild/auto_moderation.dart b/lib/src/builders/guild/auto_moderation.dart new file mode 100644 index 000000000..43eee516b --- /dev/null +++ b/lib/src/builders/guild/auto_moderation.dart @@ -0,0 +1,120 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/guild/auto_moderation.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class AutoModerationRuleBuilder extends CreateBuilder { + String name; + + AutoModerationEventType eventType; + + TriggerType triggerType; + + TriggerMetadata? metadata; + + List actions; + + bool? isEnabled; + + List? exemptRoleIds; + + List? exemptChannelIds; + + AutoModerationRuleBuilder({ + required this.name, + required this.eventType, + required this.triggerType, + this.metadata, + required this.actions, + this.isEnabled, + this.exemptRoleIds, + this.exemptChannelIds, + }); + + @override + Map build() => { + 'name': name, + 'event_type': eventType.value, + 'trigger_type': triggerType.value, + if (metadata != null) + 'trigger_metadata': { + 'keyword_filter': metadata!.keywordFilter, + 'regex_patterns': metadata!.regexPatterns, + 'presets': metadata!.presets?.map((type) => type.value).toList(), + 'allow_list': metadata!.allowList, + 'mention_total_limit': metadata!.mentionTotalLimit, + 'mention_raid_protection_enabled': metadata!.isMentionRaidProtectionEnabled, + }, + 'actions': [ + for (final action in actions) + { + 'type': action.type.value, + if (action.metadata != null) + 'metadata': { + 'channel_id': action.metadata!.channelId?.toString(), + 'duration_seconds': action.metadata!.duration?.inSeconds, + 'custom_message': action.metadata!.customMessage, + } + } + ], + if (isEnabled != null) 'enabled': isEnabled, + if (exemptRoleIds != null) 'exempt_roles': exemptRoleIds!.map((id) => id.toString()).toList(), + if (exemptChannelIds != null) 'exempt_channels': exemptChannelIds!.map((id) => id.toString()).toList(), + }; +} + +class AutoModerationRuleUpdateBuilder extends UpdateBuilder { + String? name; + + AutoModerationEventType? eventType; + + TriggerMetadata? metadata; + + List? actions; + + bool? isEnabled; + + List? exemptRoleIds; + + List? exemptChannelIds; + + AutoModerationRuleUpdateBuilder({ + this.name, + this.eventType, + this.metadata, + this.actions, + this.isEnabled, + this.exemptRoleIds, + this.exemptChannelIds, + }); + + @override + Map build() => { + if (name != null) 'name': name, + if (eventType != null) 'event_type': eventType!.value, + if (metadata != null) + 'trigger_metadata': { + 'keyword_filter': metadata!.keywordFilter, + 'regex_patterns': metadata!.regexPatterns, + 'presets': metadata!.presets?.map((type) => type.value).toList(), + 'allow_list': metadata!.allowList, + 'mention_total_limit': metadata!.mentionTotalLimit, + 'mention_raid_protection_enabled': metadata!.isMentionRaidProtectionEnabled, + }, + if (actions != null) + 'actions': [ + for (final action in actions!) + { + 'type': action.type.value, + if (action.metadata != null) + 'metadata': { + 'channel_id': action.metadata!.channelId?.toString(), + 'duration_seconds': action.metadata!.duration?.inSeconds, + 'custom_message': action.metadata!.customMessage, + } + } + ], + if (isEnabled != null) 'enabled': isEnabled, + if (exemptRoleIds != null) 'exempt_roles': exemptRoleIds!.map((id) => id.toString()).toList(), + if (exemptChannelIds != null) 'exempt_channels': exemptChannelIds!.map((id) => id.toString()).toList(), + }; +} diff --git a/lib/src/builders/guild/guild.dart b/lib/src/builders/guild/guild.dart new file mode 100644 index 000000000..374a45939 --- /dev/null +++ b/lib/src/builders/guild/guild.dart @@ -0,0 +1,148 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/channel/guild_channel.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/builders/role.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/http/managers/guild_manager.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +class GuildBuilder extends CreateBuilder { + String name; + + ImageBuilder? icon; + + VerificationLevel? verificationLevel; + + MessageNotificationLevel? defaultMessageNotificationLevel; + + ExplicitContentFilterLevel? explicitContentFilterLevel; + + List? roles; + + List? channels; + + Snowflake? afkChannelId; + + Duration? afkTimeout; + + Snowflake? systemChannelId; + + Flags? systemChannelFlags; + + GuildBuilder({ + required this.name, + this.icon, + this.verificationLevel, + this.defaultMessageNotificationLevel, + this.explicitContentFilterLevel, + this.roles, + this.channels, + this.afkChannelId, + this.afkTimeout, + this.systemChannelId, + this.systemChannelFlags, + }); + + @override + Map build() => { + 'name': name, + if (icon != null) 'icon': icon!.buildDataString(), + if (verificationLevel != null) 'verification_level': verificationLevel!.value, + if (defaultMessageNotificationLevel != null) 'default_message_notification_level': defaultMessageNotificationLevel!.value, + if (explicitContentFilterLevel != null) 'explicit_content_filter_level': explicitContentFilterLevel!.value, + if (roles != null) 'roles': roles!.map((b) => b.build()).toList(), + if (channels != null) 'channels': channels!.map((b) => b.build()).toList(), + if (afkChannelId != null) 'afk_channel_id': afkChannelId!.toString(), + if (afkTimeout != null) 'afk_timeout': afkTimeout!.inSeconds, + if (systemChannelId != null) 'system_channel_id': systemChannelId!.toString(), + if (systemChannelFlags != null) 'system_channel_flags': systemChannelFlags!.value, + }; +} + +class GuildUpdateBuilder extends UpdateBuilder { + String? name; + + VerificationLevel? verificationLevel; + + MessageNotificationLevel? defaultMessageNotificationLevel; + + ExplicitContentFilterLevel? explicitContentFilterLevel; + + Snowflake? afkChannelId; + + Duration? afkTimeout; + + ImageBuilder? icon; + + Snowflake? newOwnerId; + + ImageBuilder? splash; + + ImageBuilder? discoverySplash; + + ImageBuilder? banner; + + Snowflake? systemChannelId; + + Flags? systemChannelFlags; + + Snowflake? rulesChannelId; + + Snowflake? publicUpdatesChannelId; + + Locale? preferredLocale; + + Flags? features; + + String? description; + + bool? premiumProgressBarEnabled; + + GuildUpdateBuilder({ + this.name, + this.verificationLevel, + this.defaultMessageNotificationLevel, + this.explicitContentFilterLevel, + this.afkChannelId = sentinelSnowflake, + this.afkTimeout, + this.icon = sentinelImageBuilder, + this.newOwnerId, + this.splash = sentinelImageBuilder, + this.discoverySplash = sentinelImageBuilder, + this.banner = sentinelImageBuilder, + this.systemChannelId, + this.systemChannelFlags, + this.rulesChannelId, + this.publicUpdatesChannelId, + this.preferredLocale, + this.features, + this.description = sentinelString, + this.premiumProgressBarEnabled, + }); + + @override + Map build() => { + if (name != null) 'name': name, + if (verificationLevel != null) 'verificationLevel': verificationLevel!.value, + if (defaultMessageNotificationLevel != null) 'defaultMessageNotificationLevel': defaultMessageNotificationLevel!.value, + if (explicitContentFilterLevel != null) 'explicitContentFilterLevel': explicitContentFilterLevel!.value, + if (!identical(afkChannelId, sentinelSnowflake)) 'afkChannelId': afkChannelId?.toString(), + if (afkTimeout != null) 'afkTimeout': afkTimeout!.inSeconds, + if (!identical(icon, sentinelImageBuilder)) 'icon': icon?.buildDataString(), + if (newOwnerId != null) 'newOwnerId': newOwnerId!.toString(), + if (!identical(splash, sentinelImageBuilder)) 'splash': splash?.buildDataString(), + if (!identical(discoverySplash, sentinelImageBuilder)) 'discoverySplash': discoverySplash?.buildDataString(), + if (!identical(banner, sentinelImageBuilder)) 'banner': banner?.buildDataString(), + if (systemChannelId != null) 'systemChannelId': systemChannelId!.toString(), + if (systemChannelFlags != null) 'systemChannelFlags': systemChannelFlags!.value, + if (rulesChannelId != null) 'rulesChannelId': rulesChannelId!.toString(), + if (publicUpdatesChannelId != null) 'publicUpdatesChannelId': publicUpdatesChannelId!.toString(), + if (preferredLocale != null) 'preferredLocale': preferredLocale!.identifier, + if (features != null) 'features': GuildManager.serializeGuildFeatures(features!), + if (!identical(description, sentinelString)) 'description': description, + if (premiumProgressBarEnabled != null) 'premiumProgressBarEnabled': premiumProgressBarEnabled, + }; +} diff --git a/lib/src/builders/guild/member.dart b/lib/src/builders/guild/member.dart new file mode 100644 index 000000000..6e2ec5112 --- /dev/null +++ b/lib/src/builders/guild/member.dart @@ -0,0 +1,85 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +class MemberBuilder extends CreateBuilder { + String accessToken; + + Snowflake userId; + + String? nick; + + List? roleIds; + + bool? isMute; + + bool? isDeaf; + + MemberBuilder({ + required this.accessToken, + required this.userId, + this.nick, + this.roleIds, + this.isMute, + this.isDeaf, + }); + + @override + Map build() => { + 'access_token': accessToken, + if (nick != null) 'nick': nick, + if (roleIds != null) 'roles': roleIds!.map((e) => e.toString()).toList(), + if (isMute != null) 'mute': isMute, + if (isDeaf != null) 'deaf': isDeaf, + }; +} + +class MemberUpdateBuilder extends UpdateBuilder { + String? nick; + + List? roleIds; + + bool? isMute; + + bool? isDeaf; + + Snowflake? voiceChannelId; + + DateTime? communicationDisabledUntil; + + Flags? flags; + + MemberUpdateBuilder({ + this.nick = sentinelString, + this.roleIds, + this.isMute, + this.isDeaf, + this.voiceChannelId = sentinelSnowflake, + this.communicationDisabledUntil = sentinelDateTime, + this.flags, + }); + + @override + Map build() => { + if (!identical(nick, sentinelString)) 'nick': nick, + if (roleIds != null) 'roles': roleIds!.map((e) => e.toString()).toList(), + if (isMute != null) 'mute': isMute, + if (isDeaf != null) 'deaf': isDeaf, + if (!identical(voiceChannelId, sentinelSnowflake)) 'channel_id': voiceChannelId?.toString(), + if (!identical(communicationDisabledUntil, sentinelDateTime)) 'communication_disabled_until': communicationDisabledUntil?.toIso8601String(), + if (flags != null) 'flags': flags!.value, + }; +} + +class CurrentMemberUpdateBuilder extends UpdateBuilder { + String? nick; + + CurrentMemberUpdateBuilder({this.nick = sentinelString}); + + @override + Map build() => { + if (!identical(nick, sentinelString)) 'nick': nick, + }; +} diff --git a/lib/src/builders/guild/scheduled_event.dart b/lib/src/builders/guild/scheduled_event.dart new file mode 100644 index 000000000..0a94e9ffb --- /dev/null +++ b/lib/src/builders/guild/scheduled_event.dart @@ -0,0 +1,100 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/channel/stage_instance.dart'; +import 'package:nyxx/src/models/guild/scheduled_event.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class ScheduledEventBuilder extends CreateBuilder { + Snowflake? channelId; + + EntityMetadata? metadata; + + String name; + + PrivacyLevel privacyLevel; + + DateTime scheduledStartTime; + + DateTime? scheduledEndTime; + + String? description; + + ScheduledEntityType type; + + ImageBuilder? image; + + ScheduledEventBuilder({ + required this.channelId, + this.metadata, + required this.name, + required this.privacyLevel, + required this.scheduledStartTime, + required this.scheduledEndTime, + this.description, + required this.type, + this.image, + }); + + @override + Map build() => { + if (channelId != null) 'channel_id': channelId.toString(), + if (metadata != null) 'metadata': {'location': metadata!.location}, + 'name': name, + 'privacy_level': privacyLevel.value, + 'scheduled_start_time': scheduledStartTime.toIso8601String(), + if (scheduledEndTime != null) 'scheduled_end_time': scheduledEndTime!.toIso8601String(), + if (description != null) 'description': description, + 'entity_type': type.value, + if (image != null) 'image': image!.buildDataString(), + }; +} + +class ScheduledEventUpdateBuilder extends UpdateBuilder { + Snowflake? channelId; + + EntityMetadata? metadata; + + String? name; + + PrivacyLevel? privacyLevel; + + DateTime? scheduledStartTime; + + DateTime? scheduledEndTime; + + String? description; + + ScheduledEntityType? type; + + EventStatus? status; + + ImageBuilder? image; + + ScheduledEventUpdateBuilder({ + this.channelId = sentinelSnowflake, + this.metadata = sentinelEntityMetadata, + this.name, + this.privacyLevel, + this.scheduledStartTime, + this.scheduledEndTime = sentinelDateTime, + this.description = sentinelString, + this.type, + this.status, + this.image, + }); + + @override + Map build() => { + if (!identical(channelId, sentinelSnowflake)) 'channel_id': channelId?.toString(), + if (!identical(metadata, sentinelEntityMetadata)) 'metadata': metadata == null ? null : {'location': metadata!.location}, + if (name != null) 'name': name, + if (privacyLevel != null) 'privacy_level': privacyLevel!.value, + if (scheduledStartTime != null) 'scheduled_start_time': scheduledStartTime!.toIso8601String(), + if (!identical(scheduledEndTime, sentinelDateTime)) 'scheduled_end_time': scheduledEndTime?.toIso8601String(), + if (!identical(description, sentinelString)) 'description': description, + if (type != null) 'entity_type': type!.value, + if (status != null) 'status': status!.value, + if (image != null) 'image': image!.buildDataString(), + }; +} diff --git a/lib/src/builders/guild/template.dart b/lib/src/builders/guild/template.dart new file mode 100644 index 000000000..346cd5e75 --- /dev/null +++ b/lib/src/builders/guild/template.dart @@ -0,0 +1,31 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/guild/template.dart'; + +class GuildTemplateBuilder extends CreateBuilder { + String name; + + String? description; + + GuildTemplateBuilder({required this.name, this.description}); + + @override + Map build() => { + 'name': name, + if (description != null) 'description': description, + }; +} + +class GuildTemplateUpdateBuilder extends UpdateBuilder { + String? name; + + String? description; + + GuildTemplateUpdateBuilder({this.name, this.description = sentinelString}); + + @override + Map build() => { + if (name != null) 'name': name, + if (!identical(description, sentinelString)) 'description': description, + }; +} diff --git a/lib/src/builders/guild/welcome_screen.dart b/lib/src/builders/guild/welcome_screen.dart new file mode 100644 index 000000000..8ea232fb0 --- /dev/null +++ b/lib/src/builders/guild/welcome_screen.dart @@ -0,0 +1,29 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/guild/welcome_screen.dart'; + +class WelcomeScreenUpdateBuilder extends UpdateBuilder { + bool? isEnabled; + + List? channels; + + String? description; + + WelcomeScreenUpdateBuilder({this.isEnabled, this.channels, this.description = sentinelString}); + + @override + Map build() => { + if (isEnabled != null) 'enabled': isEnabled, + if (channels != null) + 'channels': [ + for (final channel in channels!) + { + 'channel_id': channel.channelId.toString(), + 'description': channel.description, + 'emoji_id': channel.emojiId?.toString(), + 'emoji_name': channel.emojiName, + }, + ], + if (!identical(description, sentinelString)) 'description': description, + }; +} diff --git a/lib/src/builders/guild/widget.dart b/lib/src/builders/guild/widget.dart new file mode 100644 index 000000000..56cfcc8b8 --- /dev/null +++ b/lib/src/builders/guild/widget.dart @@ -0,0 +1,18 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/guild/guild_widget.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class WidgetSettingsUpdateBuilder extends UpdateBuilder { + bool? isEnabled; + + Snowflake? channelId; + + WidgetSettingsUpdateBuilder({this.isEnabled, this.channelId = sentinelSnowflake}); + + @override + Map build() => { + if (isEnabled != null) 'enabled': isEnabled, + if (!identical(channelId, sentinelSnowflake)) 'channel_id': channelId?.toString(), + }; +} diff --git a/lib/src/builders/image.dart b/lib/src/builders/image.dart new file mode 100644 index 000000000..4759df255 --- /dev/null +++ b/lib/src/builders/image.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class ImageBuilder { + List data; + String format; + + ImageBuilder({required this.data, required this.format}); + + ImageBuilder.png(this.data) : format = 'png'; + + ImageBuilder.jpeg(this.data) : format = 'jpeg'; + + ImageBuilder.gif(this.data) : format = 'gif'; + + static Future fromFile(File file, {String? format}) async { + format ??= p.extension(file.path); + + const formats = { + 'png': 'png', + 'jpeg': 'jpeg', + 'jpg': 'jpeg', + 'gif': 'gif', + 'json': 'lottie', + }; + + final actualFormat = formats[format]; + + if (actualFormat == null) { + throw ArgumentError('Invalid format $format'); + } + + final data = await file.readAsBytes(); + + return ImageBuilder(data: data, format: actualFormat); + } + + String buildDataString() => 'data:image/$format;base64,${base64Encode(data)}'; + + List buildRawData() => data; +} diff --git a/lib/src/builders/interaction_response.dart b/lib/src/builders/interaction_response.dart new file mode 100644 index 000000000..be130ff26 --- /dev/null +++ b/lib/src/builders/interaction_response.dart @@ -0,0 +1,133 @@ +import 'package:nyxx/src/builders/application_command.dart'; +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/message/component.dart'; +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/models/message/message.dart'; + +class InteractionResponseBuilder extends CreateBuilder { + InteractionCallbackType type; + + dynamic data; + + InteractionResponseBuilder({required this.type, required this.data}); + + factory InteractionResponseBuilder.pong() => InteractionResponseBuilder(type: InteractionCallbackType.pong, data: null); + + factory InteractionResponseBuilder.channelMessage(MessageBuilder message, {bool? isEphemeral}) => InteractionResponseBuilder( + type: InteractionCallbackType.channelMessageWithSource, + data: _EphemeralMessageBuilder( + content: message.content, + nonce: message.nonce, + tts: message.tts, + embeds: message.embeds, + allowedMentions: message.allowedMentions, + replyId: message.replyId, + requireReplyToExist: message.requireReplyToExist, + components: message.components, + stickerIds: message.stickerIds, + attachments: message.attachments, + suppressEmbeds: message.suppressEmbeds, + suppressNotifications: message.suppressNotifications, + isEphemeral: isEphemeral, + ), + ); + + factory InteractionResponseBuilder.deferredChannelMessage({bool? isEphemeral}) => InteractionResponseBuilder( + type: InteractionCallbackType.deferredChannelMessageWithSource, + data: isEphemeral == null ? null : {'flags': (isEphemeral ? MessageFlags.ephemeral.value : 0)}, + ); + + factory InteractionResponseBuilder.updateMessage(MessageUpdateBuilder message) => InteractionResponseBuilder( + type: InteractionCallbackType.updateMessage, + data: message, + ); + + factory InteractionResponseBuilder.deferredUpdateMessage() => InteractionResponseBuilder( + type: InteractionCallbackType.deferredUpdateMessage, + data: null, + ); + + factory InteractionResponseBuilder.autocompleteResult(List> choices) => InteractionResponseBuilder( + type: InteractionCallbackType.applicationCommandAutocompleteResult, + data: choices, + ); + + factory InteractionResponseBuilder.modal(ModalBuilder modal) => InteractionResponseBuilder(type: InteractionCallbackType.modal, data: modal); + + @override + Map build() { + final builtData = switch (data) { + final Builder builder => builder.build(), + final List> builders => builders.map((e) => e.build()).toList(), + Map() || List() || String() || int() || double() || bool() || null => data, + _ => throw ArgumentError.value(data, 'data', 'must be a Builder, a List or a JSON value') + }; + + return { + 'type': type.value, + 'data': builtData, + }; + } +} + +class _EphemeralMessageBuilder extends MessageBuilder { + bool? isEphemeral; + + _EphemeralMessageBuilder({ + required super.content, + required super.nonce, + required super.tts, + required super.embeds, + required super.allowedMentions, + required super.replyId, + required super.requireReplyToExist, + required super.components, + required super.stickerIds, + required super.attachments, + required super.suppressEmbeds, + required super.suppressNotifications, + required this.isEphemeral, + }); + + @override + Map build() { + final built = super.build(); + + if (isEphemeral != null) { + built['flags'] = (built['flags'] as int? ?? 0) | (isEphemeral == true ? MessageFlags.ephemeral.value : 0); + } + + return built; + } +} + +enum InteractionCallbackType { + pong._(1), + channelMessageWithSource._(4), + deferredChannelMessageWithSource._(5), + deferredUpdateMessage._(6), + updateMessage._(7), + applicationCommandAutocompleteResult._(8), + modal._(9); + + final int value; + + const InteractionCallbackType._(this.value); +} + +class ModalBuilder extends CreateBuilder { + String customId; + + String title; + + List components; + + ModalBuilder({required this.customId, required this.title, required this.components}); + + @override + Map build() => { + 'custom_id': customId, + 'title': title, + 'components': components.map((e) => e.build()).toList(), + }; +} diff --git a/lib/src/builders/invite.dart b/lib/src/builders/invite.dart new file mode 100644 index 000000000..5cb888012 --- /dev/null +++ b/lib/src/builders/invite.dart @@ -0,0 +1,41 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class InviteBuilder extends CreateBuilder { + Duration? maxAge; + + int? maxUses; + + bool? isTemporary; + + bool? isUnique; + + TargetType? targetType; + + Snowflake? targetUserId; + + Snowflake? targetApplicationId; + + InviteBuilder({ + this.maxAge = sentinelDuration, + this.maxUses, + this.isTemporary, + this.isUnique, + this.targetType, + this.targetUserId, + this.targetApplicationId, + }); + + @override + Map build() => { + if (!identical(maxAge, sentinelDuration)) 'max_age': maxAge == null ? 0 : maxAge?.inSeconds, + if (maxUses != null) 'max_uses': maxUses, + if (isTemporary != null) 'temporary': isTemporary, + if (isUnique != null) 'unique': isUnique, + if (targetType != null) 'target_type': targetType!.value, + if (targetUserId != null) 'target_user_id': targetUserId!.toString(), + if (targetApplicationId != null) 'target_application_id': targetApplicationId!.toString(), + }; +} diff --git a/lib/src/builders/message/allowed_mentions.dart b/lib/src/builders/message/allowed_mentions.dart new file mode 100644 index 000000000..4edcc9646 --- /dev/null +++ b/lib/src/builders/message/allowed_mentions.dart @@ -0,0 +1,93 @@ +import 'package:nyxx/src/models/snowflake.dart'; + +class AllowedMentions { + List? parse; + + List? users; + + List? roles; + + bool? repliedUser; + + AllowedMentions({this.parse, this.users, this.roles, this.repliedUser}); + + factory AllowedMentions.users([List? users]) => AllowedMentions(parse: users == null ? ['users'] : null, users: users); + + factory AllowedMentions.roles([List? roles]) => AllowedMentions(parse: roles == null ? ['roles'] : null, roles: roles); + + AllowedMentions operator |(AllowedMentions other) { + final parse = {...?this.parse, ...?other.parse}.toList(); + final users = {...?this.users, ...?other.users}.toList(); + final roles = {...?this.roles, ...?other.roles}.toList(); + + if (users.isNotEmpty) { + parse.remove('users'); + } + + if (roles.isNotEmpty) { + parse.remove('parse'); + } + + bool? repliedUser = this.repliedUser; + if (repliedUser != null && other.repliedUser != null) { + repliedUser = repliedUser || other.repliedUser!; + } else { + repliedUser ??= other.repliedUser; + } + + return AllowedMentions( + parse: parse, + users: users, + roles: roles, + repliedUser: repliedUser, + ); + } + + AllowedMentions operator &(AllowedMentions other) { + List? parse; + if (this.parse != null && other.parse != null) { + // If both this and other provide parse, perform the intersection + parse = this.parse!.where((element) => other.parse!.contains(element)).toList(); + } else if ((this.parse == null) ^ (other.parse == null)) { + // If only one of this and other supply parse, don't allow anything. + parse = []; + } + + List? users; + if (this.users != null && other.users != null) { + // If both this an other provide users, perform the intersection + users = this.users!.where(other.users!.contains).toList(); + } else if (this.parse?.contains('users') == true || other.parse?.contains('users') == true) { + // Otherwise, if one of this or other supplies user and the other has 'users' in its parse, use the users from whichever provides it. + // This assumes correctly formatted AllowedMentions that don't both provide users and have 'users' in its parse + users = this.users ?? other.users; + } else if ((this.users == null) ^ (other.users == null)) { + // If only one of this and other provide users, don't allow anything. + users = []; + } + + List? roles; + // Same as above + if (this.roles != null && other.roles != null) { + roles = this.users!.where(other.roles!.contains).toList(); + } else if (this.parse?.contains('roles') == true || other.parse?.contains('roles') == true) { + roles = this.roles ?? other.roles; + } else if ((this.roles == null) ^ (other.roles == null)) { + roles = []; + } + + return AllowedMentions( + parse: parse, + roles: roles, + users: users, + repliedUser: repliedUser == true && other.repliedUser == true, + ); + } + + Map build() => { + if (parse != null) 'parse': parse, + if (users != null && users!.isNotEmpty) 'users': users!.map((e) => e.toString()).toList(), + if (roles != null && roles!.isNotEmpty) 'roles': roles!.map((e) => e.toString()).toList(), + if (repliedUser != null) 'replied_user': repliedUser, + }; +} diff --git a/lib/src/builders/message/attachment.dart b/lib/src/builders/message/attachment.dart new file mode 100644 index 000000000..45be187c2 --- /dev/null +++ b/lib/src/builders/message/attachment.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path_lib; + +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/message/attachment.dart'; + +class AttachmentBuilder extends Builder { + List data; + + String? fileName; + + String? description; + + AttachmentBuilder({required this.data, this.fileName, this.description}); + + static Future fromFile(File file, {String? description}) async { + final data = await file.readAsBytes(); + + return AttachmentBuilder( + data: data, + fileName: path_lib.basename(file.path), + description: description, + ); + } + + @override + Map build() => { + if (fileName != null) 'filename': fileName, + if (description != null) 'description': description, + }; +} diff --git a/lib/src/builders/message/component.dart b/lib/src/builders/message/component.dart new file mode 100644 index 000000000..4b3aaa16d --- /dev/null +++ b/lib/src/builders/message/component.dart @@ -0,0 +1,260 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/message/component.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +abstract class MessageComponentBuilder extends CreateBuilder { + MessageComponentType type; + + MessageComponentBuilder({required this.type}); + + @override + Map build() => {'type': type.value}; +} + +class ActionRowBuilder extends MessageComponentBuilder { + List components; + + ActionRowBuilder({required this.components}) : super(type: MessageComponentType.actionRow); + + @override + Map build() => { + ...super.build(), + 'components': components.map((e) => e.build()).toList(), + }; +} + +class ButtonBuilder extends MessageComponentBuilder { + ButtonStyle style; + + String? label; + + Emoji? emoji; + + String? customId; + + Uri? url; + + bool? isDisabled; + + ButtonBuilder({ + required this.style, + this.label, + this.emoji, + this.customId, + this.url, + this.isDisabled, + }) : super(type: MessageComponentType.button); + + ButtonBuilder.primary({ + this.label, + this.emoji, + required String customId, + this.isDisabled, + }) : style = ButtonStyle.primary, + super(type: MessageComponentType.button); + + ButtonBuilder.secondary({ + this.label, + this.emoji, + required String customId, + this.isDisabled, + }) : style = ButtonStyle.secondary, + super(type: MessageComponentType.button); + + ButtonBuilder.success({ + this.label, + this.emoji, + required String customId, + this.isDisabled, + }) : style = ButtonStyle.success, + super(type: MessageComponentType.button); + + ButtonBuilder.danger({ + this.label, + this.emoji, + required String customId, + this.isDisabled, + }) : style = ButtonStyle.danger, + super(type: MessageComponentType.button); + + ButtonBuilder.link({ + this.label, + this.emoji, + required Uri this.url, + this.isDisabled, + }) : style = ButtonStyle.link, + super(type: MessageComponentType.button); + + @override + Map build() => { + ...super.build(), + 'style': style.value, + if (label != null) 'label': label, + if (emoji != null) + 'emoji': { + 'id': emoji!.id == Snowflake.zero ? null : emoji!.id, + 'name': emoji!.name, + if (emoji is GuildEmoji) 'animated': (emoji as GuildEmoji).isAnimated == true, + }, + if (customId != null) 'custom_id': customId, + if (url != null) 'url': url!.toString(), + if (isDisabled != null) 'disabled': isDisabled, + }; +} + +class SelectMenuBuilder extends MessageComponentBuilder { + String customId; + + List? options; + + List? channelTypes; + + String? placeholder; + + int? minValues; + + int? maxValues; + + bool? isDisabled; + + SelectMenuBuilder({ + required super.type, + required this.customId, + this.options, + this.channelTypes, + this.placeholder, + this.minValues, + this.maxValues, + this.isDisabled, + }); + + SelectMenuBuilder.stringSelect({ + required this.customId, + required List this.options, + this.placeholder, + this.minValues, + this.maxValues, + this.isDisabled, + }) : super(type: MessageComponentType.stringSelect); + + SelectMenuBuilder.userSelect({ + required this.customId, + this.placeholder, + this.minValues, + this.maxValues, + this.isDisabled, + }) : super(type: MessageComponentType.userSelect); + + SelectMenuBuilder.roleSelect({ + required this.customId, + this.placeholder, + this.minValues, + this.maxValues, + this.isDisabled, + }) : super(type: MessageComponentType.roleSelect); + + SelectMenuBuilder.mentionableSelect({ + required this.customId, + this.channelTypes, + this.placeholder, + this.minValues, + this.maxValues, + this.isDisabled, + }) : super(type: MessageComponentType.mentionableSelect); + + SelectMenuBuilder.channelSelect({ + required this.customId, + this.placeholder, + this.minValues, + this.maxValues, + this.isDisabled, + }) : super(type: MessageComponentType.channelSelect); + + @override + Map build() => { + ...super.build(), + 'custom_id': customId, + if (options != null) 'options': options?.map((e) => e.build()).toList(), + if (channelTypes != null) 'channel_types': channelTypes?.map((e) => e.value).toList(), + if (placeholder != null) 'placeholder': placeholder, + if (minValues != null) 'min_values': minValues, + if (maxValues != null) 'max_values': maxValues, + if (isDisabled != null) 'disabled': isDisabled, + }; +} + +class SelectMenuOptionBuilder extends CreateBuilder { + String label; + + String value; + + String? description; + + Emoji? emoji; + + bool? isDefault; + + SelectMenuOptionBuilder({ + required this.label, + required this.value, + this.description, + this.emoji, + this.isDefault, + }); + + @override + Map build() => { + 'label': label, + 'value': value, + if (description != null) 'description': description, + if (emoji != null) + 'emoji': { + 'id': emoji!.id.value, + 'name': emoji!.name, + 'animated': emoji is GuildEmoji && (emoji as GuildEmoji).isAnimated == true, + }, + if (isDefault != null) 'default': isDefault, + }; +} + +class TextInputBuilder extends MessageComponentBuilder { + String customId; + + TextInputStyle style; + + String label; + + int? minLength; + + int? maxLength; + + bool? isRequired; + + String? value; + + String? placeholder; + + TextInputBuilder({ + required this.customId, + required this.style, + required this.label, + this.minLength, + this.maxLength, + this.isRequired, + this.value, + this.placeholder, + }) : super(type: MessageComponentType.textInput); + + @override + Map build() => { + ...super.build(), + 'custom_id': customId, + 'style': style.value, + if (minLength != null) 'min_length': minLength, + if (maxLength != null) 'max_length': maxLength, + if (isRequired != null) 'required': isRequired, + if (placeholder != null) 'placeholder': placeholder, + }; +} diff --git a/lib/src/builders/message/embed.dart b/lib/src/builders/message/embed.dart new file mode 100644 index 000000000..8ba2e6e0e --- /dev/null +++ b/lib/src/builders/message/embed.dart @@ -0,0 +1,126 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/discord_color.dart'; +import 'package:nyxx/src/models/message/embed.dart'; + +class EmbedBuilder extends CreateBuilder { + String? title; + + String? description; + + Uri? url; + + DateTime? timestamp; + + DiscordColor? color; + + EmbedFooterBuilder? footer; + + EmbedImageBuilder? image; + + EmbedThumbnailBuilder? thumbnail; + + EmbedAuthorBuilder? author; + + List? fields; + + EmbedBuilder({ + this.title, + this.description, + this.url, + this.timestamp, + this.color, + this.footer, + this.image, + this.thumbnail, + this.author, + this.fields, + }); + + @override + Map build() => { + if (title != null) 'title': title, + if (description != null) 'description': description, + if (url != null) 'url': url.toString(), + if (timestamp != null) 'timestamp': timestamp!.toIso8601String(), + if (color != null) 'color': color!.value, + if (footer != null) 'footer': footer!.build(), + if (image != null) 'image': image!.build(), + if (thumbnail != null) 'thumbnail': thumbnail!.build(), + if (author != null) 'author': author!.build(), + if (fields != null) 'fields': fields!.map((e) => e.build()).toList(), + }; +} + +class EmbedFooterBuilder extends CreateBuilder { + String text; + + Uri? iconUrl; + + EmbedFooterBuilder({required this.text, this.iconUrl}); + + @override + Map build() => { + 'text': text, + if (iconUrl != null) 'icon_url': iconUrl!.toString(), + }; +} + +class EmbedImageBuilder extends CreateBuilder { + Uri url; + + EmbedImageBuilder({required this.url}); + + @override + Map build() => { + 'url': url.toString(), + }; +} + +class EmbedThumbnailBuilder extends CreateBuilder { + Uri url; + + EmbedThumbnailBuilder({required this.url}); + + @override + Map build() => { + 'url': url.toString(), + }; +} + +class EmbedAuthorBuilder extends CreateBuilder { + String name; + + Uri? url; + + Uri? iconUrl; + + EmbedAuthorBuilder({required this.name, this.url, this.iconUrl}); + + @override + Map build() => { + 'name': name, + if (url != null) 'url': url!.toString(), + if (iconUrl != null) 'icon_url': iconUrl!.toString(), + }; +} + +class EmbedFieldBuilder extends CreateBuilder { + String name; + + String value; + + bool isInline; + + EmbedFieldBuilder({ + required this.name, + required this.value, + required this.isInline, + }); + + @override + Map build() => { + 'name': name, + 'value': value, + 'inline': isInline, + }; +} diff --git a/lib/src/builders/message/message.dart b/lib/src/builders/message/message.dart new file mode 100644 index 000000000..d2a121a79 --- /dev/null +++ b/lib/src/builders/message/message.dart @@ -0,0 +1,102 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/message/allowed_mentions.dart'; +import 'package:nyxx/src/builders/message/attachment.dart'; +import 'package:nyxx/src/builders/message/component.dart'; +import 'package:nyxx/src/builders/message/embed.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +class MessageBuilder extends CreateBuilder { + String? content; + + dynamic /* int | String */ nonce; + + bool? tts; + + List? embeds; + + AllowedMentions? allowedMentions; + + Snowflake? replyId; + + bool? requireReplyToExist; + + List? components; + + List? stickerIds; + + List? attachments; + + bool? suppressEmbeds; + + bool? suppressNotifications; + + MessageBuilder({ + this.content, + this.nonce, + this.tts, + this.embeds, + this.allowedMentions, + this.replyId, + this.requireReplyToExist, + this.components, + this.stickerIds, + this.attachments, + this.suppressEmbeds, + this.suppressNotifications, + }); + + @override + Map build() => { + if (content != null) 'content': content, + if (nonce != null) 'nonce': nonce, + if (tts != null) 'tts': tts, + if (embeds != null) 'embeds': embeds!.map((e) => e.build()).toList(), + if (allowedMentions != null) 'allowed_mentions': allowedMentions!.build(), + if (replyId != null) + 'message_reference': { + 'message_id': replyId.toString(), + if (requireReplyToExist != null) 'fail_if_not_exists': requireReplyToExist, + }, + if (components != null) 'components': components!.map((e) => e.build()).toList(), + if (stickerIds != null) 'sticker_ids': stickerIds!.map((e) => e.toString()).toList(), + if (attachments != null) 'attachments': attachments!.map((e) => e.build()).toList(), + if (suppressEmbeds != null || suppressNotifications != null) + 'flags': + (suppressEmbeds == true ? MessageFlags.suppressEmbeds.value : 0) | (suppressNotifications == true ? MessageFlags.suppressNotifications.value : 0), + }; +} + +class MessageUpdateBuilder extends UpdateBuilder { + String? content; + + List? embeds; + + bool? suppressEmbeds; + + AllowedMentions? allowedMentions; + + List? components; + + List? attachments; + + MessageUpdateBuilder({ + this.content = sentinelString, + this.embeds = sentinelList, + this.suppressEmbeds, + this.allowedMentions, + this.components, + this.attachments = sentinelList, + }); + + @override + Map build() => { + if (!identical(content, sentinelString)) 'content': content, + if (!identical(embeds, sentinelList)) 'embeds': embeds!.map((e) => e.build()).toList(), + if (allowedMentions != null) 'allowed_mentions': allowedMentions!.build(), + if (components != null) 'components': components!.map((e) => e.build()).toList(), + if (!identical(attachments, sentinelList)) 'attachments': attachments!.map((e) => e.build()).toList(), + if (suppressEmbeds != null) 'flags': (suppressEmbeds == true ? MessageFlags.suppressEmbeds.value : 0), + }; +} diff --git a/lib/src/builders/permission_overwrite.dart b/lib/src/builders/permission_overwrite.dart new file mode 100644 index 000000000..8f55073fe --- /dev/null +++ b/lib/src/builders/permission_overwrite.dart @@ -0,0 +1,24 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +class PermissionOverwriteBuilder extends CreateBuilder { + Snowflake id; + + PermissionOverwriteType type; + + Flags? allow; + + Flags? deny; + + PermissionOverwriteBuilder({required this.id, required this.type, this.allow, this.deny}); + + @override + Map build() => { + 'type': type.value, + if (allow != null) 'allow': allow!.value.toString(), + if (deny != null) 'deny': deny!.value.toString(), + }; +} diff --git a/lib/src/builders/presence.dart b/lib/src/builders/presence.dart new file mode 100644 index 000000000..eb4138eb4 --- /dev/null +++ b/lib/src/builders/presence.dart @@ -0,0 +1,55 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/models/gateway/events/presence.dart'; +import 'package:nyxx/src/models/presence.dart'; + +class PresenceBuilder extends CreateBuilder { + DateTime? since; + + List? activities; + + CurrentUserStatus status; + + bool isAfk; + + PresenceBuilder({this.since, this.activities, required this.status, required this.isAfk}); + + @override + Map build() => { + 'since': since?.millisecondsSinceEpoch, + if (activities != null) 'activities': activities!.map((e) => e.build()).toList(), + 'status': status.value, + 'afk': isAfk, + }; +} + +enum CurrentUserStatus { + online._('online'), + dnd._('dnd'), + idle._('idle'), + invisible._('invisible'), + offline._('offline'); + + final String value; + + const CurrentUserStatus._(this.value); + + @override + String toString() => 'CurrentUserStatus($value)'; +} + +class ActivityBuilder extends CreateBuilder { + String name; + + ActivityType type; + + Uri? url; + + ActivityBuilder({required this.name, required this.type, this.url}); + + @override + Map build() => { + 'name': name, + 'type': type.value, + if (url != null) 'url': url!.toString(), + }; +} diff --git a/lib/src/builders/role.dart b/lib/src/builders/role.dart new file mode 100644 index 000000000..017894a2b --- /dev/null +++ b/lib/src/builders/role.dart @@ -0,0 +1,81 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/discord_color.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +class RoleBuilder extends CreateBuilder { + String? name; + + Flags? permissions; + + DiscordColor? color; + + bool? isHoisted; + + ImageBuilder? icon; + + String? unicodeEmoji; + + bool? isMentionable; + + RoleBuilder({ + this.name, + this.permissions, + this.color, + this.isHoisted, + this.icon, + this.unicodeEmoji, + this.isMentionable, + }); + + @override + Map build() => { + if (name != null) 'name': name, + if (permissions != null) 'permissions': permissions!.value.toString(), + if (color != null) 'color': color!.value, + if (isHoisted != null) 'hoisted': isHoisted, + if (icon != null) 'icon': icon!.buildDataString(), + if (unicodeEmoji != null) 'unicode_emoji': unicodeEmoji, + if (isMentionable != null) 'mentionable': isMentionable, + }; +} + +class RoleUpdateBuilder extends UpdateBuilder { + String? name; + + Permissions? permissions; + + DiscordColor? color; + + bool? isHoisted; + + ImageBuilder? icon; + + String? unicodeEmoji; + + bool? isMentionable; + + RoleUpdateBuilder({ + this.name, + this.permissions, + this.color, + this.isHoisted, + this.icon = sentinelImageBuilder, + this.unicodeEmoji = sentinelString, + this.isMentionable, + }); + + @override + Map build() => { + if (name != null) 'name': name, + if (permissions != null) 'permissions': permissions!.value.toString(), + if (color != null) 'color': color!.value, + if (isHoisted != null) 'hoisted': isHoisted, + if (!identical(icon, sentinelImageBuilder)) 'icon': icon?.buildDataString(), + if (!identical(unicodeEmoji, sentinelString)) 'unicode_emoji': unicodeEmoji, + if (isMentionable != null) 'mentionable': isMentionable, + }; +} diff --git a/lib/src/builders/sentinels.dart b/lib/src/builders/sentinels.dart new file mode 100644 index 000000000..79734da50 --- /dev/null +++ b/lib/src/builders/sentinels.dart @@ -0,0 +1,92 @@ +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/models/channel/types/forum.dart'; +import 'package:nyxx/src/models/guild/scheduled_event.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +// ASCII encoded "nyxx" +const sentinelInteger = 0x6E797878; + +// ESC-"nyxx" +const sentinelString = '\u{1B}nyxx'; + +const sentinelDuration = _SentinelDuration(); + +class _SentinelDuration implements Duration { + const _SentinelDuration(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const sentinelSnowflake = _SentinelSnowflake(); + +class _SentinelSnowflake implements Snowflake { + const _SentinelSnowflake(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const sentinelDefaultReaction = _SentinelDefaultReaction(); + +class _SentinelDefaultReaction implements DefaultReaction { + const _SentinelDefaultReaction(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const sentinelList = _SentinelList(); + +class _SentinelList implements List { + const _SentinelList(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const sentinelImageBuilder = _SentinelImageBuilder(); + +class _SentinelImageBuilder implements ImageBuilder { + const _SentinelImageBuilder(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const sentinelDateTime = _SentinelDateTime(); + +class _SentinelDateTime implements DateTime { + const _SentinelDateTime(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const sentinelEntityMetadata = _SentinelEntityMetadata(); + +class _SentinelEntityMetadata implements EntityMetadata { + const _SentinelEntityMetadata(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const sentinelMap = _SentinelMap(); + +class _SentinelMap implements Map { + const _SentinelMap(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const sentinelFlags = _SentinelFlags(); + +class _SentinelFlags implements Flags { + const _SentinelFlags(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/lib/src/builders/sticker.dart b/lib/src/builders/sticker.dart new file mode 100644 index 000000000..18c7082ce --- /dev/null +++ b/lib/src/builders/sticker.dart @@ -0,0 +1,47 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/sticker/guild_sticker.dart'; + +class StickerBuilder implements CreateBuilder { + /// Name of the sticker (2-30 characters) + String name; + + /// Description of the sticker (empty or 2-100 characters) + String description; + + /// Autocomplete/suggestion tags for the sticker (max 200 characters) + String tags; + + /// The sticker file to upload + ImageBuilder file; + + StickerBuilder({required this.name, this.description = '', required this.tags, required this.file}); + + @override + Map build() => { + "name": name, + "description": description, + "tags": tags, + }; +} + +class StickerUpdateBuilder implements UpdateBuilder { + /// Name of the sticker (2-30 characters) + String? name; + + /// Description of the sticker (empty or 2-100 characters) + String? description; + + /// Autocomplete/suggestion tags for the sticker (max 200 characters) + String? tags; + + StickerUpdateBuilder({this.name, this.description = sentinelString, this.tags}); + + @override + Map build() => { + if (!identical(description, sentinelString)) "description": description ?? '', + if (name != null) "name": name, + if (tags != null) "tags": tags, + }; +} diff --git a/lib/src/builders/user.dart b/lib/src/builders/user.dart new file mode 100644 index 000000000..30fa322ec --- /dev/null +++ b/lib/src/builders/user.dart @@ -0,0 +1,16 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +class UserUpdateBuilder extends UpdateBuilder { + String? username; + ImageBuilder? avatar; + + UserUpdateBuilder({this.username, this.avatar}); + + @override + Map build() => { + if (username != null) 'username': username!, + if (avatar != null) 'avatar': avatar!.buildDataString(), + }; +} diff --git a/lib/src/builders/voice.dart b/lib/src/builders/voice.dart new file mode 100644 index 000000000..f6fcab3f4 --- /dev/null +++ b/lib/src/builders/voice.dart @@ -0,0 +1,47 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/voice/voice_state.dart'; + +class VoiceStateUpdateBuilder extends UpdateBuilder { + Snowflake? channelId; + + bool? suppress; + + VoiceStateUpdateBuilder({this.channelId, this.suppress}); + + @override + Map build() => { + if (channelId != null) 'channel_id': channelId!.toString(), + if (suppress != null) 'suppress': suppress, + }; +} + +class CurrentUserVoiceStateUpdateBuilder extends VoiceStateUpdateBuilder { + DateTime? requestToSpeakTimeStamp; + + CurrentUserVoiceStateUpdateBuilder({super.channelId, super.suppress, this.requestToSpeakTimeStamp = sentinelDateTime}); + + @override + Map build() => { + ...super.build(), + if (!identical(requestToSpeakTimeStamp, sentinelDateTime)) 'request_to_speak_timestamp': requestToSpeakTimeStamp?.toIso8601String(), + }; +} + +class GatewayVoiceStateBuilder extends CreateBuilder { + Snowflake? channelId; + + bool isMuted; + + bool isDeafened; + + GatewayVoiceStateBuilder({required this.channelId, required this.isMuted, required this.isDeafened}); + + @override + Map build() => { + 'channel_id': channelId?.toString(), + 'mute': isMuted, + 'deaf': isDeafened, + }; +} diff --git a/lib/src/builders/webhook.dart b/lib/src/builders/webhook.dart new file mode 100644 index 000000000..61faee148 --- /dev/null +++ b/lib/src/builders/webhook.dart @@ -0,0 +1,43 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +class WebhookBuilder extends CreateBuilder { + String name; + + // Not used in build, but used to determine the proper route when creating webhooks. + Snowflake channelId; + + ImageBuilder? avatar; + + WebhookBuilder({required this.name, required this.channelId, this.avatar}); + + @override + Map build() => { + 'name': name, + if (avatar != null) 'avatar': avatar!.buildDataString(), + }; +} + +class WebhookUpdateBuilder extends UpdateBuilder { + String? name; + + ImageBuilder? avatar; + + Snowflake? channelId; + + WebhookUpdateBuilder({ + this.name, + this.avatar = sentinelImageBuilder, + this.channelId, + }); + + @override + Map build() => { + if (name != null) 'name': name, + if (!identical(avatar, sentinelImageBuilder)) 'avatar': avatar?.buildDataString(), + if (channelId != null) 'channel_id': channelId.toString(), + }; +} diff --git a/lib/src/cache/cache.dart b/lib/src/cache/cache.dart new file mode 100644 index 000000000..540fd2774 --- /dev/null +++ b/lib/src/cache/cache.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; + +/// The configuration for a [Cache] instance. +class CacheConfig { + /// The maximum amount of items allowed in the cache. + final int? maxSize; + + /// A predicate determining whether a given item should be cached. + /// + /// This function is called whenever an item is added to the cache. If it returns `true`, the item + /// is added and can later be retrieved. If it returns false, the item is not added. + /// + /// The default is to add all items to the cache. + final bool Function(T item)? shouldCache; + + /// Create a new [CacheConfig] with the provided properties. + const CacheConfig({this.maxSize, this.shouldCache}); +} + +typedef _CacheKey = ({String identifier, Snowflake key}); + +class _CacheEntry { + Object? value; + int accessCount; + + _CacheEntry(this.value) : accessCount = 0; +} + +/// A simple cache for [SnowflakeEntity]s. +class Cache with MapMixin { + static final Expando> _stores = Expando('Cache store'); + + Map<_CacheKey, _CacheEntry> get _store => _stores[client] ??= {}; + + /// The configuration for this cache. + final CacheConfig config; + + /// An identifier for this cache. + /// + /// Caches with the same identifier will use the same backing store, so this allows for multiple caches pointing to the same resource to exist. + final String identifier; + + /// The client this cache is associated with. + final Nyxx client; + + /// Create a new cache with the provided config. + Cache(this.client, this.identifier, this.config); + + /// Filter the items in the cache so that it obeys the [config]. + /// + /// Items are retained based on the number of accesses they have until the [CacheConfig.maxSize] + /// is respected. + void filterItems() { + final keys = List.of(_store.keys.where((element) => element.identifier == identifier)); + + if (config.maxSize != null && keys.length > config.maxSize!) { + keys.sort((a, b) => _store[a]!.accessCount.compareTo(_store[b]!.accessCount)); + + final overflow = keys.length - config.maxSize!; + + for (final key in keys.take(overflow)) { + _store.remove(key); + } + } + } + + bool _resizeScheduled = false; + + /// Schedule [filterItems] to be run, if it isn't already scheduled. + /// + /// This allows for bulk insertions to only trigger one [filterItems] call. + void scheduleFilterItems() { + if (!_resizeScheduled) { + _resizeScheduled = true; + scheduleMicrotask(() { + filterItems(); + _resizeScheduled = false; + }); + } + } + + @override + void operator []=(Snowflake key, T value) { + assert(value is! ManagedSnowflakeEntity || value.id == key, 'Mismatched entity key in cache'); + + if (config.shouldCache?.call(value) == false) { + remove(key); + return; + } + + _store.update( + (identifier: identifier, key: key), + (entry) => entry..value = value, + ifAbsent: () => _CacheEntry(value), + ); + + scheduleFilterItems(); + } + + @override + T? operator [](Object? key) { + if (key is! Snowflake) { + return null; + } + + final entry = _store[(identifier: identifier, key: key)]; + if (entry == null) { + return null; + } + + entry.accessCount++; + return entry.value as T; + } + + @override + void clear() { + _store.removeWhere((key, value) => key.identifier == identifier); + } + + @override + Iterable get keys => _store.keys.where((element) => element.identifier == identifier).map((e) => e.key); + + @override + T? remove(Object? key) { + return _store.remove((identifier: identifier, key: key))?.value as T?; + } + + /// Return a mapping of identifier to cache contents for all caches associated with [client]. + static Map> cachesFor(Nyxx client) { + final store = _stores[client]; + if (store == null) { + return {}; + } + + final result = >{}; + + for (final entry in store.entries) { + (result[entry.key.identifier] ??= {})[entry.key.key] = entry.value.value; + } + + return result; + } +} + +extension SnowflakeCache> on Cache { + void addEntities(Iterable entities) => addEntries(entities.map((e) => MapEntry(e.id, e))); +} diff --git a/lib/src/client.dart b/lib/src/client.dart new file mode 100644 index 000000000..57105f772 --- /dev/null +++ b/lib/src/client.dart @@ -0,0 +1,260 @@ +import 'package:logging/logging.dart'; +import 'package:nyxx/src/builders/presence.dart'; +import 'package:nyxx/src/builders/voice.dart'; +import 'package:nyxx/src/client_options.dart'; +import 'package:nyxx/src/event_mixin.dart'; +import 'package:nyxx/src/gateway/gateway.dart'; +import 'package:nyxx/src/http/handler.dart'; +import 'package:nyxx/src/http/managers/gateway_manager.dart'; +import 'package:nyxx/src/intents.dart'; +import 'package:nyxx/src/manager_mixin.dart'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/plugin/plugin.dart'; +import 'package:nyxx/src/utils/flags.dart'; +import 'package:oauth2/oauth2.dart'; + +/// A helper function to nest and execute calls to plugin connect methods. +Future _doConnect(ApiOptions apiOptions, ClientOptions clientOptions, Future Function() connect, List plugins) { + connect = plugins.fold(connect, (previousConnect, plugin) => () => plugin.connect(apiOptions, clientOptions, previousConnect)); + return connect(); +} + +/// A helper function to nest and execute calls to plugin close methods. +Future _doClose(Nyxx client, Future Function() close, List plugins) { + close = plugins.fold(close, (previousClose, plugin) => () => plugin.close(client, previousClose)); + return close(); +} + +/// The base class for clients interacting with the Discord API. +abstract class Nyxx { + /// The options this client will use when connecting to the API. + ApiOptions get apiOptions; + + /// The [HttpHandler] used by this client to make requests. + HttpHandler get httpHandler; + + /// The options controlling the behavior of this client. + ClientOptions get options; + + /// The logger for this client. + Logger get logger; + + /// Create an instance of [NyxxRest] that can perform requests to the HTTP API and is + /// authenticated with a bot token. + static Future connectRest(String token, {RestClientOptions options = const RestClientOptions()}) => + connectRestWithOptions(RestApiOptions(token: token), options); + + /// Create an instance of [NyxxRest] using the provided options. + static Future connectRestWithOptions(RestApiOptions apiOptions, [RestClientOptions clientOptions = const RestClientOptions()]) async { + clientOptions.logger + ..info('Connecting to the REST API') + ..fine('Token: ${apiOptions.token}, Authorization: ${apiOptions.authorizationHeader}, User-Agent: ${apiOptions.userAgent}') + ..fine('Plugins: ${clientOptions.plugins.map((plugin) => plugin.name).join(', ')}'); + + return _doConnect(apiOptions, clientOptions, () async { + final client = NyxxRest._(apiOptions, clientOptions); + + if (clientOptions.applicationId != null) { + return client..application = client.applications[clientOptions.applicationId!]; + } + + return client..application = await client.applications.fetchCurrentApplication(); + }, clientOptions.plugins); + } + + /// Create an instance of [NyxxOAuth2] that can perform requests to the HTTP API and is + /// authenticated with OAuth2 [Credentials]. + static Future connectOAuth2(Credentials credentials, {RestClientOptions options = const RestClientOptions()}) => + connectOAuth2WithOptions(OAuth2ApiOptions(credentials: credentials), options); + + /// Create an instance of [NyxxOAuth2] using the provided options. + static Future connectOAuth2WithOptions(OAuth2ApiOptions apiOptions, [RestClientOptions clientOptions = const RestClientOptions()]) async { + clientOptions.logger + ..info('Connecting to the REST API via OAuth2') + ..fine('Token: ${apiOptions.token}, Authorization: ${apiOptions.authorizationHeader}, User-Agent: ${apiOptions.userAgent}') + ..fine('Plugins: ${clientOptions.plugins.map((plugin) => plugin.name).join(', ')}'); + + return _doConnect(apiOptions, clientOptions, () async { + final client = NyxxOAuth2._(apiOptions, clientOptions); + + if (clientOptions.applicationId != null) { + return client..application = client.applications[clientOptions.applicationId!]; + } + + return client..application = await client.applications.fetchCurrentApplication(); + }, clientOptions.plugins); + } + + /// Create an instance of [NyxxGateway] that can perform requests to the HTTP API, connects + /// to the gateway and is authenticated with a bot token. + static Future connectGateway(String token, Flags intents, {GatewayClientOptions options = const GatewayClientOptions()}) => + connectGatewayWithOptions(GatewayApiOptions(token: token, intents: intents), options); + + /// Create an instance of [NyxxGateway] using the provided options. + static Future connectGatewayWithOptions( + GatewayApiOptions apiOptions, [ + GatewayClientOptions clientOptions = const GatewayClientOptions(), + ]) async { + clientOptions.logger + ..info('Connecting to the Gateway API') + ..fine( + 'Token: ${apiOptions.token}, Authorization: ${apiOptions.authorizationHeader}, User-Agent: ${apiOptions.userAgent},' + ' Intents: ${apiOptions.intents.value}, Payloads: ${apiOptions.payloadFormat.value}, Compression: ${apiOptions.compression.name},' + ' Shards: ${apiOptions.shards?.join(', ')}, Total shards: ${apiOptions.totalShards}, Large threshold: ${apiOptions.largeThreshold}', + ) + ..fine('Plugins: ${clientOptions.plugins.map((plugin) => plugin.name).join(', ')}'); + + return _doConnect(apiOptions, clientOptions, () async { + final client = NyxxGateway._(apiOptions, clientOptions); + + if (clientOptions.applicationId != null) { + client.application = client.applications[clientOptions.applicationId!]; + } else { + client.application = await client.applications.fetchCurrentApplication(); + } + + // We can't use client.gateway as it is not initialized yet + final gatewayManager = GatewayManager(client); + + final gatewayBot = await gatewayManager.fetchGatewayBot(); + return client..gateway = await Gateway.connect(client, gatewayBot); + }, clientOptions.plugins); + } + + /// Close this client and any underlying resources. + /// + /// The client should not be used after this is called and unexpected behavior may occur. + Future close(); +} + +/// A client that can make requests to the HTTP API and is authenticated with a bot token. +class NyxxRest with ManagerMixin implements Nyxx { + @override + final RestApiOptions apiOptions; + + @override + final RestClientOptions options; + + @override + late final HttpHandler httpHandler = HttpHandler(this); + + /// The application associated with this client. + late final PartialApplication application; + + @override + Logger get logger => options.logger; + + NyxxRest._(this.apiOptions, this.options); + + /// Add the current user to the thread with the ID [id]. + /// + /// External references: + /// * [ChannelManager.joinThread] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#join-thread + Future joinThread(Snowflake id) => channels.joinThread(id); + + /// Remove the current user from the thread with the ID [id]. + /// + /// External references: + /// * [ChannelManager.leaveThread] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#leave-thread + Future leaveThread(Snowflake id) => channels.leaveThread(id); + + /// List the guilds the current user is a member of. + Future> listGuilds({Snowflake? before, Snowflake? after, int? limit}) => + users.listCurrentUserGuilds(before: before, after: after, limit: limit); + + @override + Future close() { + logger.info('Closing client'); + return _doClose(this, () async => httpHandler.close(), options.plugins); + } +} + +class NyxxOAuth2 with ManagerMixin implements NyxxRest { + @override + final OAuth2ApiOptions apiOptions; + + @override + final RestClientOptions options; + + @override + late final HttpHandler httpHandler = Oauth2HttpHandler(this); + + @override + Logger get logger => options.logger; + + @override + late final PartialApplication application; + + NyxxOAuth2._(this.apiOptions, this.options); + + @override + Future joinThread(Snowflake id) => channels.joinThread(id); + + @override + Future leaveThread(Snowflake id) => channels.leaveThread(id); + + @override + Future> listGuilds({Snowflake? before, Snowflake? after, int? limit}) => + users.listCurrentUserGuilds(before: before, after: after, limit: limit); + + @override + Future close() { + logger.info('Closing client'); + return _doClose(this, () async => httpHandler.close(), options.plugins); + } +} + +/// A client that can make requests to the HTTP API, connects to the Gateway and is authenticated with a bot token. +class NyxxGateway with ManagerMixin, EventMixin implements NyxxRest { + @override + final GatewayApiOptions apiOptions; + + @override + final GatewayClientOptions options; + + @override + late final HttpHandler httpHandler = HttpHandler(this); + + @override + late final PartialApplication application; + + /// The [Gateway] used by this client to send and receive Gateway events. + // Initialized in connectGateway due to a circular dependency + @override + late final Gateway gateway; + + @override + Logger get logger => options.logger; + + NyxxGateway._(this.apiOptions, this.options); + + @override + Future joinThread(Snowflake id) => channels.joinThread(id); + + @override + Future leaveThread(Snowflake id) => channels.leaveThread(id); + + @override + Future> listGuilds({Snowflake? before, Snowflake? after, int? limit}) => + users.listCurrentUserGuilds(before: before, after: after, limit: limit); + + /// Update the client's voice state in the guild with the ID [guildId]. + void updateVoiceState(Snowflake guildId, GatewayVoiceStateBuilder builder) => gateway.updateVoiceState(guildId, builder); + + /// Update the client's presence on all shards. + void updatePresence(PresenceBuilder builder) => gateway.updatePresence(builder); + + @override + Future close() { + logger.info('Closing client'); + return _doClose(this, () async { + await gateway.close(); + httpHandler.close(); + }, options.plugins); + } +} diff --git a/lib/src/client_options.dart b/lib/src/client_options.dart index ac50f6e2f..8e7d3428a 100644 --- a/lib/src/client_options.dart +++ b/lib/src/client_options.dart @@ -1,212 +1,155 @@ -import 'package:nyxx/src/core/allowed_mentions.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/internal/cache/cache_policy.dart'; -import 'package:nyxx/src/internal/constants.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/internal/shard/shard.dart'; -import 'package:nyxx/src/utils/builders/presence_builder.dart'; -import 'package:retry/retry.dart'; - -/// Options for configuring cache. Allows to specify where and which entities should be cached and preserved in cache -class CacheOptions { - /// Defines in which locations members will be cached - CachePolicyLocation memberCachePolicyLocation = CachePolicyLocation(); - - /// Defines which members are preserved in cache - CachePolicy memberCachePolicy = MemberCachePolicy.def; - - /// Defines where channel entities are preserved cache. Defaults to [CachePolicyLocation] with additional objectConstructor set to true - CachePolicyLocation channelCachePolicyLocation = CachePolicyLocation()..objectConstructor = true; - - /// Defines which channel entities are preserved in cache. - CachePolicy channelCachePolicy = ChannelCachePolicy.def; - - /// Defines in which places user can be cached - CachePolicyLocation userCachePolicyLocation = CachePolicyLocation(); - - /// Defines in which locations members will be cached - CachePolicyLocation messageCachePolicyLocation = CachePolicyLocation(); - - /// Defines which members are preserved in cache - CachePolicy messageCachePolicy = MessageCachePolicy.def; +import 'package:logging/logging.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/commands/application_command.dart'; +import 'package:nyxx/src/models/commands/application_command_permissions.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/channel/stage_instance.dart'; +import 'package:nyxx/src/models/guild/audit_log.dart'; +import 'package:nyxx/src/models/guild/auto_moderation.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/integration.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/guild/scheduled_event.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/sticker/global_sticker.dart'; +import 'package:nyxx/src/models/sticker/guild_sticker.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/models/voice/voice_state.dart'; +import 'package:nyxx/src/models/webhook.dart'; +import 'package:nyxx/src/plugin/plugin.dart'; + +/// Options for controlling the behavior of a [Nyxx] client. +abstract class ClientOptions { + /// The plugins to use for this client. + final List plugins; + + /// The name of the logger to use for this client. + final String loggerName; + + /// The logger to use for this client. + Logger get logger => Logger(loggerName); + + /// Create a new [ClientOptions]. + const ClientOptions({this.plugins = const [], this.loggerName = 'Nyxx'}); } -/// Optional client settings which can be used when creating new instance -/// of client. It allows to tune up client to your needs. -class ClientOptions { - /// Whether or not to disable @everyone and @here mentions at a global level. - /// **It means client won't send any of these. It doesn't mean filtering guild messages.** - AllowedMentions? allowedMentions; +/// Options for controlling the behavior of a [NyxxRest] client. +class RestClientOptions extends ClientOptions { + /// The [CacheConfig] to use for the cache of the [NyxxRest.users] manager. + final CacheConfig userCacheConfig; - /// The total number of shards. - int? shardCount; + /// The [CacheConfig] to use for the cache of the [NyxxRest.channels] manager. + final CacheConfig channelCacheConfig; - /// A list of shards to spawn on this instance of nyxx. - List? shardIds; + /// The [CacheConfig] to use for the cache of [TextChannel.messages] managers. + final CacheConfig messageCacheConfig; - /// The number of messages to cache for each channel. - int messageCacheSize; + /// The [CacheConfig] to use for the cache of the [NyxxRest.webhooks] manager. + final CacheConfig webhookCacheConfig; - /// Maximum size of guild for which offline member will be sent - int largeThreshold; + /// The [CacheConfig] to use for the cache of the [NyxxRest.guilds] manager. + final CacheConfig guildCacheConfig; - /// Allows to receive compressed payloads from gateway - bool compressedGatewayPayloads; + /// The [CacheConfig] to use for the [Guild.members] manager. + final CacheConfig memberCacheConfig; - /// Initial bot presence - PresenceBuilder? initialPresence; + /// The [CacheConfig] to use for the [Guild.roles] manager. + final CacheConfig roleCacheConfig; - /// Hook executed when disposing bots process. - /// - /// Most likely by when process receives SIGINT (*nix) or SIGTERM (*nix and windows). - /// Not guaranteed to be completed or executed at all. - ShutdownHook? shutdownHook; - - /// Hook executed when shard is disposing. - /// - /// It could be either when shards disconnects or when bots process shuts down (look [shutdownHook]. - ShutdownShardHook? shutdownShardHook; - - /// Allows to enable receiving raw gateway event - bool dispatchRawShardEvent; - - /// The [RetryOptions] to use when a shard fails to connect to the gateway. - RetryOptions shardReconnectOptions; - - /// The [RetryOptions] to use when a HTTP request fails. - /// - /// Note that this will not retry requests that fail because of their HTTP response code (e.g a 4xx response) but rather requests that fail due to native - /// errors (e.g failed host lookup) which can occur if there is no internet. - RetryOptions httpRetryOptions; - - /// The encoding protocol to use when receiving/sending payloads. - Encoding payloadEncoding; - - /// Enable payload compression. - /// This cannot be used with the [Encoding.etf] encoding. - /// This will also be disabled if [compressedGatewayPayloads] is used. - bool payloadCompression; - - /// Makes a new `ClientOptions` object. - ClientOptions({ - this.allowedMentions, - this.shardCount, - this.messageCacheSize = 100, - this.largeThreshold = 50, - this.compressedGatewayPayloads = true, - this.initialPresence, - this.shutdownHook, - this.shutdownShardHook, - this.dispatchRawShardEvent = false, - this.shardIds, - this.shardReconnectOptions = const RetryOptions(), - this.httpRetryOptions = const RetryOptions(), - this.payloadEncoding = Encoding.json, - this.payloadCompression = false, - }); -} - -/// When identifying to the gateway, you can specify an intents parameter which -/// allows you to conditionally subscribe to pre-defined "intents", groups of events defined by Discord. -/// If you do not specify a certain intent, you will not receive any of the gateway events that are batched into that group. -/// [Reference](https://discordapp.com/developers/docs/topics/gateway#gateway-intents) -class GatewayIntents { - /// Includes events: `GUILD_CREATE, GUILD_UPDATE, GUILD_DELETE, GUILD_ROLE_CREATE, GUILD_ROLE_UPDATE, GUILD_ROLE_DELETE, CHANNEL_DELETE, CHANNEL_CREATE, CHANNEL_UPDATE, CHANNEL_PINS_UPDATE` - static const int guilds = 1 << 0; - - /// Includes events: `GUILD_MEMBER_ADD, GUILD_MEMBER_UPDATE, GUILD_MEMBER_REMOVE` - static const int guildMembers = 1 << 1; - - /// Includes events: `GUILD_BAN_ADD, GUILD_BAN_REMOVE` - static const int guildBans = 1 << 2; - - /// Includes event: `GUILD_EMOJIS_UPDATE` - static const int guildEmojis = 1 << 3; + /// The [CacheConfig] to use for the [Emoji]s in the [Guild.emojis] manager. + final CacheConfig emojiCacheConfig; - /// Includes events: `GUILD_INTEGRATIONS_UPDATE` - static const int guildIntegrations = 1 << 4; + /// The [CacheConfig] to use for the [GuildSticker]s in the [Guild.stickers] manager. + final CacheConfig stickerCacheConfig; - /// Includes events: `WEBHOOKS_UPDATE` - static const int guildWebhooks = 1 << 5; + /// The [CacheConfig] to use for the [GlobalSticker]s in the [NyxxRest.stickers] manager. + final CacheConfig globalStickerCacheConfig; - /// Includes events: `INVITE_CREATE, INVITE_DELETE` - static const int guildInvites = 1 << 6; + /// The [CacheConfig] to use for [StageInstance]s in the [NyxxRest.channels] manager. + final CacheConfig stageInstanceCacheConfig; - /// Includes events: `VOICE_STATE_UPDATE` - static const int guildVoiceState = 1 << 7; + /// The [CacheConfig] to use for the [Guild.scheduledEvents] manager. + final CacheConfig scheduledEventCacheConfig; - /// Includes events: `PRESENCE_UPDATE` - static const int guildPresences = 1 << 8; + /// The [CacheConfig] to use for the [Guild.autoModerationRules] manager. + final CacheConfig autoModerationRuleConfig; - /// Include events: `MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, MESSAGE_DELETE_BULK` - static const int guildMessages = 1 << 9; + /// The [CacheConfig] to use for the [Guild.integrations] manager. + final CacheConfig integrationConfig; - /// Includes events: `MESSAGE_REACTION_ADD, MESSAGE_REACTION_REMOVE, MESSAGE_REACTION_REMOVE_ALL, MESSAGE_REACTION_REMOVE_EMOJI` - static const int guildMessageReactions = 1 << 10; + /// The [CacheConfig] to use for the [Guild.auditLogs] manager. + final CacheConfig auditLogEntryConfig; - /// Includes events: `TYPING_START` - static const int guildMessageTyping = 1 << 11; + /// The [CacheConfig] to use for the [NyxxRest.voice] manager. + final CacheConfig voiceStateConfig; - /// Includes events: `CHANNEL_CREATE, MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, CHANNEL_PINS_UPDATE` - static const int directMessages = 1 << 12; + /// The [CacheConfig] to use for the [NyxxRest.commands] manager. + final CacheConfig applicationCommandConfig; - /// Includes events: `MESSAGE_REACTION_ADD, MESSAGE_REACTION_REMOVE, MESSAGE_REACTION_REMOVE_ALL, MESSAGE_REACTION_REMOVE_EMOJI` - static const int directMessageReactions = 1 << 13; + /// The [CacheConfig] to use for the [GuildApplicationCommandManager.permissionsCache] cache. + final CacheConfig commandPermissionsConfig; - /// Includes events: `TYPING_START` - static const int directMessageTyping = 1 << 14; + /// The ID of the application the client is authenticating for. + final Snowflake? applicationId; - /// Includes public content of messages in guilds (content, embeds, attachments, components) - /// If your bot is mentioned it will always receive full message - /// If you are not opted in for message content intent you will receive empty fields - static const int messageContent = 1 << 15; - - /// Includes events: `GUILD_SCHEDULED_EVENT_CREATE`, `GUILD_SCHEDULED_EVENT_DELETE`, `GUILD_SCHEDULED_EVENT_UPDATE`, `GUILD_SCHEDULED_EVENT_USER_ADD`, `GUILD_SCHEDULED_EVENT_USER_REMOVE` - static const int guildScheduledEvents = 1 << 16; - - /// Includes events: `AUTO_MODERATION_RULE_CREATE`, `AUTO_MODERATION_RULE_UPDATE`, `AUTO_MODERATION_RULE_DELETE` - static const int autoModerationConfiguration = 1 << 20; - - /// Includes events: `AUTO_MODERATION_ACTION_EXECUTION` - static const int autoModerationExecution = 1 << 21; - - /// All unprivileged intents - static const int allUnprivileged = guilds | - guildBans | - guildEmojis | - guildIntegrations | - guildWebhooks | - guildInvites | - guildVoiceState | - guildMessages | - guildMessageReactions | - guildMessageTyping | - directMessages | - directMessageReactions | - directMessageTyping | - guildScheduledEvents | - autoModerationConfiguration | - autoModerationExecution; - - /// All privileged intents - static const int allPrivileged = guildMembers | guildPresences | messageContent; - - /// All intents - static const int all = allUnprivileged | allPrivileged; - - /// No intents. Client shouldn't receive any events. - static const int none = 0; + /// Create a new [RestClientOptions]. + const RestClientOptions({ + super.plugins, + super.loggerName, + this.userCacheConfig = const CacheConfig(), + this.channelCacheConfig = const CacheConfig(), + this.messageCacheConfig = const CacheConfig(), + this.webhookCacheConfig = const CacheConfig(), + this.guildCacheConfig = const CacheConfig(), + this.memberCacheConfig = const CacheConfig(), + this.roleCacheConfig = const CacheConfig(), + this.emojiCacheConfig = const CacheConfig(), + this.stageInstanceCacheConfig = const CacheConfig(), + this.scheduledEventCacheConfig = const CacheConfig(), + this.autoModerationRuleConfig = const CacheConfig(), + this.integrationConfig = const CacheConfig(), + this.auditLogEntryConfig = const CacheConfig(), + this.voiceStateConfig = const CacheConfig(), + this.stickerCacheConfig = const CacheConfig(), + this.globalStickerCacheConfig = const CacheConfig(), + this.applicationCommandConfig = const CacheConfig(), + this.commandPermissionsConfig = const CacheConfig(), + this.applicationId, + }); } -/// Hook executed when disposing bots process. -/// -/// Executed most likely when process receives SIGINT (*nix) or SIGTERM (*nix and windows). -/// Not guaranteed to be completed or executed at all. -typedef ShutdownHook = Future Function(NyxxWebsocket client); - -/// Hook executed when shard is disposing. -/// -/// It could be either when shards disconnects or when bots process shuts down (look [ShutdownHook]. -typedef ShutdownShardHook = Future Function(NyxxWebsocket client, Shard shard); +/// Options for controlling the behavior of a [NyxxWebsocket] client. +class GatewayClientOptions extends RestClientOptions { + /// The minimum number of session starts this client needs to connect. + /// + /// This is a safety feature to avoid API bans due to excessive connection starts. + /// + /// If the remaining number of session starts is below this number, an error will be thrown when connecting. + final int minimumSessionStarts; + + const GatewayClientOptions({ + this.minimumSessionStarts = 10, + super.plugins, + super.loggerName, + super.userCacheConfig, + super.channelCacheConfig, + super.messageCacheConfig, + super.webhookCacheConfig, + super.guildCacheConfig, + super.memberCacheConfig, + super.roleCacheConfig, + super.stageInstanceCacheConfig, + super.scheduledEventCacheConfig, + super.autoModerationRuleConfig, + super.integrationConfig, + super.auditLogEntryConfig, + super.voiceStateConfig, + super.applicationCommandConfig, + super.commandPermissionsConfig, + super.applicationId, + }); +} diff --git a/lib/src/core/allowed_mentions.dart b/lib/src/core/allowed_mentions.dart deleted file mode 100644 index a29d7342a..000000000 --- a/lib/src/core/allowed_mentions.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -/// The allowed mention field allows for more granular control over mentions without various hacks to the message content. -/// This will always validate against message content to avoid phantom pings (e.g. to ping everyone, you must still have @everyone in the message content), and check against user/bot permissions. -/// -/// If class is only instantiated without any modifications to its fields, by default it will suppress all mentions. -class AllowedMentions extends Builder { - bool _allowEveryone = false; - bool _allowUsers = false; - bool _allowRoles = false; - bool _allowReply = false; - - final List _users = []; - final List _roles = []; - - /// Allow @everyone and @here if [everyone] is true - /// Allow @user if [users] is true - /// Allow @role if [roles] is true - /// Mention the user on reply if [reply] is true - void allow({bool? reply, bool? everyone, bool? users, bool? roles}) { - if (everyone != null) { - _allowEveryone = everyone; - } - - if (users != null) { - _allowUsers = users; - } - if (roles != null) { - _allowRoles = roles; - } - - if (reply != null) { - _allowReply = reply; - } - } - - /// Suppress mentioning specific user by its id - void suppressUser(Snowflake userId) { - _users.add(userId); - } - - /// Suppress mentioning multiple users by their ids - void suppressUsers(Iterable userIds) { - _users.addAll(userIds); - } - - /// Suppress mentioning specific role by its id - void suppressRole(Snowflake roleId) { - _roles.add(roleId); - } - - /// Suppress mentioning multiple roles by their ids - void suppressRoles(Iterable roleIds) { - _roles.addAll(roleIds); - } - - @override - RawApiMap build() { - final map = { - "parse": [ - if (_allowEveryone) "everyone", - if (_allowRoles) "roles", - if (_allowUsers) "users", - ], - "replied_user": _allowReply - }; - - if (_users.isNotEmpty) { - if (!_allowUsers) { - throw ArgumentError("Invalid configuration of allowed mentions! Allowed `user` and blacklisted users at the same time!"); - } - - map["users"] = _users.map((e) => e.id.toString()); - } - - if (_roles.isNotEmpty) { - if (!_allowRoles) { - throw ArgumentError("Invalid configuration of allowed mentions! Allowed `roles` and blacklisted roles at the same time!"); - } - - map["roles"] = _roles.map((e) => e.id.toString()); - } - - return map; - } -} diff --git a/lib/src/core/application/app_team.dart b/lib/src/core/application/app_team.dart deleted file mode 100644 index 14bc9a9db..000000000 --- a/lib/src/core/application/app_team.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/application/app_team_member.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -/// Object of team that manages given app -abstract class IAppTeam implements SnowflakeEntity { - /// Reference to [INyxx]. - INyxx get client; - - /// Hash of team icon - String? get iconHash; - - /// Id of Team owner - Snowflake get ownerId; - - /// List of members of team - List get members; - - /// Returns instance of [IAppTeamMember] of team owner - IAppTeamMember get ownerMember; - - /// The team's name. - String get name; - - /// Returns URL to team icon with given [format] and [size]. - String? iconUrl({String format = 'webp', int? size}); -} - -/// Object of team that manages given app -class AppTeam extends SnowflakeEntity implements IAppTeam { - /// Hash of team icon - @override - late final String? iconHash; - - /// Id of Team owner - @override - late final Snowflake ownerId; - - /// List of members of team - @override - late final List members; - - /// Returns instance of [IAppTeamMember] of team owner - @override - IAppTeamMember get ownerMember => members.firstWhere((element) => element.user.id == ownerId); - - @override - final INyxx client; - - @override - late final String name; - - /// Creates an instance of [AppTeam] - AppTeam(RawApiMap raw, this.client) : super(Snowflake(raw["id"])) { - iconHash = raw["icon"] as String?; - ownerId = Snowflake(raw["owner_user_id"]); - name = raw['name'] as String; - - members = [for (final rawMember in raw["members"] as RawApiList) AppTeamMember(rawMember as RawApiMap, client)]; - } - - /// Returns url to team icon - @override - String? iconUrl({String format = 'webp', int? size}) { - if (iconHash == null) { - return null; - } - - return client.cdnHttpEndpoints.teamIcon(id, iconHash!, format: format, size: size); - } -} diff --git a/lib/src/core/application/app_team_member.dart b/lib/src/core/application/app_team_member.dart deleted file mode 100644 index 46577b386..000000000 --- a/lib/src/core/application/app_team_member.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:nyxx/src/core/application/app_team_user.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -/// Represent membership of user in app team -abstract class IAppTeamMember { - /// Reference to [INyxx]. - INyxx get client; - - /// Basic information of user - IAppTeamUser get user; - - /// State of membership - int get membershipState; -} - -/// Represent membership of user in app team -class AppTeamMember implements IAppTeamMember { - /// Basic information of user - @override - late final IAppTeamUser user; - - /// State of membership - @override - late final int membershipState; - - @override - final INyxx client; - - /// Creates and instance of [AppTeamMember] - AppTeamMember(RawApiMap raw, this.client) { - user = AppTeamUser(raw["user"] as RawApiMap, client); - membershipState = raw["membership_state"] as int; - } -} diff --git a/lib/src/core/application/app_team_user.dart b/lib/src/core/application/app_team_user.dart deleted file mode 100644 index a7a75d172..000000000 --- a/lib/src/core/application/app_team_user.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IAppTeamUser implements SnowflakeEntity { - /// Reference to [INyxx]. - INyxx get client; - - /// The user's username. - String get username; - - /// The user's discriminator. - String get discriminator; - - /// The user's avatar hash. - String? get avatar; - - /// The user's avatar, represented as URL. - /// In case if user does not have avatar, default discord avatar will be returned; [format], [size] and [animated] will no longer affectng this URL. - /// If [animated] is set as `true`, if available, the url will be a gif, otherwise the [format] or fallback to "webp". - String avatarUrl({String format = 'webp', int? size}); -} - -/// Represent user in member context -class AppTeamUser extends SnowflakeEntity implements IAppTeamUser { - /// The user's username. - @override - late final String username; - - /// The user's discriminator. - @override - late final String discriminator; - - /// The user's avatar hash. - @override - late final String? avatar; - - @override - final INyxx client; - - /// Creates an instance of [AppTeamUser] - AppTeamUser(RawApiMap raw, this.client) : super(Snowflake(raw["id"])) { - username = raw["username"] as String; - discriminator = raw["discriminator"] as String; - avatar = raw["avatar"] as String?; - } - - @override - String avatarUrl({String format = 'webp', int? size, bool animated = false}) { - if (avatar == null) { - return client.cdnHttpEndpoints.defaultAvatar(int.tryParse(discriminator) ?? 0); - } - - return client.cdnHttpEndpoints.avatar(id, avatar!, format: format, size: size, animated: animated); - } -} diff --git a/lib/src/core/application/client_oauth2_application.dart b/lib/src/core/application/client_oauth2_application.dart deleted file mode 100644 index be18a261b..000000000 --- a/lib/src/core/application/client_oauth2_application.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/application/oauth2_application.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/permissions.dart'; - -/// The client's OAuth2 app, if the client is a bot. -abstract class IClientOAuth2Application implements IOAuth2Application { - /// The app's flags. - IApplicationFlags? get flags; - - /// The app's owner. - IUser get owner; - - /// When false only app owner can join the app's bot to guilds. - bool get isPublic; - - /// When true the app's bot will only join upon completion of the full oauth2 code grant flow. - bool get requireCodeGrant; - - /// Creates an OAuth2 URL with the specified permissions. - String getInviteUrl([int? permissions]); -} - -/// The client's OAuth2 app, if the client is a bot. -class ClientOAuth2Application extends OAuth2Application implements IClientOAuth2Application { - /// The app's flags. - @override - late final IApplicationFlags? flags; - - /// The app's owner. - @override - late final IUser owner; - - @override - late final bool isPublic; - - @override - late final bool requireCodeGrant; - - /// Creates an instance of [ClientOAuth2Application] - ClientOAuth2Application(RawApiMap raw, INyxx client) : super(raw, client) { - flags = raw["flags"] != null ? ApplicationFlags(raw['flags'] as int) : null; - owner = User(client, raw["owner"] as RawApiMap); - isPublic = raw['bot_public'] as bool; - requireCodeGrant = raw['bot_require_code_grant'] as bool; - } - - /// Creates an OAuth2 URL with the specified permissions. - @override - String getInviteUrl([int? permissions]) => client.httpEndpoints.getApplicationInviteUrl(id, permissions); -} - -/// https://discord.com/developers/docs/resources/application#application-object-application-flags -abstract class IApplicationFlags { - /// Intent required for bots in 100 or more servers to receive `presence_update` events. - bool get gatewayPresence; - - /// Intent required for bots in under 100 servers to receive `presence_update` events, found in Bot Settings - bool get gatewayPresenceLimited; - - /// Intent required for bots in 100 or more servers to receive member-related events like `guild_member_add`. - bool get gatewayGuildMembers; - - /// Intent required for bots in under 100 servers to receive member-related events like `guild_member_add`, found in Bot Settings. - bool get gatewayGuildMembersLimited; - - /// Indicates unusual growth of an app that prevents verification. - bool get verificationPendingGuildLimit; - - /// Indicates if an app is embedded within the Discord client (currently unavailable publicly). - bool get embedded; - - /// Intent required for bots in 100 or more servers to receive message content. - bool get gatewayMessageContent; - - /// Intent required for bots in under 100 servers to receive message content, found in Bot Settings. - bool get gatewayMessageContentLimited; - - /// Indicates if an app has registered global [application commands](https://discord.com/developers/docs/interactions/application-commands). - bool get applicationCommandBadge; -} - -class ApplicationFlags implements IApplicationFlags { - @override - bool get applicationCommandBadge => PermissionsUtils.isApplied(raw, 1 << 12); - - @override - bool get embedded => PermissionsUtils.isApplied(raw, 1 << 13); - - @override - bool get gatewayGuildMembers => PermissionsUtils.isApplied(raw, 1 << 14); - - @override - bool get gatewayGuildMembersLimited => PermissionsUtils.isApplied(raw, 1 << 15); - - @override - bool get gatewayMessageContent => PermissionsUtils.isApplied(raw, 1 << 16); - - @override - bool get gatewayMessageContentLimited => PermissionsUtils.isApplied(raw, 1 << 17); - - @override - bool get gatewayPresence => PermissionsUtils.isApplied(raw, 1 << 18); - - @override - bool get gatewayPresenceLimited => PermissionsUtils.isApplied(raw, 1 << 19); - - @override - bool get verificationPendingGuildLimit => PermissionsUtils.isApplied(raw, 1 << 23); - - final int raw; - const ApplicationFlags(this.raw); -} diff --git a/lib/src/core/application/oauth2_application.dart b/lib/src/core/application/oauth2_application.dart deleted file mode 100644 index ded241750..000000000 --- a/lib/src/core/application/oauth2_application.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:nyxx/src/core/application/app_team.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IOAuth2Application implements SnowflakeEntity { - /// The app's description. - String get description; - - /// The app's icon hash. - String? get icon; - - /// The app's cover hash. - String? get coverImage; - - /// The app's name. - String get name; - - /// The app's RPC origins. - List? get rpcOrigins; - - /// Reference to [INyxx]. - INyxx get client; - - /// If the application belongs to a team, this will be a list of the members of that team. - IAppTeam? get team; - - /// The url of the app's terms of service. - String? get termsOfServiceUrl; - - /// The url of the app's privacy policy. - String? get privacyPolicyUrl; - - /// The hex encoded key for verification in interactions and the GameSDK's [GetTicket](https://discord.com/developers/docs/game-sdk/applications#getticket) - String get verifyKey; - - /// If this application is a game sold on Discord, this field will be the guild to which it has been linked. - Snowflake? get guildId; - - /// If this application is a game sold on Discord, this field will be the id of the "Game SKU" that is created, if exists. - Snowflake? get primarySkuId; - - /// If this application is a game sold on Discord, this field will be the URL slug that links to the store page. - String? get slug; - - /// Returns URL to app's icon. - String? iconUrl({String format = 'webp', int? size}); - - /// Returns the cover image URL of the app. - String? coverImageUrl({String format = 'webp', int? size}); -} - -/// An OAuth2 application. -class OAuth2Application extends SnowflakeEntity implements IOAuth2Application { - /// The app's description. - @override - late final String description; - - /// The app's icon hash. - @override - late final String? icon; - - /// The app's name. - @override - late final String name; - - /// The app's RPC origins. - @override - late final List? rpcOrigins; - - @override - late final String? coverImage; - - @override - final INyxx client; - - @override - late final IAppTeam? team; - - @override - late final String? termsOfServiceUrl; - - @override - late final String? privacyPolicyUrl; - - @override - late final String verifyKey; - - @override - late final Snowflake? guildId; - - @override - late final Snowflake? primarySkuId; - - @override - late final String? slug; - - /// Creates an instance of [OAuth2Application] - OAuth2Application(RawApiMap raw, this.client) : super(Snowflake(raw["id"])) { - description = raw["description"] as String; - name = raw["name"] as String; - - icon = raw["icon"] as String?; - rpcOrigins = (raw["rpc_origins"] as List?)?.cast(); - coverImage = raw['cover_image'] as String?; - if (raw['team'] != null) { - team = AppTeam(raw['team'] as RawApiMap, client); - } else { - team = null; - } - - termsOfServiceUrl = raw['terms_of_service_url'] as String?; - privacyPolicyUrl = raw['privacy_policy_url'] as String?; - verifyKey = raw['verify_key'] as String; - guildId = raw['guild_id'] != null ? Snowflake(raw['guild_id']) : null; - primarySkuId = raw['primary_sku_id'] != null ? Snowflake(raw['primary_sku_id']) : null; - slug = raw['slug'] as String?; - } - - /// Returns url to apps icon - @override - String? iconUrl({String format = 'webp', int? size}) { - if (icon == null) { - return null; - } - - return client.cdnHttpEndpoints.appIcon(id, icon!, format: format, size: size); - } - - @override - String? coverImageUrl({String format = 'webp', int? size}) { - if (coverImage == null) { - return null; - } - - return client.cdnHttpEndpoints.appIcon(id, coverImage!, format: format, size: size); - } -} diff --git a/lib/src/core/audit_logs/audit_log.dart b/lib/src/core/audit_logs/audit_log.dart deleted file mode 100644 index 21d412d8c..000000000 --- a/lib/src/core/audit_logs/audit_log.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:nyxx/src/core/audit_logs/audit_log_entry.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/guild/auto_moderation.dart'; -import 'package:nyxx/src/core/guild/scheduled_event.dart'; -import 'package:nyxx/src/core/guild/webhook.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IAuditLog { - /// Map of webhooks found in the audit log. - late final Map webhooks; - - /// Map of users found in the audit log. - late final Map users; - - /// Map of audit log entries. - late final Map entries; - - /// Map of auto moderation rules referenced in the audit log. - late final Map autoModerationRules; - - /// Map of guild scheduled events referenced in the audit log. - late final Map events; - - /// Map of threads referenced in the audit log. - late final Map threads; - - /// Filters audit log by [users] - Iterable filter(bool Function(IAuditLogEntry) test); -} - -/// Whenever an admin action is performed on the API, an entry is added to the respective guild's audit log. -/// -/// [Look here for more](https://discordapp.com/developers/docs/resources/audit-log) -class AuditLog implements IAuditLog { - /// Map of webhooks found in the audit log - @override - late final Map webhooks; - - /// Map of users found in the audit log - @override - late final Map users; - - /// Map of audit log entries - @override - late final Map entries; - - /// Map of auto moderation rules referenced in the audit log - @override - late final Map autoModerationRules; - - /// Map of guild scheduled events referenced in the audit log - @override - late final Map events; - - /// Map of threads referenced in the audit log. - @override - late final Map threads; - - /// Creates an instance of [AuditLog] - AuditLog(RawApiMap raw, INyxx client) { - webhooks = {}; - users = {}; - entries = {}; - autoModerationRules = {}; - events = {}; - threads = {}; - - raw["webhooks"].forEach((o) { - webhooks[Snowflake(o["id"])] = Webhook(o as RawApiMap, client); - }); - - raw["users"].forEach((o) { - users[Snowflake(o["id"])] = User(client, o as RawApiMap); - }); - - raw["audit_log_entries"].forEach((o) { - entries[Snowflake(o["id"])] = AuditLogEntry(o as RawApiMap, client); - }); - - raw['auto_moderation_rules'].forEach((o) { - autoModerationRules[Snowflake(o['id'])] = AutoModerationRule(o as RawApiMap, client); - }); - - raw['guild_scheduled_events'].forEach((o) { - events[Snowflake(o['id'])] = GuildEvent(o as RawApiMap, client); - }); - - raw['threads'].forEach((o) { - threads[Snowflake(o['id'])] = ThreadChannel(client, o as RawApiMap); - }); - } - - /// Filters audit log by [entries] - @override - Iterable filter(bool Function(IAuditLogEntry) test) => entries.values.where(test); -} diff --git a/lib/src/core/audit_logs/audit_log_change.dart b/lib/src/core/audit_logs/audit_log_change.dart deleted file mode 100644 index fa23d706b..000000000 --- a/lib/src/core/audit_logs/audit_log_change.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/enum.dart'; - -abstract class IAuditLogChange { - /// New value - dynamic get newValue; - - /// Old value - dynamic get oldValue; - - /// type of audit log change hey - ChangeKeyType get key; -} - -/// Represents change made in guild with old and new value -/// -/// [Look here for more](https://discordapp.com/developers/docs/resources/audit-log) -class AuditLogChange implements IAuditLogChange { - /// New value - @override - dynamic newValue; - - /// Old value - @override - dynamic oldValue; - - /// type of audit log change hey - @override - late final ChangeKeyType key; - - /// Creates an instance of [AuditLogChange] - AuditLogChange(RawApiMap raw) { - if (raw["new_value"] != null) { - newValue = raw["new_value"]; - } - - if (raw["old_value"] != null) { - oldValue = raw["old_value"]; - } - - key = ChangeKeyType.from(raw["key"] as String); - } -} - -/// Type of change in audit log -class ChangeKeyType extends IEnum { - static const ChangeKeyType name = ChangeKeyType._create("name"); - static const ChangeKeyType iconHash = ChangeKeyType._create("icon_hash"); - static const ChangeKeyType splashHash = ChangeKeyType._create("splash_hash"); - static const ChangeKeyType ownerId = ChangeKeyType._create("owner_id"); - static const ChangeKeyType region = ChangeKeyType._create("region"); - static const ChangeKeyType afkChannelId = ChangeKeyType._create("afk_channel_id"); - static const ChangeKeyType afkTimeout = ChangeKeyType._create("afk_timeout"); - static const ChangeKeyType mfaLevel = ChangeKeyType._create("mfa_level"); - static const ChangeKeyType verificationLevel = ChangeKeyType._create("verification_level"); - static const ChangeKeyType explicitContentFilter = ChangeKeyType._create("explicit_content_filter"); - static const ChangeKeyType defaultMessageNotifications = ChangeKeyType._create("default_message_notifications"); - static const ChangeKeyType $add = ChangeKeyType._create("\$add"); - static const ChangeKeyType $remove = ChangeKeyType._create("\$remove"); - static const ChangeKeyType pruneDeleteDays = ChangeKeyType._create("prune_delete_days"); - static const ChangeKeyType widgetEnabled = ChangeKeyType._create("widget_enabled"); - static const ChangeKeyType widgetChannelId = ChangeKeyType._create("widget_channel_id"); - static const ChangeKeyType position = ChangeKeyType._create("position"); - static const ChangeKeyType topic = ChangeKeyType._create("topic"); - static const ChangeKeyType bitrate = ChangeKeyType._create("bitrate"); - static const ChangeKeyType slowmode = ChangeKeyType._create("rate_limit_per_user"); - static const ChangeKeyType permissionOverwrites = ChangeKeyType._create("permission_overwrites"); - static const ChangeKeyType nsfw = ChangeKeyType._create("nsfw"); - static const ChangeKeyType applicationId = ChangeKeyType._create("application_id"); - static const ChangeKeyType permissions = ChangeKeyType._create("permissions"); - static const ChangeKeyType color = ChangeKeyType._create("color"); - static const ChangeKeyType hoist = ChangeKeyType._create("hoist"); - static const ChangeKeyType mentionable = ChangeKeyType._create("mentionable"); - - static const ChangeKeyType allow = ChangeKeyType._create("allow"); - static const ChangeKeyType deny = ChangeKeyType._create("deny"); - static const ChangeKeyType code = ChangeKeyType._create("code"); - static const ChangeKeyType channelId = ChangeKeyType._create("channel_id"); - static const ChangeKeyType inviterId = ChangeKeyType._create("inviter_id"); - static const ChangeKeyType maxUses = ChangeKeyType._create("max_uses"); - static const ChangeKeyType uses = ChangeKeyType._create("uses"); - static const ChangeKeyType maxAge = ChangeKeyType._create("max_age"); - static const ChangeKeyType temporary = ChangeKeyType._create("temporary"); - static const ChangeKeyType deaf = ChangeKeyType._create("deaf"); - static const ChangeKeyType mute = ChangeKeyType._create("mute"); - static const ChangeKeyType nick = ChangeKeyType._create("nick"); - - static const ChangeKeyType avatarHash = ChangeKeyType._create("avatar_hash"); - static const ChangeKeyType id = ChangeKeyType._create("id"); - static const ChangeKeyType type = ChangeKeyType._create("type"); - - /// Creates instance of [ChangeKeyType] from [value] - ChangeKeyType.from(String value) : super(value); - const ChangeKeyType._create(String value) : super(value); - - @override - bool operator ==(dynamic other) { - if (other is String) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/audit_logs/audit_log_entry.dart b/lib/src/core/audit_logs/audit_log_entry.dart deleted file mode 100644 index 30e6c4a06..000000000 --- a/lib/src/core/audit_logs/audit_log_entry.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/audit_logs/audit_log_change.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/enum.dart'; -import 'package:nyxx/src/core/audit_logs/audit_log_options.dart'; - -abstract class IAuditLogEntry implements SnowflakeEntity { - /// Id of the affected entity (webhook, user, role, etc.) - String? get targetId; - - /// Changes made to the target_id - List get changes; - - /// The user who made the changes - Cacheable get user; - - /// Type of action that occurred - AuditLogEntryType get type; - - /// Additional info for certain action types - IAuditLogOptions? get options; - - /// The reason for the change - String? get reason; -} - -/// Single entry of Audit Log -/// -/// /// [Look here for more](https://discordapp.com/developers/docs/resources/audit-log) -class AuditLogEntry extends SnowflakeEntity implements IAuditLogEntry { - /// Id of the affected entity (webhook, user, role, etc.) - @override - late final String? targetId; - - /// Changes made to the target_id - @override - late final List changes; - - /// The user who made the changes - @override - late final Cacheable user; - - /// Type of action that occurred - @override - late final AuditLogEntryType type; - - /// Additional info for certain action types - @override - late final IAuditLogOptions? options; - - /// The reason for the change - @override - late final String? reason; - - /// Creates an instance of [AuditLogEntry] - AuditLogEntry(RawApiMap raw, INyxx client) : super(Snowflake(raw["id"] as String)) { - targetId = raw["target_id"] as String?; - - changes = [ - if (raw["changes"] != null) - for (var o in raw["changes"] as RawApiList) AuditLogChange(o as RawApiMap) - ]; - - user = UserCacheable(client, Snowflake(raw["user_id"])); - type = AuditLogEntryType._create(raw["action_type"] as int); - - if (raw["options"] != null) { - options = AuditLogOptions(raw["options"] as RawApiMap); - } else { - options = null; - } - - reason = raw["reason"] as String?; - } -} - -class AuditLogEntryType extends IEnum { - static const AuditLogEntryType unknown = AuditLogEntryType._create(0); - static const AuditLogEntryType guildUpdate = AuditLogEntryType._create(1); - static const AuditLogEntryType channelCreate = AuditLogEntryType._create(10); - static const AuditLogEntryType channelUpdate = AuditLogEntryType._create(11); - static const AuditLogEntryType channelDelete = AuditLogEntryType._create(12); - static const AuditLogEntryType channelOverwriteCreate = AuditLogEntryType._create(13); - static const AuditLogEntryType channelOverwriteUpdate = AuditLogEntryType._create(14); - static const AuditLogEntryType channelOverwriteDelete = AuditLogEntryType._create(15); - static const AuditLogEntryType memberKick = AuditLogEntryType._create(20); - static const AuditLogEntryType memberPrune = AuditLogEntryType._create(21); - static const AuditLogEntryType memberBanAdd = AuditLogEntryType._create(22); - static const AuditLogEntryType memberBanRemove = AuditLogEntryType._create(23); - static const AuditLogEntryType memberUpdate = AuditLogEntryType._create(24); - static const AuditLogEntryType memberRoleUpdate = AuditLogEntryType._create(25); - static const AuditLogEntryType memberMove = AuditLogEntryType._create(26); - static const AuditLogEntryType memberDisconnect = AuditLogEntryType._create(27); - static const AuditLogEntryType botAdd = AuditLogEntryType._create(28); - static const AuditLogEntryType roleCreate = AuditLogEntryType._create(30); - static const AuditLogEntryType roleUpdate = AuditLogEntryType._create(31); - static const AuditLogEntryType roleDelete = AuditLogEntryType._create(32); - static const AuditLogEntryType inviteCreate = AuditLogEntryType._create(40); - static const AuditLogEntryType inviteUpdate = AuditLogEntryType._create(41); - static const AuditLogEntryType inviteDelete = AuditLogEntryType._create(42); - static const AuditLogEntryType webhookCreate = AuditLogEntryType._create(50); - static const AuditLogEntryType webhookUpdate = AuditLogEntryType._create(51); - static const AuditLogEntryType webhookDelete = AuditLogEntryType._create(52); - static const AuditLogEntryType emojiCreate = AuditLogEntryType._create(60); - static const AuditLogEntryType emojiUpdate = AuditLogEntryType._create(61); - static const AuditLogEntryType emojiDelete = AuditLogEntryType._create(62); - static const AuditLogEntryType messageDelete = AuditLogEntryType._create(72); - static const AuditLogEntryType messageBulkDelete = AuditLogEntryType._create(73); - static const AuditLogEntryType messagePin = AuditLogEntryType._create(74); - static const AuditLogEntryType messageUnpin = AuditLogEntryType._create(75); - static const AuditLogEntryType integrationCreate = AuditLogEntryType._create(80); - static const AuditLogEntryType integrationUpdate = AuditLogEntryType._create(81); - static const AuditLogEntryType integrationDelete = AuditLogEntryType._create(82); - static const AuditLogEntryType stageInstanceCreate = AuditLogEntryType._create(83); - static const AuditLogEntryType stageInstanceUpdate = AuditLogEntryType._create(84); - static const AuditLogEntryType stageInstanceDelete = AuditLogEntryType._create(85); - static const AuditLogEntryType stickerCreate = AuditLogEntryType._create(90); - static const AuditLogEntryType stickerUpdate = AuditLogEntryType._create(91); - static const AuditLogEntryType stickerDelete = AuditLogEntryType._create(92); - static const AuditLogEntryType guildScheduledEventCreate = AuditLogEntryType._create(100); - static const AuditLogEntryType guildScheduledEventUpdate = AuditLogEntryType._create(101); - static const AuditLogEntryType guildScheduledEventDelete = AuditLogEntryType._create(102); - static const AuditLogEntryType threadCreate = AuditLogEntryType._create(110); - static const AuditLogEntryType threadUpdate = AuditLogEntryType._create(111); - static const AuditLogEntryType threadDelete = AuditLogEntryType._create(112); - static const AuditLogEntryType applicationCommandPermissionUpdate = AuditLogEntryType._create(121); - static const AuditLogEntryType autoModerationRuleCreate = AuditLogEntryType._create(140); - static const AuditLogEntryType autoModerationRuleUpdate = AuditLogEntryType._create(141); - static const AuditLogEntryType autoModerationRuleDelete = AuditLogEntryType._create(142); - static const AuditLogEntryType autoModerationBlockMessage = AuditLogEntryType._create(143); - - const AuditLogEntryType._create(int value) : super(value); - - @override - bool operator ==(dynamic other) { - if (other is int) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/audit_logs/audit_log_options.dart b/lib/src/core/audit_logs/audit_log_options.dart deleted file mode 100644 index 5f98e5355..000000000 --- a/lib/src/core/audit_logs/audit_log_options.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -/// Additional info for certain action types -/// -/// [Look here for more](https://discord.com/developers/docs/resources/audit-log#audit-log-entry-object-audit-log-events) -abstract class IAuditLogOptions { - /// The channel in which the entities were targeted. - Snowflake? get channelId; - - /// The number of entities targeted. - int? get count; - - /// The number of days after which inactive users will be kicked. - Duration? get deleteMemberDuration; - - /// Id of the overwritten entity. - Snowflake? get id; - - /// The number of the members removed by the prune. - int? get pruneCount; - - /// The id of the message that was targeted. - Snowflake? get messageId; - - /// The name of the role that was targeted. (Not present if [overwrittenType] is `member`). - String? get roleName; - - /// Type of overwritten entity. - /// One of: - /// - `role` - /// - `member` - String? get overwrittenType; -} - -class AuditLogOptions implements IAuditLogOptions { - /// The channel in which the entities were targeted. - @override - late final Snowflake? channelId; - - /// The number of entities targeted. - @override - late final int? count; - - /// The number of days after which inactive users will be kicked. - @override - late final Duration? deleteMemberDuration; - - /// Id of the overwritten entity. - @override - late final Snowflake? id; - - /// The number of the members removed by the prune. - @override - late final int? pruneCount; - - /// The id of the message that was targeted. - @override - late final Snowflake? messageId; - - /// The name of the role that was targeted. (Not present if [overwrittenType] is `member`). - @override - late final String? roleName; - - /// Type of overwritten entity. - /// One of: - /// - `role` - /// - `member` - @override - late final String? overwrittenType; - - AuditLogOptions(RawApiMap raw) { - if (raw['channel_id'] != null) { - channelId = Snowflake(raw['channel_id']); - } else { - channelId = null; - } - - count = raw['count'] != null ? int.parse(raw['count'] as String) : null; - deleteMemberDuration = (raw['delete_member_days'] as String?) != null ? Duration(days: int.parse(raw['delete_member_days'] as String)) : null; - if (raw['id'] != null) { - id = Snowflake(raw['id']); - } else { - id = null; - } - - pruneCount = (raw['members_removed'] as String?) != null ? int.parse(raw['members_removed'] as String) : null; - if (raw['message_id'] != null) { - messageId = Snowflake(raw['message_id']); - } else { - messageId = null; - } - - roleName = raw['role_name'] as String?; - switch (raw['type']) { - case '0': - overwrittenType = 'role'; - break; - case '1': - overwrittenType = 'member'; - break; - default: - overwrittenType = null; - } - } -} diff --git a/lib/src/core/channel/cacheable_text_channel.dart b/lib/src/core/channel/cacheable_text_channel.dart deleted file mode 100644 index 30e1bd5b4..000000000 --- a/lib/src/core/channel/cacheable_text_channel.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/internal/interfaces/send.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; - -abstract class ICacheableTextChannel implements IChannel, ITextChannel, ISend, Cacheable {} - -/// Lightweight channel which implements cacheable and allows to perform basic operation on channel instance -class CacheableTextChannel extends Channel implements ICacheableTextChannel { - late Timer _typing; - - @override - DateTime get createdAt => id.timestamp; - - /// Creates an instance of [CacheableTextChannel] - CacheableTextChannel(INyxx client, Snowflake id, [ChannelType type = ChannelType.unknown]) : super.raw(client, id, type); - - @override - S? getFromCache() => client.channels[id] as S?; - - @override - Future download() => client.httpEndpoints.fetchChannel(id); - - @override - FutureOr getOrDownload() async => getFromCache() ?? await download(); - - @override - Future bulkRemoveMessages(Iterable messages) => client.httpEndpoints.bulkRemoveMessages(id, messages); - - @override - Future delete() => client.httpEndpoints.deleteChannel(id); - - @override - Stream downloadMessages({int limit = 50, Snowflake? after, Snowflake? around, Snowflake? before}) => - client.httpEndpoints.downloadMessages(id, limit: limit, after: after, around: around, before: before); - - @override - Future fetchMessage(Snowflake id) => client.httpEndpoints.fetchMessage(this.id, id); - - /// Returns always null since this type of channel doesn't have cache. - @override - IMessage? getMessage(Snowflake id) => null; - - @override - Future sendMessage(MessageBuilder builder) => client.httpEndpoints.sendMessage(id, builder); - - @override - Future startTyping() => client.httpEndpoints.triggerTyping(id); - - @override - void startTypingLoop() { - startTyping(); - _typing = Timer.periodic(const Duration(seconds: 7), (Timer t) => startTyping()); - } - - @override - void stopTypingLoop() => _typing.cancel(); - - @override - Stream fetchPinnedMessages() => client.httpEndpoints.fetchPinnedMessages(id); - - @override - Future dispose() async {} - - @override - Future get fileUploadLimit => - throw UnimplementedError("CacheableTextChannel doesn't provide fileUploadLimit. Try getting channel from channel using methods from Cacheable"); - - @override - Map get messageCache => - throw UnimplementedError("CacheableTextChannel doesn't provide cache. Try getting channel from channel using methods from Cacheable"); -} diff --git a/lib/src/core/channel/channel.dart b/lib/src/core/channel/channel.dart deleted file mode 100644 index f681eceab..000000000 --- a/lib/src/core/channel/channel.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:nyxx/src/core/channel/guild/forum/forum_channel.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/dm_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/channel/guild/category_guild_channel.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; -import 'package:nyxx/src/core/channel/guild/voice_channel.dart'; -import 'package:nyxx/src/internal/interfaces/disposable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/enum.dart'; - -abstract class IChannel implements SnowflakeEntity, Disposable { - /// Reference to client - INyxx get client; - - /// Type of this channel - ChannelType get channelType; - - /// Deletes channel if guild channel or closes DM if DM channel - Future delete(); -} - -/// A channel. -/// Abstract base class that defines the base methods and/or properties for all Discord channel types. -/// Generic interface for all channels -abstract class Channel extends SnowflakeEntity implements IChannel { - /// Type of this channel - @override - late final ChannelType channelType; - - /// Reference to client - @override - final INyxx client; - - /// Creates instance of [Channel] - Channel(this.client, RawApiMap raw) : super(Snowflake(raw["id"])) { - channelType = ChannelType.from(raw["type"] as int); - } - - /// Creates instance of [Channel] as raw - Channel.raw(this.client, Snowflake id, this.channelType) : super(id); - - /// Deserializes and matches payload to create appropriate instance of [Channel] - factory Channel.deserialize(INyxx client, RawApiMap raw, [Snowflake? guildId]) { - final type = raw["type"] as int; - - switch (type) { - case 1: - case 3: - return DMChannel(client, raw); - case 2: - return TextVoiceTextChannel(client, raw, guildId); - case 0: - case 5: - return TextGuildChannel(client, raw, guildId); - case 4: - return CategoryGuildChannel(client, raw, guildId); - case 10: - case 11: - case 12: - return ThreadChannel(client, raw); - case 13: - return StageVoiceGuildChannel(client, raw, guildId); - case 15: - return ForumChannel(client, raw, guildId); - default: - return _InternalChannel._new(client, raw, guildId); - } - } - - /// Deletes channel if guild channel or closes DM if DM channel - @override - Future delete() => client.httpEndpoints.deleteChannel(id); - - @override - Future dispose() async {} -} - -class _InternalChannel extends GuildChannel { - _InternalChannel._new(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId); -} - -/// Enum for possible channel types -class ChannelType extends IEnum { - static const ChannelType text = ChannelType._create(0); - static const ChannelType voice = ChannelType._create(2); - static const ChannelType category = ChannelType._create(4); - - static const ChannelType dm = ChannelType._create(1); - static const ChannelType groupDm = ChannelType._create(3); - - static const ChannelType guildNews = ChannelType._create(5); - static const ChannelType guildStore = ChannelType._create(6); - static const ChannelType guildStage = ChannelType._create(13); - - static const ChannelType guildNewsThread = ChannelType._create(10); - static const ChannelType guildPublicThread = ChannelType._create(11); - static const ChannelType guildPrivateThread = ChannelType._create(12); - - /// Channel in a Student Hub containing the listed servers - static const ChannelType guildDirectory = ChannelType._create(14); - - static const ChannelType forumChannel = ChannelType._create(15); - - /// Type of channel is unknown - static const ChannelType unknown = ChannelType._create(1337); - - /// Creates instance of [ChannelType] from [value]. - ChannelType.from(int value) : super(value); - const ChannelType._create(int value) : super(value); - - @override - bool operator ==(dynamic other) { - if (other is int) { - return value == other; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/channel/dm_channel.dart b/lib/src/core/channel/dm_channel.dart deleted file mode 100644 index 70e3323ad..000000000 --- a/lib/src/core/channel/dm_channel.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; - -abstract class IDMChannel implements IChannel, ITextChannel { - @override - Future get fileUploadLimit async => 8 * 1024 * 1024; - - /// True if channel is group dm - bool get isGroupDM; - - /// List of participants in channel. If not group dm channel it will only return other user in chat. - Iterable get participants; - - /// Returns other user in chat if channel is not group dm. Will throw [ArgumentError] if channel is group dm. - IUser get participant; -} - -/// Represents private channel with user -class DMChannel extends Channel implements IDMChannel { - @override - late final Map messageCache = {}; - - @override - Future get fileUploadLimit async => 8 * 1024 * 1024; - - // Used to create infinite typing loop - Timer? _typing; - - /// True if channel is group dm - @override - bool get isGroupDM => participants.length > 1; - - /// List of participants in channel. If not group dm channel it will only return other user in chat. - @override - late final Iterable participants; - - /// Returns other user in chat if channel is not group dm. Will throw [ArgumentError] if channel is group dm. - @override - IUser get participant => !isGroupDM ? participants.first : throw ArgumentError("Channel is not direct DM"); - - /// Creates an instance of [DMChannel] - DMChannel(INyxx client, RawApiMap raw) : super(client, raw) { - if (raw["recipients"] != null) { - participants = [for (final userRaw in raw["recipients"] as RawApiList) User(this.client, userRaw as RawApiMap)]; - } else { - participants = [User(client, raw["recipient"] as RawApiMap)]; - } - } - - @override - Future startTyping() async => client.httpEndpoints.triggerTyping(id); - - @override - void startTypingLoop() { - startTyping(); - _typing = Timer.periodic(const Duration(seconds: 7), (Timer t) => startTyping()); - } - - @override - void stopTypingLoop() => _typing?.cancel(); - - @override - Future bulkRemoveMessages(Iterable messages) => client.httpEndpoints.bulkRemoveMessages(id, messages); - - @override - Stream downloadMessages({int limit = 50, Snowflake? after, Snowflake? around, Snowflake? before}) => - client.httpEndpoints.downloadMessages(id, limit: limit, after: after, around: around, before: before); - - @override - Future fetchMessage(Snowflake messageId) async { - final message = await client.httpEndpoints.fetchMessage(id, messageId); - - if (client.cacheOptions.messageCachePolicyLocation.http && client.cacheOptions.messageCachePolicy.canCache(message)) { - messageCache[messageId] = message; - } - - return message; - } - - @override - Stream fetchPinnedMessages() => client.httpEndpoints.fetchPinnedMessages(id); - - @override - IMessage? getMessage(Snowflake id) => messageCache[id]; - - @override - Future sendMessage(MessageBuilder builder) => client.httpEndpoints.sendMessage(id, builder); -} diff --git a/lib/src/core/channel/guild/activity_types.dart b/lib/src/core/channel/guild/activity_types.dart deleted file mode 100644 index 57d5fd1ea..000000000 --- a/lib/src/core/channel/guild/activity_types.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -/// Activity Types -class VoiceActivityType extends IEnum { - static const VoiceActivityType youtubeTogether = VoiceActivityType._create("755600276941176913"); - static const VoiceActivityType poker = VoiceActivityType._create("755827207812677713"); - static const VoiceActivityType betrayal = VoiceActivityType._create("773336526917861400"); - static const VoiceActivityType fishing = VoiceActivityType._create("814288819477020702"); - static const VoiceActivityType chess = VoiceActivityType._create("832012774040141894"); - static const VoiceActivityType letterTile = VoiceActivityType._create("879863686565621790"); - static const VoiceActivityType wordSnack = VoiceActivityType._create("879863976006127627"); - static const VoiceActivityType doodleCrew = VoiceActivityType._create("878067389634314250"); - - /// Creates instance of [VoiceActivityType] from [value]. - VoiceActivityType.from(String? value) : super(value ?? ""); - const VoiceActivityType._create(String? value) : super(value ?? ""); - - @override - bool operator ==(dynamic other) { - if (other is String) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/channel/guild/category_guild_channel.dart b/lib/src/core/channel/guild/category_guild_channel.dart deleted file mode 100644 index c3498f83a..000000000 --- a/lib/src/core/channel/guild/category_guild_channel.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class ICategoryGuildChannel implements IGuildChannel {} - -class CategoryGuildChannel extends GuildChannel implements ICategoryGuildChannel { - CategoryGuildChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId); -} diff --git a/lib/src/core/channel/guild/forum/forum_channel.dart b/lib/src/core/channel/guild/forum/forum_channel.dart deleted file mode 100644 index 3b83c58fc..000000000 --- a/lib/src/core/channel/guild/forum/forum_channel.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:nyxx/src/core/channel/guild/forum/forum_channel_tags.dart'; -import 'package:nyxx/src/core/channel/guild/forum/forum_tag.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/channel/thread_preview_channel.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/message/unicode_emoji.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/internal/exceptions/unknown_enum_value.dart'; -import 'package:nyxx/src/internal/interfaces/mentionable.dart'; -import 'package:nyxx/src/internal/response_wrapper/thread_list_result_wrapper.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/forum_thread_builder.dart'; - -enum ForumSortOrder { - /// Sort forum posts by activity - latestActivity(0), - - /// Sort forum posts by creation time (from most recent to oldest) - creationDate(1); - - final int value; - const ForumSortOrder(this.value); - - static ForumSortOrder _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); - - @override - String toString() => 'ForumSortOrder[$value]'; -} - -enum ForumLayout { - notSet(0), - listView(1), - galleryView(2); - - final int value; - const ForumLayout(this.value); - - static ForumLayout _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); - - @override - String toString() => 'ForumLayout[$value]'; -} - -abstract class IForumChannel implements IGuildChannel, Mentionable { - /// Tags available to assign to forum posts - List get availableTags; - - /// Channel flags - IForumChannelTags get forumChannelFlags; - - /// The default sort order type used to order posts in GUILD_FORUM channels. - /// Defaults to null, which indicates a preferred sort order hasn't been set by a channel admin - ForumSortOrder? get defaultSortOrder; - - /// The default forum layout view used to display posts in GUILD_FORUM channels. - /// Defaults to 0, which indicates a layout view has not been set by a channel admin - ForumLayout get defaultForumLayout; - - /// The emoji to show in the add reaction button on a thread in a GUILD_FORUM channel - IEmoji? get defaultReactionEmoji; - - /// Creates a thread in a channel, that only retrieves a [ThreadPreviewChannel] - Future createThread(ForumThreadBuilder builder); - - /// Fetches joined private and archived thread channels - Future fetchJoinedPrivateArchivedThreads({DateTime? before, int? limit}); - - /// Fetches private, archived thread channels - Future fetchPrivateArchivedThreads({DateTime? before, int? limit}); - - /// Fetches public, archives thread channels - Future fetchPublicArchivedThreads({DateTime? before, int? limit}); -} - -class ForumChannel extends GuildChannel implements IForumChannel { - @override - late final List availableTags; - - @override - late final IForumChannelTags forumChannelFlags; - - @override - late final ForumSortOrder? defaultSortOrder; - - @override - late final ForumLayout defaultForumLayout; - - @override - late final IEmoji? defaultReactionEmoji; - - /// Creates an instance of [TextGuildChannel] - ForumChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId) { - availableTags = (raw['available_tags'] as List? ?? []).cast().map((e) => ForumTag(e)).toList(); - forumChannelFlags = ForumChannelTags(raw['flags'] as int); - defaultSortOrder = raw['default_sort_order'] == null ? null : ForumSortOrder._fromValue(raw['default_sort_order'] as int); - defaultForumLayout = raw['default_sort_order'] == null ? ForumLayout.notSet : ForumLayout._fromValue(raw['default_sort_order'] as int); - - if (raw['default_reaction_emoji'] != null) { - final rawDefaultEmoji = raw['default_reaction_emoji'] as RawApiMap; - - if (rawDefaultEmoji['emoji_id'] != null) { - defaultReactionEmoji = GuildEmojiPartial({'id': rawDefaultEmoji['emoji_id']}, client); - } else { - defaultReactionEmoji = UnicodeEmoji(rawDefaultEmoji['emoji_name'] as String); - } - } else { - defaultReactionEmoji = null; - } - } - - /// The channel's mention string. - @override - String get mention => "<#$id>"; - - /// Creates a thread in a channel, that only retrieves a [ThreadPreviewChannel] - @override - Future createThread(ForumThreadBuilder builder) => client.httpEndpoints.startForumThread(id, builder); - - /// Fetches joined private and archived thread channels - @override - Future fetchJoinedPrivateArchivedThreads({DateTime? before, int? limit}) => - client.httpEndpoints.fetchJoinedPrivateArchivedThreads(id, before: before, limit: limit); - - /// Fetches private, archived thread channels - @override - Future fetchPrivateArchivedThreads({DateTime? before, int? limit}) => - client.httpEndpoints.fetchPrivateArchivedThreads(id, before: before, limit: limit); - - /// Fetches public, archives thread channels - @override - Future fetchPublicArchivedThreads({DateTime? before, int? limit}) => - client.httpEndpoints.fetchPublicArchivedThreads(id, before: before, limit: limit); -} diff --git a/lib/src/core/channel/guild/forum/forum_channel_tags.dart b/lib/src/core/channel/guild/forum/forum_channel_tags.dart deleted file mode 100644 index 1671390cd..000000000 --- a/lib/src/core/channel/guild/forum/forum_channel_tags.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:nyxx/src/utils/permissions.dart'; - -abstract class IForumChannelTags { - /// This thread is pinned to the top of its parent GUILD_FORUM channel - bool get pinned; - - /// Whether a tag is required to be specified when creating a thread in a GUILD_FORUM channel. - bool get requireTag; -} - -class ForumChannelTags implements IForumChannelTags { - @override - late final bool pinned; - - @override - late final bool requireTag; - - ForumChannelTags(int raw) { - pinned = PermissionsUtils.isApplied(raw, 1 << 1); - requireTag = PermissionsUtils.isApplied(raw, 1 << 4); - } -} diff --git a/lib/src/core/channel/guild/forum/forum_tag.dart b/lib/src/core/channel/guild/forum/forum_tag.dart deleted file mode 100644 index 56dbdd473..000000000 --- a/lib/src/core/channel/guild/forum/forum_tag.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/forum_thread_builder.dart'; - -abstract class IForumTag implements Convertable { - /// Id of forum tag - Snowflake get id; - - /// Name of forum tag - String? get name; - - /// Id of corresponding emoji if guild emoji - Snowflake? get emojiId; - - /// Unicode emoji of emoji if non guild emoji - String? get emojiName; -} - -class ForumTag implements IForumTag { - @override - late final Snowflake id; - - @override - late final String? name; - - @override - late final Snowflake? emojiId; - - @override - late final String? emojiName; - - ForumTag(RawApiMap raw) { - id = Snowflake(raw['id']); - name = raw['string'] as String?; - emojiId = raw['emoji_id'] != null ? Snowflake(raw['emoji_id']) : null; - emojiName = raw['emoji_name'] as String?; - } - - @override - ForumTagBuilder toBuilder() => ForumTagBuilder.fromForumTag(this); -} diff --git a/lib/src/core/channel/guild/guild_channel.dart b/lib/src/core/channel/guild/guild_channel.dart deleted file mode 100644 index 48e7e11f7..000000000 --- a/lib/src/core/channel/guild/guild_channel.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/channel/invite.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/permissions/permission_overrides.dart'; -import 'package:nyxx/src/core/permissions/permissions.dart'; -import 'package:nyxx/src/core/permissions/permissions_constants.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/channel_builder.dart'; -import 'package:nyxx/src/utils/builders/permissions_builder.dart'; -import 'package:nyxx/src/utils/permissions.dart'; -import 'package:nyxx/src/utils/utils.dart'; - -abstract class IGuildChannel implements IMinimalGuildChannel { - /// Relative position of channel in context of channel list - int get position; - - /// Permission override for channel - List get permissionOverrides; - - /// Edits channel - Future edit(ChannelBuilder builder, {String? auditReason}); - - /// Returns effective permissions for [member] to this channel including channel overrides. - Future effectivePermissions(IMember member); - - /// Returns effective permissions for [role] to this channel including channel overrides. - Future effectivePermissionForRole(IRole role); - - /// Fetches and returns all channel"s [Invite]s - /// - /// ``` - /// var invites = await chan.fetchChannelInvites(); - /// ``` - Stream fetchChannelInvites(); - - /// Allows to set or edit permissions for channel. [id] can be either User or Role - /// Throws if [id] isn't [User] or [Role] - Future editChannelPermissions(PermissionsBuilder perms, SnowflakeEntity entity, {String? auditReason}); - - /// Allows to edit or set channel permission overrides. - Future editChannelPermissionOverrides(PermissionOverrideBuilder permissionBuilder, {String? auditReason}); - - /// Deletes permission overwrite for given User or Role [entity] - /// Throws if [entity] isn't [User] or [Role] - Future deleteChannelPermission(SnowflakeEntity entity, {String? auditReason}); - - /// Creates new [IInvite] for [IChannel] and returns it"s instance - /// - /// ``` - /// var invite = await channel.createInvite(maxUses: 2137); - /// ``` - Future createInvite({int? maxAge, int? maxUses, bool? temporary, bool? unique, String? auditReason}); -} - -/// Represents channel within [Guild]. Shares logic for both [TextGuildChannel] and [VoiceGuildChannel]. -abstract class GuildChannel extends MinimalGuildChannel implements IGuildChannel { - /// Relative position of channel in context of channel list - @override - late final int position; - - /// Permission override for channel - @override - late final List permissionOverrides; - - /// Creates an instance of [GuildChannel] - GuildChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId) { - position = raw["position"] as int; - - permissionOverrides = [ - if (raw["permission_overwrites"] != null) - for (var obj in raw["permission_overwrites"] as RawApiList) PermissionsOverrides(obj as RawApiMap) - ]; - } - - /// Edits channel - @override - Future edit(ChannelBuilder builder, {String? auditReason}) => - client.httpEndpoints.editGuildChannel(id, builder, auditReason: auditReason); - - /// Returns effective permissions for [member] to this channel including channel overrides. - @override - Future effectivePermissions(IMember member) async { - if (member.guild != guild) { - return Permissions.empty(); - } - - final owner = await member.guild.getOrDownload(); - if (owner == member) { - return Permissions(PermissionsConstants.allPermissions); - } - - var rawMemberPerms = (await member.effectivePermissions).raw; - - if (PermissionsUtils.isApplied(rawMemberPerms, PermissionsConstants.administrator)) { - return Permissions(PermissionsConstants.allPermissions); - } - - final overrides = PermissionsUtils.getOverrides(member, this); - rawMemberPerms = PermissionsUtils.apply(rawMemberPerms, overrides.first, overrides.last); - - return PermissionsUtils.isApplied(rawMemberPerms, PermissionsConstants.viewChannel) ? Permissions(rawMemberPerms) : Permissions.empty(); - } - - /// Returns effective permissions for [role] to this channel including channel overrides. - @override - Future effectivePermissionForRole(IRole role) async { - if (role.guild != guild) { - return Permissions.empty(); - } - - final guildInstance = await guild.getOrDownload(); - var permissions = role.permissions.raw | guildInstance.everyoneRole.permissions.raw; - - final overEveryone = permissionOverrides.firstWhereSafe((f) => f.id == guildInstance.everyoneRole.id); - if (overEveryone != null) { - permissions &= ~overEveryone.deny; - permissions |= overEveryone.allow; - } - - final overRole = permissionOverrides.firstWhereSafe((f) => f.id == role.id); - if (overRole != null) { - permissions &= ~overRole.deny; - permissions |= overRole.allow; - } - - return Permissions(permissions); - } - - /// Fetches and returns all channel"s [Invite]s - /// - /// ``` - /// var invites = await chan.getChannelInvites(); - /// ``` - @override - Stream fetchChannelInvites() => client.httpEndpoints.fetchChannelInvites(id); - - /// Allows to set or edit permissions for channel. [id] can be either User or Role - /// Throws if [id] isn't [User] or [Role] - @override - Future editChannelPermissions(PermissionsBuilder perms, SnowflakeEntity entity, {String? auditReason}) => - client.httpEndpoints.editChannelPermissions(id, perms, entity, auditReason: auditReason); - - /// Allows to edit or set channel permission overrides. - @override - Future editChannelPermissionOverrides(PermissionOverrideBuilder permissionBuilder, {String? auditReason}) => - client.httpEndpoints.editChannelPermissionOverrides(id, permissionBuilder, auditReason: auditReason); - - /// Deletes permission overwrite for given User or Role [entity] - /// Throws if [entity] isn't [User] or [Role] - @override - Future deleteChannelPermission(SnowflakeEntity entity, {String? auditReason}) => - client.httpEndpoints.deleteChannelPermission(id, entity, auditReason: auditReason); - - /// Creates new [Invite] for [IChannel] and returns it"s instance - /// - /// ``` - /// var invite = await channel.createInvite(maxUses: 2137); - /// ``` - @override - Future createInvite({int? maxAge, int? maxUses, bool? temporary, bool? unique, String? auditReason}) => - client.httpEndpoints.createInvite(id, maxAge: maxAge, maxUses: maxUses, temporary: temporary, unique: unique, auditReason: auditReason); -} - -abstract class IMinimalGuildChannel implements IChannel { - /// The channel's name. - String get name; - - /// Id of [Guild] that the channel is in. - Cacheable get guild; - - /// Id of parent channel - Cacheable? get parentChannel; - - /// Indicates if channel is nsfw - bool get isNsfw; -} - -abstract class MinimalGuildChannel extends Channel implements IMinimalGuildChannel { - /// The channel's name. - @override - late final String name; - - /// Id of [Guild] that the channel is in. - @override - late final Cacheable guild; - - /// Id of parent channel - @override - late final Cacheable? parentChannel; - - /// Indicates if channel is nsfw - @override - late final bool isNsfw; - - /// Creates instance of [MinimalGuildChannel] - MinimalGuildChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw) { - name = raw["name"] as String; - - if (raw["guild_id"] != null) { - guild = GuildCacheable(client, Snowflake(raw["guild_id"])); - } else if (guildId != null) { - guild = GuildCacheable(client, guildId); - } else { - throw Exception( - "Cannot initialize instance of GuildChannel due missing `guild_id` in json payload and/or missing optional guildId parameter. Report this issue to developer"); - } - - if (raw["parent_id"] != null) { - parentChannel = ChannelCacheable(client, Snowflake(raw["parent_id"])); - } else { - parentChannel = null; - } - - isNsfw = raw["nsfw"] as bool? ?? false; - } -} diff --git a/lib/src/core/channel/guild/text_guild_channel.dart b/lib/src/core/channel/guild/text_guild_channel.dart deleted file mode 100644 index 75e5dcfbb..000000000 --- a/lib/src/core/channel/guild/text_guild_channel.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/channel/thread_preview_channel.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/guild/webhook.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/internal/cache/cache.dart'; -import 'package:nyxx/src/internal/interfaces/mentionable.dart'; -import 'package:nyxx/src/internal/response_wrapper/thread_list_result_wrapper.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; -import 'package:nyxx/src/utils/builders/thread_builder.dart'; - -abstract class ITextGuildChannel implements IGuildChannel, ITextChannel, Mentionable { - /// The channel's topic. - String? get topic; - - /// Channel's slow mode rate limit in seconds. This must be between 0 and 120. - int get slowModeThreshold; - - /// Returns url to this channel. - String get url; - - /// Gets all of the webhooks for this channel. - Stream getWebhooks(); - - /// Creates a webhook for channel. - /// Valid file types for [avatarFile] are jpeg, gif and png. - /// - /// ``` - /// final webhook = await channel.createWebhook("!a Send nudes kek6407"); - /// ``` - Future createWebhook(String name, {AttachmentBuilder? avatarAttachment, String? auditReason}); - - /// Creates a thread in a channel, that only retrieves a [ThreadPreviewChannel] - Future createThread(ThreadBuilder builder); - - /// Creates a thread in a message - Future createAndGetThread(ThreadBuilder builder); - - /// Fetches joined private and archived thread channels - Future fetchJoinedPrivateArchivedThreads({DateTime? before, int? limit}); - - /// Fetches private, archived thread channels - Future fetchPrivateArchivedThreads({DateTime? before, int? limit}); - - /// Fetches public, archives thread channels - Future fetchPublicArchivedThreads({DateTime? before, int? limit}); -} - -class TextGuildChannel extends GuildChannel implements ITextGuildChannel { - /// The channel's topic. - @override - late final String? topic; - - /// The channel's mention string. - @override - String get mention => "<#$id>"; - - /// Channel's slow mode rate limit in seconds. This must be between 0 and 120. - @override - late final int slowModeThreshold; - - /// Returns url to this channel. - @override - String get url => "https://discordapp.com/channels/${guild.id.toString()}" - "/${id.toString()}"; - - @override - late final SnowflakeCache messageCache = SnowflakeCache(client.options.messageCacheSize); - - @override - Future get fileUploadLimit async { - final guildInstance = await guild.getOrDownload(); - - return guildInstance.fileUploadLimit; - } - - // Used to create infinite typing loop - Timer? _typing; - - /// Creates an instance of [TextGuildChannel] - TextGuildChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId) { - topic = raw["topic"] as String?; - slowModeThreshold = raw["rate_limit_per_user"] as int? ?? 0; - } - - /// Gets all of the webhooks for this channel. - @override - Stream getWebhooks() => client.httpEndpoints.fetchChannelWebhooks(id); - - /// Creates a webhook for channel. - /// Valid file types for [avatarFile] are jpeg, gif and png. - /// - /// ``` - /// final webhook = await channnel.createWebhook("!a Send nudes kek6407"); - /// ``` - @override - Future createWebhook(String name, {AttachmentBuilder? avatarAttachment, String? auditReason}) => - client.httpEndpoints.createWebhook(id, name, avatarAttachment: avatarAttachment, auditReason: auditReason); - - /// Returns pinned [Message]s for channel. - @override - Stream fetchPinnedMessages() => client.httpEndpoints.fetchPinnedMessages(id); - - /// Creates a thread in a channel, that only retrieves a [ThreadPreviewChannel] - @override - Future createThread(ThreadBuilder builder) => client.httpEndpoints.createThread(id, builder); - - /// Creates a thread in a message - @override - Future createAndGetThread(ThreadBuilder builder) async { - final preview = await client.httpEndpoints.createThread(id, builder); - return preview.getThreadChannel().getOrDownload(); - } - - @override - Future startTyping() async => client.httpEndpoints.triggerTyping(id); - - @override - void startTypingLoop() { - startTyping(); - _typing = Timer.periodic(const Duration(seconds: 7), (Timer t) => startTyping()); - } - - @override - void stopTypingLoop() => _typing?.cancel(); - - @override - Future bulkRemoveMessages(Iterable messages) => client.httpEndpoints.bulkRemoveMessages(id, messages); - - @override - Stream downloadMessages({int limit = 50, Snowflake? after, Snowflake? around, Snowflake? before}) => - client.httpEndpoints.downloadMessages(id, limit: limit, after: after, around: around, before: before); - - @override - Future fetchMessage(Snowflake messageId) async { - final message = await client.httpEndpoints.fetchMessage(id, messageId); - - if (client.cacheOptions.messageCachePolicyLocation.http && client.cacheOptions.messageCachePolicy.canCache(message)) { - messageCache[messageId] = message; - } - - return message; - } - - @override - IMessage? getMessage(Snowflake id) => messageCache[id]; - - @override - Future sendMessage(MessageBuilder builder) => client.httpEndpoints.sendMessage(id, builder); - - /// Fetches joined private and archived thread channels - @override - Future fetchJoinedPrivateArchivedThreads({DateTime? before, int? limit}) => - client.httpEndpoints.fetchJoinedPrivateArchivedThreads(id, before: before, limit: limit); - - /// Fetches private, archived thread channels - @override - Future fetchPrivateArchivedThreads({DateTime? before, int? limit}) => - client.httpEndpoints.fetchPrivateArchivedThreads(id, before: before, limit: limit); - - /// Fetches public, archives thread channels - @override - Future fetchPublicArchivedThreads({DateTime? before, int? limit}) => - client.httpEndpoints.fetchPublicArchivedThreads(id, before: before, limit: limit); -} diff --git a/lib/src/core/channel/guild/voice_channel.dart b/lib/src/core/channel/guild/voice_channel.dart deleted file mode 100644 index 406db124d..000000000 --- a/lib/src/core/channel/guild/voice_channel.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/internal/cache/cache.dart'; -import 'package:nyxx/src/internal/exceptions/unknown_enum_value.dart'; -import 'package:nyxx/src/internal/interfaces/mentionable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/channel/invite.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/guild/activity_types.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/internal/exceptions/invalid_shard_exception.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; -import 'package:nyxx/src/utils/enum.dart'; - -enum VideoQualityMode { - auto(1), - full(2); - - final int value; - const VideoQualityMode(this.value); - - static VideoQualityMode _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); - - @override - String toString() => 'VideoQualityMode[$value]'; -} - -abstract class IVoiceGuildChannel implements IGuildChannel { - /// The channel's bitrate. - int? get bitrate; - - /// The channel's user limit. - int? get userLimit; - - /// Channel voice region id, automatic when set to null - String? get rtcRegion; - - /// The camera video quality mode of the voice channel, 1 when not present - VideoQualityMode get videoQualityMode; - - /// Connects client to channel - void connect({bool selfMute = false, bool selfDeafen = false}); - - /// Disconnects use from channel. - void disconnect(); - - /// Creates activity invite. Currently in beta - Future createActivityInvite(VoiceActivityType type, {int? maxAge, int? maxUses}); -} - -class VoiceGuildChannel extends GuildChannel implements IVoiceGuildChannel { - @override - late final int? bitrate; - - @override - late final int? userLimit; - - @override - late final String? rtcRegion; - - @override - late final VideoQualityMode videoQualityMode; - - /// Creates an instance of [VoiceGuildChannel] - VoiceGuildChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId) { - bitrate = raw["bitrate"] as int?; - userLimit = raw["user_limit"] as int?; - rtcRegion = raw['rtc_region'] as String?; - videoQualityMode = raw['video_quality_mode'] == null ? VideoQualityMode.auto : VideoQualityMode._fromValue(raw['video_quality_mode'] as int); - } - - @override - void connect({bool selfMute = false, bool selfDeafen = false}) { - if (client is! NyxxWebsocket) { - throw UnsupportedError("Cannot connect with NyxxRest"); - } - - try { - final shard = (client as NyxxWebsocket).shardManager.shards.firstWhere((element) => element.guilds.contains(guild.id)); - - shard.changeVoiceState(guild.id, id, selfMute: selfMute, selfDeafen: selfDeafen); - } on Error { - throw InvalidShardException("Cannot find shard for this channel!"); - } - } - - @override - void disconnect() { - if (client is! NyxxWebsocket) { - throw UnsupportedError("Cannot connect with NyxxRest"); - } - - try { - final shard = (client as NyxxWebsocket).shardManager.shards.firstWhere((element) => element.guilds.contains(guild.id)); - - shard.changeVoiceState(guild.id, null); - } on Error { - throw InvalidShardException("Cannot find shard for this channel!"); - } - } - - @override - Future createActivityInvite(VoiceActivityType type, {int? maxAge, int? maxUses}) => - client.httpEndpoints.createVoiceActivityInvite(Snowflake(type.value), id, maxAge: maxAge, maxUses: maxUses); -} - -abstract class ITextVoiceTextChannel implements IVoiceGuildChannel, ITextChannel, Mentionable {} - -class TextVoiceTextChannel extends VoiceGuildChannel implements ITextVoiceTextChannel { - @override - late final SnowflakeCache messageCache = SnowflakeCache(client.options.messageCacheSize); - - // Used to create infinite typing loop - Timer? _typing; - - TextVoiceTextChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId); - - @override - Future bulkRemoveMessages(Iterable messages) => client.httpEndpoints.bulkRemoveMessages(id, messages); - - /// Connects client to channel - @override - void connect({bool selfMute = false, bool selfDeafen = false}) { - if (client is! NyxxWebsocket) { - throw UnsupportedError("Cannot connect with NyxxRest"); - } - - try { - final shard = (client as NyxxWebsocket).shardManager.shards.firstWhere((element) => element.guilds.contains(guild.id)); - - shard.changeVoiceState(guild.id, id, selfMute: selfMute, selfDeafen: selfDeafen); - } on Error { - throw InvalidShardException("Cannot find shard for this channel!"); - } - } - - /// Disconnects use from channel. - @override - void disconnect() { - if (client is! NyxxWebsocket) { - throw UnsupportedError("Cannot connect with NyxxRest"); - } - - try { - final shard = (client as NyxxWebsocket).shardManager.shards.firstWhere((element) => element.guilds.contains(guild.id)); - - shard.changeVoiceState(guild.id, null); - } on Error { - throw InvalidShardException("Cannot find shard for this channel!"); - } - } - - @override - Future createActivityInvite(VoiceActivityType type, {int? maxAge, int? maxUses}) => - client.httpEndpoints.createVoiceActivityInvite(Snowflake(type.value), id, maxAge: maxAge, maxUses: maxUses); - - @override - Stream downloadMessages({int limit = 50, Snowflake? after, Snowflake? around, Snowflake? before}) => - client.httpEndpoints.downloadMessages(id, limit: limit, after: after, around: around, before: before); - - @override - Future fetchMessage(Snowflake messageId) async { - final message = await client.httpEndpoints.fetchMessage(id, messageId); - - if (client.cacheOptions.messageCachePolicyLocation.http && client.cacheOptions.messageCachePolicy.canCache(message)) { - messageCache[messageId] = message; - } - - return message; - } - - /// Returns pinned [Message]s for channel. - @override - Stream fetchPinnedMessages() => client.httpEndpoints.fetchPinnedMessages(id); - - @override - Future get fileUploadLimit async { - final guildInstance = await guild.getOrDownload(); - - return guildInstance.fileUploadLimit; - } - - @override - IMessage? getMessage(Snowflake id) => messageCache[id]; - - /// The channel's mention string. - @override - String get mention => "<#$id>"; - - @override - Future sendMessage(MessageBuilder builder) => client.httpEndpoints.sendMessage(id, builder); - - @override - @override - Future startTyping() async => client.httpEndpoints.triggerTyping(id); - - @override - void startTypingLoop() { - startTyping(); - _typing = Timer.periodic(const Duration(seconds: 7), (Timer t) => startTyping()); - } - - @override - void stopTypingLoop() => _typing?.cancel(); -} - -abstract class IStageVoiceGuildChannel implements IVoiceGuildChannel { - /// Gets the stage instance associated with the Stage channel, if it exists. - Future getStageChannelInstance(); - - /// Deletes the Stage instance. - Future deleteStageChannelInstance(); - - /// Creates a new Stage instance associated to a Stage channel. - Future createStageChannelInstance(String topic, {StageChannelInstancePrivacyLevel? privacyLevel}); - - /// Updates fields of an existing Stage instance. - Future updateStageChannelInstance(String topic, {StageChannelInstancePrivacyLevel? privacyLevel}); -} - -class StageVoiceGuildChannel extends VoiceGuildChannel implements IStageVoiceGuildChannel { - StageVoiceGuildChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId); - - /// Gets the stage instance associated with the Stage channel, if it exists. - @override - Future getStageChannelInstance() => client.httpEndpoints.getStageChannelInstance(id); - - /// Deletes the Stage instance. - @override - Future deleteStageChannelInstance() => client.httpEndpoints.deleteStageChannelInstance(id); - - /// Creates a new Stage instance associated to a Stage channel. - @override - Future createStageChannelInstance(String topic, {StageChannelInstancePrivacyLevel? privacyLevel}) => - client.httpEndpoints.createStageChannelInstance(id, topic, privacyLevel: privacyLevel); - - /// Updates fields of an existing Stage instance. - @override - Future updateStageChannelInstance(String topic, {StageChannelInstancePrivacyLevel? privacyLevel}) => - client.httpEndpoints.updateStageChannelInstance(id, topic, privacyLevel: privacyLevel); -} - -abstract class IStageChannelInstance implements SnowflakeEntity { - /// The guild id of the associated Stage channel - Cacheable get guild; - - /// The id of the associated Stage channel - Cacheable get channel; - - /// The topic of the Stage instance - String get topic; - - /// The privacy level of the Stage instance - StageChannelInstancePrivacyLevel get privacyLevel; - - /// Whether or not Stage discovery is disabled - bool get disoverableDisabled; -} - -/// A [StageChannelInstance] holds information about a live stage. -class StageChannelInstance extends SnowflakeEntity implements IStageChannelInstance { - /// The guild id of the associated Stage channel - @override - late final Cacheable guild; - - /// The id of the associated Stage channel - @override - late final Cacheable channel; - - /// The topic of the Stage instance - @override - late final String topic; - - /// The privacy level of the Stage instance - @override - late final StageChannelInstancePrivacyLevel privacyLevel; - - /// Whether or not Stage discovery is disabled - @override - late final bool disoverableDisabled; - - /// Creates an instance of [StageChannelInstance] - StageChannelInstance(INyxx client, RawApiMap raw) : super(Snowflake(raw["id"])) { - guild = GuildCacheable(client, Snowflake(raw["guild_id"])); - channel = ChannelCacheable(client, Snowflake(raw["channel_id"])); - topic = raw["topic"] as String; - privacyLevel = StageChannelInstancePrivacyLevel.from(raw["privacy_level"] as int); - disoverableDisabled = raw["discoverable_disabled"] as bool; - } -} - -/// The privacy level of the Stage instance -class StageChannelInstancePrivacyLevel extends IEnum { - /// The Stage instance is visible publicly, such as on Stage discovery. - static const StageChannelInstancePrivacyLevel public = StageChannelInstancePrivacyLevel(1); - - /// The Stage instance is visible to only guild members. - static const StageChannelInstancePrivacyLevel guildOnly = StageChannelInstancePrivacyLevel(2); - - /// Creates an instance of [StageChannelInstancePrivacyLevel] - const StageChannelInstancePrivacyLevel(int value) : super(value); - - /// Create [StageChannelInstancePrivacyLevel] from [value] - StageChannelInstancePrivacyLevel.from(int value) : super(value); -} diff --git a/lib/src/core/channel/invite.dart b/lib/src/core/channel/invite.dart deleted file mode 100644 index 8d349d309..000000000 --- a/lib/src/core/channel/invite.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IInvite { - /// The invite's code. - String get code; - - /// A mini guild object for the invite's guild. - Cacheable? get guild; - - /// A mini channel object for the invite's channel. - Cacheable? get channel; - - /// User who created this invite - IUser? get inviter; - - /// The target user for this invite - Cacheable? get targetUser; - - /// Reference to bot instance - INyxx get client; - - /// Returns url to invite - String get url; - - /// Deletes this [Invite]. - Future delete({String? auditReason}); -} - -/// Represents invite to guild. -class Invite implements IInvite { - /// The invite's code. - @override - late final String code; - - /// A mini guild object for the invite's guild. - @override - late final Cacheable? guild; - - /// A mini channel object for the invite's channel. - @override - late final Cacheable? channel; - - /// User who created this invite - @override - late final IUser? inviter; - - /// The target user for this invite - @override - late final Cacheable? targetUser; - - /// Reference to bot instance - @override - final INyxx client; - - /// Returns url to invite - @override - String get url => "https://discord.gg/$code"; - - /// Creates an instance of [Invite] - Invite(RawApiMap raw, this.client) { - code = raw["code"] as String; - - if (raw["guild"] != null) { - guild = GuildCacheable(client, Snowflake(raw["guild"]["id"])); - } else { - guild = null; - } - - if (raw["channel"] != null) { - channel = ChannelCacheable(client, Snowflake(raw["channel"]["id"])); - } else { - channel = null; - } - - if (raw["inviter"] != null) { - inviter = User(client, raw["inviter"] as RawApiMap); - } else { - inviter = null; - } - - if (raw["target_user"] != null) { - targetUser = UserCacheable(client, Snowflake(raw["target_user"]["id"])); - } else { - targetUser = null; - } - } - - /// Deletes this [Invite]. - @override - Future delete({String? auditReason}) async => client.httpEndpoints.deleteInvite(code, auditReason: auditReason); -} - -abstract class IInviteWithMeta implements IInvite { - /// Date when invite was created - DateTime get createdAt; - - /// Whether this invite only grants temporary membership - bool get temporary; - - /// Number of uses of this invite - int get uses; - - /// Max number of uses of this invite - int get maxUses; - - /// Duration (in seconds) after which the invite expires - int get maxAge; - - /// Date when invite will expire - DateTime get expiryDate; - - /// True if Invite is valid and can be used - bool get isValid; -} - -/// Invite object with additional metadata -class InviteWithMeta extends Invite implements IInviteWithMeta { - /// Date when invite was created - @override - late final DateTime createdAt; - - /// Whether this invite only grants temporary membership - @override - late final bool temporary; - - /// Number of uses of this invite - @override - late final int uses; - - /// Max number of uses of this invite - @override - late final int maxUses; - - /// Duration (in seconds) after which the invite expires - @override - late final int maxAge; - - /// Date when invite will expire - @override - DateTime get expiryDate => createdAt.add(Duration(seconds: maxAge)); - - /// True if Invite is valid and can be used - @override - bool get isValid { - var ageValidity = true; - var expiryValidity = true; - - if (maxUses > 0) { - ageValidity = uses <= maxUses; - } - - if (maxAge > 0) { - expiryValidity = expiryDate.isAfter(DateTime.now()); - } - - return ageValidity && expiryValidity; - } - - /// Creates an instance of [InviteWithMeta] - InviteWithMeta(RawApiMap raw, INyxx client) : super(raw, client) { - createdAt = DateTime.parse(raw["created_at"] as String); - temporary = raw["temporary"] as bool; - uses = raw["uses"] as int; - maxUses = raw["max_uses"] as int; - maxAge = raw["max_age"] as int; - } -} diff --git a/lib/src/core/channel/text_channel.dart b/lib/src/core/channel/text_channel.dart deleted file mode 100644 index bcd6bf7e3..000000000 --- a/lib/src/core/channel/text_channel.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/internal/interfaces/send.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; - -abstract class ITextChannel implements IChannel, ISend { - /// File upload limit for channel in bytes. - Future get fileUploadLimit; - - /// A collection of messages sent to this channel. - Map get messageCache; - - /// Returns [Message] with given id from CACHE - IMessage? getMessage(Snowflake id); - - /// Returns [IMessage] downloaded from API - Future fetchMessage(Snowflake id); - - /// Sends message to channel. Allows to send embeds with [MessageBuilder.embed()] method. - /// - /// ``` - /// await channel.sendMessage(MessageBuilder.content("Very nice message!")); - /// ``` - /// - /// Can be used in combination with Emoji. Just run `toString()` on Emoji instance: - /// ``` - /// final emoji = guild.emojis.findOne((e) => e.name.startsWith("dart")); - /// await channel.sendMessage(MessageBuilder.content("Dart is superb! ${emoji.toString()}")); - /// ``` - /// Embeds can be sent very easily: - /// ``` - /// var embed = EmbedBuilder() - /// ..title = "Example Title" - /// ..addField(name: "Memory usage", value: "${ProcessInfo.currentRss / 1024 / 1024}MB"); - /// - /// await channel.sendMessage(MessageBuilder.embed(embed)); - /// ``` - /// - /// Method also allows to send multiple files and optional [content] with [embed]. - /// - /// ``` - /// await event.message.channel.sendMessage( - /// MessageBuilder.files( - /// [ - /// AttachmentBuilder.file( - /// File("kitten.png"), - /// name: "kitten.png", - /// ), - /// ], - /// )..content = "Kittens ^-^", - /// ); - /// ``` - /// You can refer the sent attachments in embeds by prefixing them with `attachment://`: - /// ``` - /// var embed = EmbedBuilder() - /// ..title = "Example Title" - /// ..thumbnailUrl = "attachment://kitten.jpg"; - /// - /// await event.message.channel.sendMessage( - /// MessageBuilder.files( - /// [ - /// AttachmentBuilder.file( - /// File("kitten.jpg"), - /// ), - /// ], - /// ) - /// ..embeds = [embed] - /// ..content = "HEJKA!", - /// ); - /// ``` - @override - Future sendMessage(MessageBuilder builder); - - /// Bulk removes many referenced messages. Where [messages] is list of messages to delete. - /// - /// ``` - /// var toDelete = channel.messageCache.take(5); - /// await channel.bulkRemoveMessages(toDelete); - /// ``` - Future bulkRemoveMessages(Iterable messages); - - /// Gets several [IMessage] objects from API. - /// - /// ``` - /// var messages = await channel.downloadMessages(limit: 100, after: Snowflake("222078108977594368")); - /// ``` - Stream downloadMessages({int limit = 50, Snowflake? after, Snowflake? around, Snowflake? before}); - - /// Returns pinned [IMessage]s for channel. - Stream fetchPinnedMessages(); - - /// Starts typing. - Future startTyping(); - - /// Loops `startTyping` until `stopTypingLoop` is called. - void startTypingLoop(); - - /// Stops a typing loop if one is running. - void stopTypingLoop(); -} diff --git a/lib/src/core/channel/thread_channel.dart b/lib/src/core/channel/thread_channel.dart deleted file mode 100644 index 1405b5f6c..000000000 --- a/lib/src/core/channel/thread_channel.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cache.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; -import 'package:nyxx/src/utils/builders/thread_builder.dart'; - -abstract class IThreadMember implements SnowflakeEntity { - /// Reference to client - INyxx get client; - - /// Reference to [ThreadChannel] - ICacheableTextChannel get thread; - - /// When member joined thread - DateTime get joinTimestamp; - - /// Any user-thread settings, currently only used for notifications - int get flags; - - /// [ThreadMember]s [IGuild] - Cacheable get guild; - - /// [Cacheable] of [IUser] - Cacheable get user; - - /// [Cacheable] of [IMember] - Cacheable get member; -} - -/// Member of [ThreadChannel] -class ThreadMember extends SnowflakeEntity implements IThreadMember { - /// Reference to client - @override - final INyxx client; - - /// Reference to [ThreadChannel] - @override - late final ICacheableTextChannel thread; - - /// When member joined thread - @override - late final DateTime joinTimestamp; - - /// Any user-thread settings, currently only used for notifications - @override - late final int flags; - - /// [ThreadMember]s [Guild] - @override - final Cacheable guild; - - /// [Cacheable] of [User] - @override - Cacheable get user => UserCacheable(client, id); - - /// [Cacheable] of [Member] - @override - Cacheable get member => MemberCacheable(client, id, guild); - - /// Creates an instance of [ThreadMember] - ThreadMember(this.client, RawApiMap raw, this.guild) : super(Snowflake(raw["user_id"])) { - thread = CacheableTextChannel(client, Snowflake(raw["id"])); - joinTimestamp = DateTime.parse(raw["join_timestamp"] as String); - flags = raw["flags"] as int; - } -} - -abstract class IThreadMemberWithMember extends IThreadMember { - /// Fetched member from API - IMember get fetchedMember; -} - -class ThreadMemberWithMember extends ThreadMember implements IThreadMember { - late final Member fetchedMember; - - ThreadMemberWithMember(INyxx client, RawApiMap raw, Cacheable guild) : super(client, raw, guild) { - fetchedMember = Member(client, raw['member'] as RawApiMap, guild.id); - - if (client.cacheOptions.memberCachePolicyLocation.http && client.cacheOptions.memberCachePolicy.canCache(fetchedMember)) { - fetchedMember.guild.getFromCache()?.members[member.id] = fetchedMember; - } - } -} - -abstract class IThreadChannel implements MinimalGuildChannel, ITextChannel { - /// Owner of the thread - Cacheable get owner; - - /// Approximate message count - int get messageCount; - - /// Approximate member count - int get memberCount; - - /// Number of messages ever sent in a thread. - /// It's similar to message_count on message creation, but will not decrement the number when a message is deleted - int get totalMessagesSent; - - /// The IDs of the set of tags that have been applied to a thread in a GUILD_FORUM channel - List get appliedTags; - - /// True if thread is archived - bool get archived; - - /// Date when thread was archived - DateTime get archiveAt; - - /// Time after what thread will be archived - ThreadArchiveTime get archiveAfter; - - /// Whether non-moderators can add other non-moderators to a thread; only available on private threads - bool get invitable; - - /// Fetches from API current list of member that has access to that thread - /// Returns [IThreadMemberWithMember] when [withMembers] set to true - Stream fetchMembers({bool withMembers = false, Snowflake? after, int limit = 100}); - - /// Fetches thread member from the API - /// Returns [IThreadMemberWithMember] when [withMembers] set to true - Future fetchMember(Snowflake memberId, {bool withMembers = false}); - - /// Leaves this thread channel - Future leaveThread(); - - /// Removes [user] from [ThreadChannel] - Future removeThreadMember(SnowflakeEntity user); - - /// Adds [user] to [ThreadChannel] - Future addThreadMember(SnowflakeEntity user); - - /// Edits this [ThreadChannel] and returns the edited [ThreadChannel] - Future edit(ThreadBuilder builder); -} - -class ThreadChannel extends MinimalGuildChannel implements IThreadChannel { - Timer? _typing; - - @override - late final Cacheable owner; - - @override - late final int messageCount; - - @override - late final int memberCount; - - @override - late final bool archived; - - @override - late final DateTime archiveAt; - - @override - late final ThreadArchiveTime archiveAfter; - - @override - late final bool invitable; - - @override - late final int totalMessagesSent; - - @override - late final List appliedTags; - - @override - Future get fileUploadLimit async { - final guildInstance = await guild.getOrDownload(); - return guildInstance.fileUploadLimit; - } - - @override - late final SnowflakeCache messageCache = SnowflakeCache(client.options.messageCacheSize); - - /// Creates an instance of [ThreadChannel] - ThreadChannel(INyxx client, RawApiMap raw) : super(client, raw) { - owner = MemberCacheable(client, Snowflake(raw["owner_id"]), guild); - - messageCount = raw["message_count"] as int; - memberCount = raw["member_count"] as int; - - final meta = raw["thread_metadata"]; - archived = meta["archived"] as bool; - archiveAt = DateTime.parse(meta["archive_timestamp"] as String); - archiveAfter = ThreadArchiveTime(meta["auto_archive_duration"] as int); - invitable = raw["invitable"] as bool? ?? false; - - totalMessagesSent = raw['total_message_sent'] as int? ?? 0; - appliedTags = (raw['applied_tags'] as List? ?? []).map((e) => Snowflake(e)).toList(); - } - - /// Fetches from API current list of member that has access to that thread - @override - Stream fetchMembers({bool withMembers = false, Snowflake? after, int limit = 100}) => - client.httpEndpoints.fetchThreadMembers(id, guild.id, withMembers: withMembers, after: after, limit: limit); - - @override - Future fetchMember(Snowflake memberId, {bool withMembers = false}) => - client.httpEndpoints.fetchThreadMember(id, guild.id, memberId, withMembers: withMembers); - - @override - Future bulkRemoveMessages(Iterable messages) => client.httpEndpoints.bulkRemoveMessages(id, messages); - - @override - Stream downloadMessages({int limit = 50, Snowflake? after, Snowflake? around, Snowflake? before}) => - client.httpEndpoints.downloadMessages(id, limit: limit, after: after, around: around, before: before); - - @override - Future fetchMessage(Snowflake messageId) async { - final message = await client.httpEndpoints.fetchMessage(id, messageId); - - if (client.cacheOptions.messageCachePolicyLocation.http && client.cacheOptions.messageCachePolicy.canCache(message)) { - messageCache[messageId] = message; - } - - return message; - } - - @override - IMessage? getMessage(Snowflake id) => messageCache[id]; - - @override - Future sendMessage(MessageBuilder builder) => client.httpEndpoints.sendMessage(id, builder); - - @override - Future startTyping() async => client.httpEndpoints.triggerTyping(id); - - @override - void startTypingLoop() { - startTyping(); - _typing = Timer.periodic(const Duration(seconds: 7), (Timer t) => startTyping()); - } - - @override - void stopTypingLoop() => _typing?.cancel(); - - /// Leaves this thread channel - @override - Future leaveThread() => client.httpEndpoints.leaveGuild(id); - - /// Removes [user] from [ThreadChannel] - @override - Future removeThreadMember(SnowflakeEntity user) => client.httpEndpoints.removeThreadMember(id, user.id); - - /// Adds [user] to [ThreadChannel] - @override - Future addThreadMember(SnowflakeEntity user) => client.httpEndpoints.addThreadMember(id, user.id); - - @override - Stream fetchPinnedMessages() => client.httpEndpoints.fetchPinnedMessages(id); - - @override - Future edit(ThreadBuilder builder) => client.httpEndpoints.editThreadChannel(id, builder); -} diff --git a/lib/src/core/channel/thread_preview_channel.dart b/lib/src/core/channel/thread_preview_channel.dart deleted file mode 100644 index 6f319f907..000000000 --- a/lib/src/core/channel/thread_preview_channel.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/internal/cache/cache.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; -import 'package:nyxx/src/utils/builders/thread_builder.dart'; - -abstract class IThreadPreviewChannel implements IChannel, ITextChannel { - /// Name of the channel - String get name; - - /// Approximate message count - int get messageCount; - - /// Approximate member count - int get memberCount; - - /// Guild where the thread is located - Cacheable get guild; - - /// The text channel where the thread was made - CacheableTextChannel get parentChannel; - - /// Initial author of the thread - Cacheable get owner; - - /// Preview of initial members - List> get memberPreview; - - /// If the thread has been archived - bool get archived; - - /// When the thread will be archived - DateTime get archivedTime; - - /// How long till the thread is archived - ThreadArchiveTime get archivedAfter; - - /// Get the actual thread channel from the preview - ChannelCacheable getThreadChannel(); -} - -/// Given when a thread is created as only partial information is available. If you want the final channel use [getThreadChannel] -class ThreadPreviewChannel extends Channel implements IThreadPreviewChannel { - Timer? _typing; - - /// Name of the channel - @override - late final String name; - - /// Approximate message count - @override - late final int messageCount; - - /// Approximate member count - @override - late final int memberCount; - - /// Guild where the thread is located - @override - late final Cacheable guild; - - /// The text channel where the thread was made - @override - late final CacheableTextChannel parentChannel; - - /// Initial author of the thread - @override - late final Cacheable owner; - - /// Preview of initial members - @override - late final List> memberPreview; - - /// If the thread has been archived - @override - late final bool archived; - - /// When the thread will be archived - @override - late final DateTime archivedTime; - - /// How long till the thread is archived - @override - late final ThreadArchiveTime archivedAfter; - - @override - late final SnowflakeCache messageCache = SnowflakeCache(0); - - /// Creates an instance of [ThreadPreviewChannel] - ThreadPreviewChannel(INyxx client, RawApiMap raw) : super(client, raw) { - name = raw["name"] as String; - messageCount = raw["message_count"] as int; - memberCount = raw["member_count"] as int; - parentChannel = CacheableTextChannel(client, Snowflake(raw["parent_id"])); - guild = GuildCacheable(client, Snowflake(raw["guild_id"])); - owner = MemberCacheable(client, Snowflake(raw["owner_id"]), guild); - memberPreview = []; - if (raw["member_ids_preview"] != null) { - for (final id in raw["member_ids_preview"] as List) { - memberPreview.add(MemberCacheable(client, Snowflake(id), guild)); - } - } - final metadata = raw["thread_metadata"] as RawApiMap; - - archived = metadata["archived"] as bool; - archivedTime = DateTime.parse(metadata["archive_timestamp"] as String); - archivedAfter = ThreadArchiveTime(metadata["auto_archive_duration"] as int); - } - - /// Get the actual thread channel from the preview - @override - ChannelCacheable getThreadChannel() => ChannelCacheable(client, id); - - @override - Future bulkRemoveMessages(Iterable messages) => client.httpEndpoints.bulkRemoveMessages(id, messages); - - @override - Stream downloadMessages({int limit = 50, Snowflake? after, Snowflake? around, Snowflake? before}) => - client.httpEndpoints.downloadMessages(id, limit: limit, after: after, around: around, before: before); - - @override - Future fetchMessage(Snowflake messageId) => client.httpEndpoints.fetchMessage(id, messageId); - - @override - IMessage? getMessage(Snowflake id) => messageCache[id]; - - @override - Future sendMessage(MessageBuilder builder) => client.httpEndpoints.sendMessage(id, builder); - - @override - Future get fileUploadLimit async { - final guildInstance = await guild.getOrDownload(); - - return guildInstance.fileUploadLimit; - } - - @override - Future startTyping() async => client.httpEndpoints.triggerTyping(id); - - @override - void startTypingLoop() { - startTyping(); - _typing = Timer.periodic(const Duration(seconds: 7), (Timer t) => startTyping()); - } - - @override - void stopTypingLoop() => _typing?.cancel(); - - @override - Stream fetchPinnedMessages() => client.httpEndpoints.fetchPinnedMessages(id); -} diff --git a/lib/src/core/discord_color.dart b/lib/src/core/discord_color.dart deleted file mode 100644 index eb25b1efd..000000000 --- a/lib/src/core/discord_color.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -// All colors got from DiscordColor class from DSharp+. -// https://github.com/DSharpPlus/DSharpPlus/blob/a2f6eca7f5f675e83748b20b957ae8bdb8fd0cab/DSharpPlus/Entities/DiscordColor.Colors.cs - -/// Wrapper for colors. -/// Simplifies creation and provides interface to interact with colors for nyxx. -class DiscordColor extends IEnum { - /// Construct color from int. - /// It allows to create color from hex number and decimal number - /// - /// ``` - /// final color = DiscordColor.fromInt(43563); - /// final color2 = DiscordColor.fromInt(0xff0044); - /// ``` - DiscordColor.fromInt(int value) : super(value); - - /// Construct color from individual color components - factory DiscordColor.fromRgb(int r, int g, int b) => DiscordColor.fromInt(r << 16 | g << 8 | b); - - /// Construct color from individual color components with doubles - /// Values should be from 0.0 to 1.0 - factory DiscordColor.fromDouble(double r, double g, double b) { - final rb = (r * 255).toInt().clamp(0, 255); - final gb = (g * 255).toInt().clamp(0, 255); - final bb = (b * 255).toInt().clamp(0, 255); - - return DiscordColor.fromInt(rb << 16 | gb << 8 | bb); - } - - /// Construct color from hex String. - /// Leading # will be ignored in process. - factory DiscordColor.fromHexString(String hexStr) { - if (hexStr.isEmpty) { - throw ArgumentError("Hex color String cannot be empty"); - } - - if (hexStr.startsWith("#")) { - hexStr = hexStr.substring(1); - } - - return DiscordColor.fromInt(int.parse(hexStr, radix: 16)); - } - - /// Gets the blue component of this color as an integer. - int get r => (value >> 16) & 0xFF; - - /// Gets the green component of this color as an integer. - int get g => (value >> 8) & 0xFF; - - /// Gets the blue component of this color as an integer. - int get b => value & 0xFF; - - @override - String toString() => asHexString(); - - /// Returns - String asHexString() { - final buffer = StringBuffer(); - - buffer.write("#"); - buffer.write(r.toRadixString(16).padLeft(2, "0")); - buffer.write(g.toRadixString(16).padLeft(2, "0")); - buffer.write(b.toRadixString(16).padLeft(2, "0")); - - return buffer.toString().toUpperCase(); - } - - /// Represents no color, or integer 0. - static final DiscordColor none = DiscordColor.fromInt(0); - - /// A near-black color. Due to API limitations, the color is #010101, rather than #000000, as the latter is treated as no color. - static final DiscordColor black = DiscordColor.fromInt(0x010101); - - /// White, or #FFFFFF. - static final DiscordColor white = DiscordColor.fromInt(0xFFFFFF); - - /// Gray, or #808080. - static final DiscordColor gray = DiscordColor.fromInt(0x808080); - - /// Dark gray, or #A9A9A9. - static final DiscordColor darkGray = DiscordColor.fromInt(0xA9A9A9); - - /// Light gray, or #808080. - static final DiscordColor lightGray = DiscordColor.fromInt(0xD3D3D3); - - /// Very dark gray, or #666666. - static final DiscordColor veryDarkGray = DiscordColor.fromInt(0x666666); - - /// Flutter blue, or #02569B - static final DiscordColor flutterBlue = DiscordColor.fromInt(0x02569B); - - /// Dart's primary blue color, or #0175C2 - static final DiscordColor dartBlue = DiscordColor.fromInt(0x0175C2); - - /// Dart's secondary blue color, or #13B9FD - static final DiscordColor dartSecondary = DiscordColor.fromInt(0x13B9FD); - - /// Discord Blurple, or #7289DA. - static final DiscordColor blurple = DiscordColor.fromInt(0x7289DA); - - /// Discord Grayple, or #99AAB5. - static final DiscordColor grayple = DiscordColor.fromInt(0x99AAB5); - - /// Discord Dark, But Not Black, or #2C2F33. - static final DiscordColor darkButNotBlack = DiscordColor.fromInt(0x2C2F33); - - /// Discord Not QuiteBlack, or #23272A. - static final DiscordColor notQuiteBlack = DiscordColor.fromInt(0x23272A); - - /// Red, or #FF0000. - static final DiscordColor red = DiscordColor.fromInt(0xFF0000); - - /// Dark red, or #7F0000. - static final DiscordColor darkRed = DiscordColor.fromInt(0x7F0000); - - /// Green, or #00FF00. - static final DiscordColor green = DiscordColor.fromInt(0x00FF00); - - /// Dark green, or #007F00. - static final DiscordColor darkGreen = DiscordColor.fromInt(0x007F00); - - /// Blue, or #0000FF. - static final DiscordColor blue = DiscordColor.fromInt(0x0000FF); - - /// Dark blue, or #00007F. - static final DiscordColor darkBlue = DiscordColor.fromInt(0x00007F); - - /// Yellow, or #FFFF00. - static final DiscordColor yellow = DiscordColor.fromInt(0xFFFF00); - - /// Cyan, or #00FFFF. - static final DiscordColor cyan = DiscordColor.fromInt(0x00FFFF); - - /// Magenta, or #FF00FF. - static final DiscordColor magenta = DiscordColor.fromInt(0xFF00FF); - - /// Teal, or #008080. - static final DiscordColor teal = DiscordColor.fromInt(0x008080); - - /// Aquamarine, or #00FFBF. - static final DiscordColor aquamarine = DiscordColor.fromInt(0x00FFBF); - - /// Gold, or #FFD700. - static final DiscordColor gold = DiscordColor.fromInt(0xFFD700); - - /// Goldenrod, or #DAA520 - static final DiscordColor goldenrod = DiscordColor.fromInt(0xDAA520); - - /// Azure, or #007FFF. - static final DiscordColor azure = DiscordColor.fromInt(0x007FFF); - - /// Rose, or #FF007F. - static final DiscordColor rose = DiscordColor.fromInt(0xFF007F); - - /// Spring green, or #00FF7F. - static final DiscordColor springGreen = DiscordColor.fromInt(0x00FF7F); - - /// Chartreuse, or #7FFF00. - static final DiscordColor chartreuse = DiscordColor.fromInt(0x7FFF00); - - /// Orange, or #FFA500. - static final DiscordColor orange = DiscordColor.fromInt(0xFFA500); - - /// Purple, or #800080. - static final DiscordColor purple = DiscordColor.fromInt(0x800080); - - /// Violet, or #EE82EE. - static final DiscordColor violet = DiscordColor.fromInt(0xEE82EE); - - /// Brown, or #A52A2A. - static final DiscordColor brown = DiscordColor.fromInt(0xA52A2A); - - /// Hot pink, or #FF69B4 - static final DiscordColor hotPink = DiscordColor.fromInt(0xFF69B4); - - /// Lilac, or #C8A2C8. - static final DiscordColor lilac = DiscordColor.fromInt(0xC8A2C8); - - /// Cornflower blue, or #6495ED. - static final DiscordColor cornflowerBlue = DiscordColor.fromInt(0x6495ED); - - /// Midnight blue, or #191970. - static final DiscordColor midnightBlue = DiscordColor.fromInt(0x191970); - - /// Wheat, or #F5DEB3. - static final DiscordColor wheat = DiscordColor.fromInt(0xF5DEB3); - - /// Indian red, or #CD5C5C. - static final DiscordColor indianRed = DiscordColor.fromInt(0xCD5C5C); - - /// Turquoise, or #30D5C8. - static final DiscordColor turquoise = DiscordColor.fromInt(0x30D5C8); - - /// Sap green, or #507D2A. - static final DiscordColor sapGreen = DiscordColor.fromInt(0x507D2A); - - /// Phthalo blue, or #000F89. - static final DiscordColor phthaloBlue = DiscordColor.fromInt(0x000F89); - - /// Phthalo green, or #123524. - static final DiscordColor phthaloGreen = DiscordColor.fromInt(0x123524); - - /// Sienna, or #882D17. - static final DiscordColor sienna = DiscordColor.fromInt(0x882D17); -} diff --git a/lib/src/core/embed/embed.dart b/lib/src/core/embed/embed.dart deleted file mode 100644 index c75a01526..000000000 --- a/lib/src/core/embed/embed.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:nyxx/src/core/discord_color.dart'; -import 'package:nyxx/src/core/embed/embed_author.dart'; -import 'package:nyxx/src/core/embed/embed_field.dart'; -import 'package:nyxx/src/core/embed/embed_footer.dart'; -import 'package:nyxx/src/core/embed/embed_provider.dart'; -import 'package:nyxx/src/core/embed/embed_thumbnail.dart'; -import 'package:nyxx/src/core/embed/embed_video.dart'; -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/embed_builder.dart'; - -abstract class IEmbed implements Convertable { - /// The embed's title. - String? get title; - - /// The embed's type - String? get type; - - /// The embed's description. - String? get description; - - /// The embed's URL - String? get url; - - /// Timestamp of embed content - DateTime? get timestamp; - - /// Color of embed - DiscordColor? get color; - - /// Embed's footer - IEmbedFooter? get footer; - - /// The embed's thumbnail, if any. - IEmbedThumbnail? get thumbnail; - - /// The embed's provider, if any. - IEmbedProvider? get provider; - - /// Embed image - IEmbedThumbnail? get image; - - /// Embed video - IEmbedVideo? get video; - - /// Embed author - IEmbedAuthor? get author; - - /// Map of fields of embed. Map(name, field) - List get fields; -} - -/// A message embed. -/// Can contain null elements. -class Embed implements IEmbed { - /// The embed's title. - @override - late final String? title; - - /// The embed's type - @override - late final String? type; - - /// The embed's description. - @override - late final String? description; - - /// The embed's URL - @override - late final String? url; - - /// Timestamp of embed content - @override - late final DateTime? timestamp; - - /// Color of embed - @override - late final DiscordColor? color; - - /// Embed's footer - @override - late final IEmbedFooter? footer; - - /// The embed's thumbnail, if any. - @override - late final IEmbedThumbnail? thumbnail; - - /// The embed's provider, if any. - @override - late final IEmbedProvider? provider; - - /// Embed image - @override - late final IEmbedThumbnail? image; - - /// Embed video - @override - late final IEmbedVideo? video; - - /// Embed author - @override - late final IEmbedAuthor? author; - - /// Map of fields of embed. Map(name, field) - @override - late final List fields; - - /// Creates an instance [Embed] - Embed(RawApiMap raw) { - title = raw["title"] as String?; - - url = raw["url"] as String?; - - type = raw["type"] as String?; - - description = raw["description"] as String?; - - if (raw["timestamp"] != null) { - timestamp = DateTime.parse(raw["timestamp"] as String); - } else { - timestamp = null; - } - - if (raw["color"] != null) { - color = DiscordColor.fromInt(raw["color"] as int); - } else { - color = null; - } - - if (raw["author"] != null) { - author = EmbedAuthor(raw["author"] as RawApiMap); - } else { - author = null; - } - - if (raw["video"] != null) { - video = EmbedVideo(raw["video"] as RawApiMap); - } else { - video = null; - } - - if (raw["image"] != null) { - image = EmbedThumbnail(raw["image"] as RawApiMap); - } else { - image = null; - } - - if (raw["footer"] != null) { - footer = EmbedFooter(raw["footer"] as RawApiMap); - } else { - footer = null; - } - - if (raw["thumbnail"] != null) { - thumbnail = EmbedThumbnail(raw["thumbnail"] as RawApiMap); - } else { - thumbnail = null; - } - - if (raw["provider"] != null) { - provider = EmbedProvider(raw["provider"] as RawApiMap); - } else { - provider = null; - } - - fields = [ - if (raw["fields"] != null) - for (var obj in raw["fields"] as RawApiList) EmbedField(obj as RawApiMap) - ]; - } - - @override - EmbedBuilder toBuilder() => EmbedBuilder() - ..title = title - ..type = type - ..description = description - ..url = url - ..timestamp = timestamp - ..color = color - ..footer = footer?.toBuilder() - ..thumbnailUrl = thumbnail?.url - ..imageUrl = image?.url - ..author = author?.toBuilder() - ..fields = fields.map((field) => field.toBuilder()).toList(); -} diff --git a/lib/src/core/embed/embed_author.dart b/lib/src/core/embed/embed_author.dart deleted file mode 100644 index 7b22b0172..000000000 --- a/lib/src/core/embed/embed_author.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/embed_author_builder.dart'; - -abstract class IEmbedAuthor implements Convertable { - /// Name of embed author - String? get name; - - /// Url to embed author - String? get url; - - /// Url to author's url - String? get iconUrl; - - /// Proxied icon url - String? get iconProxyUrl; -} - -/// Author of embed. Can contain null elements. -class EmbedAuthor implements IEmbedAuthor { - /// Name of embed author - @override - late final String? name; - - /// Url to embed author - @override - late final String? url; - - /// Url to author's url - @override - late final String? iconUrl; - - /// Proxied icon url - @override - late final String? iconProxyUrl; - - /// Creates an instance of [EmbedAuthor] - EmbedAuthor(RawApiMap raw) { - name = raw["name"] as String?; - url = raw["url"] as String?; - iconUrl = raw["icon_url"] as String?; - iconProxyUrl = raw["iconProxyUrl"] as String?; - } - - @override - String toString() => name ?? ""; - - @override - int get hashCode => url.hashCode * 37 + name.hashCode * 37 + iconUrl.hashCode * 37; - - @override - bool operator ==(other) => other is EmbedAuthor ? other.url == url && other.name == name && other.iconUrl == iconUrl : false; - - @override - EmbedAuthorBuilder toBuilder() => EmbedAuthorBuilder() - ..url = url - ..name = name - ..iconUrl = iconUrl; -} diff --git a/lib/src/core/embed/embed_field.dart b/lib/src/core/embed/embed_field.dart deleted file mode 100644 index cca3140b0..000000000 --- a/lib/src/core/embed/embed_field.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/embed_field_builder.dart'; - -abstract class IEmbedField implements Convertable { - /// Field name - String get name; - - /// Contents of field (aka value) - String get content; - - /// Indicates of field is inlined in embed - bool? get inline; -} - -/// Single instance of Embed's field. Can contain null elements. -class EmbedField implements IEmbedField { - /// Field name - @override - late final String name; - - /// Contents of field (aka value) - @override - late final String content; - - /// Indicates of field is inlined in embed - @override - late final bool? inline; - - /// Creates an instance of [EmbedField] - EmbedField(RawApiMap raw) { - name = raw["name"] as String; - content = raw["value"] as String; - inline = raw["inline"] as bool?; - } - - @override - EmbedFieldBuilder toBuilder() => EmbedFieldBuilder(name, content, inline); -} diff --git a/lib/src/core/embed/embed_footer.dart b/lib/src/core/embed/embed_footer.dart deleted file mode 100644 index 1fb20c8df..000000000 --- a/lib/src/core/embed/embed_footer.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/embed_footer_builder.dart'; - -abstract class IEmbedFooter implements Convertable { - /// Text inside footer - String? get text; - - /// Url of icon which is next to text - String? get iconUrl; - - /// Proxied url of icon url - String? get iconProxyUrl; -} - -/// Embed's footer. Can contain null elements. -class EmbedFooter implements IEmbedFooter { - /// Text inside footer - @override - late final String? text; - - /// Url of icon which is next to text - @override - late final String? iconUrl; - - /// Proxied url of icon url - @override - late final String? iconProxyUrl; - - /// Creates an instance of [EmbedFooter] - EmbedFooter(RawApiMap raw) { - text = raw["text"] as String?; - iconUrl = raw["icon_url"] as String?; - iconProxyUrl = raw["icon_proxy_url"] as String?; - } - - @override - EmbedFooterBuilder toBuilder() => EmbedFooterBuilder() - ..text = text - ..iconUrl = iconUrl; -} diff --git a/lib/src/core/embed/embed_provider.dart b/lib/src/core/embed/embed_provider.dart deleted file mode 100644 index b367b7e5d..000000000 --- a/lib/src/core/embed/embed_provider.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; - -abstract class IEmbedProvider { - /// The embed provider's name. - String? get name; - - /// The embed provider's URL. - String? get url; -} - -/// A message embed provider. -class EmbedProvider implements IEmbedProvider { - /// The embed provider's name. - @override - late final String? name; - - /// The embed provider's URL. - @override - late final String? url; - - /// Creates an instance of [EmbedProvider] - EmbedProvider(RawApiMap raw) { - name = raw["name"] as String?; - - url = raw["url"] as String?; - } -} diff --git a/lib/src/core/embed/embed_thumbnail.dart b/lib/src/core/embed/embed_thumbnail.dart deleted file mode 100644 index 08978aa2d..000000000 --- a/lib/src/core/embed/embed_thumbnail.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; - -abstract class IEmbedThumbnail { - /// The embed thumbnail's URL. - String? get url; - - /// The embed thumbnal's proxy URL. - String? get proxyUrl; - - /// The embed thumbnal's height. - int? get height; - - /// The embed thumbnal's width. - int? get width; -} - -/// A message embed thumbnail. -class EmbedThumbnail implements IEmbedThumbnail { - /// The embed thumbnail's URL. - @override - late final String? url; - - /// The embed thumbnal's proxy URL. - @override - late final String? proxyUrl; - - /// The embed thumbnal's height. - @override - late final int? height; - - /// The embed thumbnal's width. - @override - late final int? width; - - /// Creates an instance of [EmbedThumbnail] - EmbedThumbnail(RawApiMap raw) { - url = raw["url"] as String?; - proxyUrl = raw["proxy_url"] as String?; - height = raw["height"] as int?; - width = raw["width"] as int?; - } -} diff --git a/lib/src/core/embed/embed_video.dart b/lib/src/core/embed/embed_video.dart deleted file mode 100644 index 057f75cdd..000000000 --- a/lib/src/core/embed/embed_video.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; - -abstract class IEmbedVideo { - /// The embed video's URL. - String? get url; - - /// The embed video's height. - int? get height; - - /// The embed video's width. - int? get width; -} - -/// Video attached to embed. Can contain null elements. -class EmbedVideo implements IEmbedVideo { - /// The embed video's URL. - @override - late final String? url; - - /// The embed video's height. - @override - late final int? height; - - /// The embed video's width. - @override - late final int? width; - - /// Creates an instance of [EmbedVideo] - EmbedVideo(RawApiMap raw) { - url = raw["url"] as String; - height = raw["height"] as int; - width = raw["width"] as int; - } -} diff --git a/lib/src/core/guild/auto_moderation.dart b/lib/src/core/guild/auto_moderation.dart deleted file mode 100644 index 01f305bae..000000000 --- a/lib/src/core/guild/auto_moderation.dart +++ /dev/null @@ -1,271 +0,0 @@ -import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/internal/exceptions/unknown_enum_value.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IAutoModerationRule implements SnowflakeEntity { - /// The guild's id this rule is applied to. - Cacheable get guild; - - /// The name of this rule. - String get name; - - /// The user which first created this rule. - Cacheable get creator; - - /// The rule event type. - EventTypes get eventType; - - /// The rule trigger type. - TriggerTypes get triggerType; - - /// The trigger metadata. - ITriggerMetadata get triggerMetadata; - - /// The actions which will execute when the rule is triggered. - List? get actions; - - /// Whether this rule is enabled. - bool get enabled; - - /// The role that should not be affected by the rule (Maximum of 20). - Iterable> get ignoredRoles; - - /// The channel that should not be affected by the rule (Maximum of 50). - Iterable> get ignoredChannels; -} - -enum EventTypes { - /// When a member sends or edits a message in a guild. - messageSend(1); - - final int value; - const EventTypes(this.value); - - static EventTypes _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); - - @override - String toString() => 'EventTypes[$value]'; -} - -enum TriggerTypes { - /// Check if content contains words from a user defined list of keywords. - keyword(1), - - /// Check if content contains any harmful links. - harmfulLink(2), - - /// Check if content represents generic spam. - spam(3), - - /// Check if content contains words from internal pre-defined wordsets. - keywordPreset(4), - - /// Check if content contains more mentions than allowed. - mentionSpam(5); - - final int value; - const TriggerTypes(this.value); - - // Not private because used in guild events - static TriggerTypes fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); - - @override - String toString() => 'TriggerTypes[$value]'; -} - -enum KeywordPresets { - /// Words that may be considered forms of swearing or cursing. - profanity(1), - - /// Words that refer to sexually explicit behavior or activity. - sexualContent(2), - - /// Personal insults or words that may be considered hate speech. - slurs(3); - - final int value; - const KeywordPresets(this.value); - - static KeywordPresets _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); - - @override - String toString() => 'KeywordPresets[$value]'; -} - -enum ActionTypes { - /// Blocks the content of a message according to the rule. - blockMessage(1), - - /// Logs user's sended message to a specified channel. - sendAlertMessage(2), - - /// Timeout user for a specified duration. - timeout(3); - - final int value; - const ActionTypes(this.value); - - static ActionTypes _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); - - @override - String toString() => 'ActionTypes[$value]'; -} - -abstract class ITriggerMetadata { - /// Substrings which will be searched for in the content. - /// The associated trigger type is [TriggerTypes.keyword]. - List? get keywordsFilter; - - /// The internally pre-defined wordsets which will be searched for in content. - /// The associated trigger type is [TriggerTypes.keywordPreset]. - Iterable? get keywordPresets; - - /// Substrings which will be exempt from triggering the preset trigger type. - /// The associated trigger type is [TriggerTypes.keywordPreset]. - List? get allowList; - - /// The total number of mentions (either role and user) allowed per message. - /// (Maximum of 50) - /// The associated trigger type is [TriggerTypes.mentionSpam]. - int? get mentionLimit; - - /// Regular expression patterns which will be matched against content. - /// The associated trigger type is [TriggerTypes.keyword]. - Iterable? get regexPatterns; -} - -abstract class IActionStructure { - /// The type of the action. - ActionTypes get actionType; - - /// Additional metadata needed during execution for this specific action type. - IActionMetadata? get actionMetadata; -} - -abstract class IActionMetadata { - /// The channel if to which user content should be logged. - /// The associated action type is [ActionTypes.sendAlertMessage]. - Cacheable? get channelId; - - /// The timeout duration - maximum duration is 4 weeks (2,419,200 seconds). - /// It's associated action type is [ActionTypes.timeout]. - Duration? get duration; -} - -class AutoModerationRule extends SnowflakeEntity implements IAutoModerationRule { - @override - late final Cacheable guild; - - @override - late final String name; - - @override - late final Cacheable creator; - - @override - late final EventTypes eventType; - - @override - late final TriggerTypes triggerType; - - @override - late final ITriggerMetadata triggerMetadata; - - @override - late final List? actions; - - @override - late final bool enabled; - - @override - late final Iterable> ignoredRoles; - - @override - late final Iterable> ignoredChannels; - - AutoModerationRule(RawApiMap rawData, INyxx client) : super(Snowflake(rawData['id'])) { - guild = GuildCacheable(client, Snowflake(rawData['guild_id'])); - name = rawData['name'] as String; - creator = MemberCacheable(client, Snowflake(rawData['creator_id']), guild); - eventType = EventTypes._fromValue(rawData['event_type'] as int); - triggerType = TriggerTypes.fromValue(rawData['trigger_type'] as int); - triggerMetadata = TriggerMetadata(rawData['trigger_metadata'] as RawApiMap); - actions = (rawData['actions'] as RawApiList?)?.map((a) => ActionStructure(a as RawApiMap, client)).toList(); - enabled = rawData['enabled'] as bool; - ignoredRoles = (rawData['exempt_roles'] as RawApiList).map((r) => RoleCacheable(client, Snowflake(r), guild)); - ignoredChannels = (rawData['exempt_channels'] as RawApiList).map((r) => ChannelCacheable(client, Snowflake(r))); - } - - @override - String toString() => 'IAutoModerationRule(id: $id, guildId: ${guild.id}, name: $name, triggerMetadata: $triggerMetadata)'; -} - -class TriggerMetadata implements ITriggerMetadata { - // Maybe return null instead of empty list - @override - late final Iterable? keywordPresets; - - @override - late final List? keywordsFilter; - - @override - late final List? allowList; - - @override - late final int? mentionLimit; - - @override - late final Iterable? regexPatterns; - - /// Creates an instance of [TriggerMetadata] - TriggerMetadata(RawApiMap data) { - keywordsFilter = data['keyword_filter'] != null ? (data['keyword_filter'] as RawApiList).cast() : null; - keywordPresets = data['presets'] != null ? (data['presets'] as RawApiList).map((p) => KeywordPresets._fromValue(p as int)) : null; - allowList = (data['allow_list'] as RawApiList?)?.cast().toList(); - mentionLimit = data['mention_total_limit'] as int?; - regexPatterns = data['regex_patterns'] != null ? (data['regex_patterns'] as RawApiList).cast() : null; - } - - @override - String toString() => - 'ITriggerMetadata(keywordPresets: $keywordPresets, keywordFilter: $keywordsFilter, allowList: $allowList, mentionLimit: $mentionLimit, regexPatterns: $regexPatterns)'; -} - -class ActionStructure implements IActionStructure { - @override - late final IActionMetadata? actionMetadata; - - @override - late final ActionTypes actionType; - - /// Creates an instance of [ActionStructure]. - ActionStructure(RawApiMap data, INyxx client) { - actionType = ActionTypes._fromValue(data['type'] as int); - if (data['metadata'] != null && (data['metadata'] as RawApiMap).isNotEmpty) { - actionMetadata = ActionMetadata(data['metadata'] as RawApiMap, client); - } else { - actionMetadata = null; - } - } -} - -class ActionMetadata implements IActionMetadata { - @override - late final Cacheable? channelId; - - @override - late final Duration? duration; - - /// Creates an instance of [ActionMetadata]. - ActionMetadata(RawApiMap data, INyxx client) { - channelId = data['channel_id'] != null ? ChannelCacheable(client, Snowflake(data['channel_id'])) : null; - duration = data['duration_seconds'] != null ? Duration(seconds: data['duration_seconds'] as int) : null; - } -} diff --git a/lib/src/core/guild/ban.dart b/lib/src/core/guild/ban.dart deleted file mode 100644 index 2defd4e27..000000000 --- a/lib/src/core/guild/ban.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IBan { - /// Reason of ban - String? get reason; - - /// Banned user - IUser get user; -} - -/// Ban object. Has attached reason of ban and user who was banned. -class Ban implements IBan { - /// Reason of ban - @override - late final String? reason; - - /// Banned user - @override - late final IUser user; - - /// Creates an instance of [Ban] - Ban(RawApiMap raw, INyxx client) { - reason = raw["reason"] as String; - user = User(client, raw["user"] as RawApiMap); - } -} diff --git a/lib/src/core/guild/client_user.dart b/lib/src/core/guild/client_user.dart deleted file mode 100644 index 38233d2cb..000000000 --- a/lib/src/core/guild/client_user.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; - -/// ClientUser is bot's discord account. Allows to change bot's presence. -abstract class IClientUser implements IUser { - /// Weather or not the client user's account is verified. - bool? get verified; - - /// Weather or not the client user has MFA enabled. - bool? get mfa; - - /// Edits current user. This changes user's username - not per guild nickname. - Future edit({String? username, AttachmentBuilder? avatarAttachment}); -} - -/// ClientUser is bot's discord account. Allows to change bot's presence. -class ClientUser extends User implements IClientUser { - /// Weather or not the client user's account is verified. - @override - late final bool? verified; - - /// Weather or not the client user has MFA enabled. - @override - late final bool? mfa; - - /// Creates an instance of [ClientUser] - ClientUser(NyxxWebsocket client, RawApiMap raw) : super(client, raw) { - verified = raw["verified"] as bool; - mfa = raw["mfa_enabled"] as bool; - } - - /// Edits current user. This changes user's username - not per guild nickname. - @override - Future edit({String? username, AttachmentBuilder? avatarAttachment}) => - client.httpEndpoints.editSelfUser(username: username, avatarAttachment: avatarAttachment); -} diff --git a/lib/src/core/guild/guild.dart b/lib/src/core/guild/guild.dart deleted file mode 100644 index 4881018d7..000000000 --- a/lib/src/core/guild/guild.dart +++ /dev/null @@ -1,984 +0,0 @@ -import 'package:nyxx/src/core/audit_logs/audit_log.dart'; -import 'package:nyxx/src/core/audit_logs/audit_log_entry.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/channel/invite.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/guild/auto_moderation.dart'; -import 'package:nyxx/src/core/guild/guild_feature.dart'; -import 'package:nyxx/src/core/guild/guild_nsfw_level.dart'; -import 'package:nyxx/src/core/guild/guild_preview.dart'; -import 'package:nyxx/src/core/guild/guild_welcome_screen.dart'; -import 'package:nyxx/src/core/guild/premium_tier.dart'; -import 'package:nyxx/src/core/guild/scheduled_event.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/user/presence.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cache.dart'; -import 'package:nyxx/src/internal/exceptions/invalid_shard_exception.dart'; -import 'package:nyxx/src/internal/response_wrapper/thread_list_result_wrapper.dart'; -import 'package:nyxx/src/internal/shard/shard.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; -import 'package:nyxx/src/core/channel/guild/voice_channel.dart'; -import 'package:nyxx/src/core/guild/ban.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/message/sticker.dart'; -import 'package:nyxx/src/core/permissions/permissions.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/voice/voice_region.dart'; -import 'package:nyxx/src/core/voice/voice_state.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; -import 'package:nyxx/src/utils/builders/auto_moderation_builder.dart'; -import 'package:nyxx/src/utils/builders/channel_builder.dart'; -import 'package:nyxx/src/utils/builders/guild_builder.dart'; -import 'package:nyxx/src/utils/builders/guild_event_builder.dart'; -import 'package:nyxx/src/utils/builders/sticker_builder.dart'; - -abstract class IGuild implements SnowflakeEntity { - /// Reference to [INyxxWebsocket] instance - INyxx get client; - - /// The guild's name. - String get name; - - /// The guild's icon hash. - String? get icon; - - /// Splash hash - String? get splash; - - /// Discovery splash hash - String? get discoverySplash; - - /// System channel where system messages are sent - Cacheable? get systemChannel; - - /// enabled guild features - Iterable get features; - - /// The guild's afk channel ID, null if not set. - Cacheable? get afkChannel; - - /// The channel ID for the guild's widget if enabled. - Cacheable? get embedChannel; - - /// The guild's AFK timeout. - int get afkTimeout; - - /// The guild's verification level. - int get verificationLevel; - - /// The guild's notification level. - int get notificationLevel; - - /// The guild's MFA level. - int get mfaLevel; - - /// If the guild's widget is enabled. - bool? get embedEnabled; - - /// Whether or not the guild is available. - bool get available; - - /// System Channel Flags - int get systemChannelFlags; - - /// Channel where "PUBLIC" guilds display rules and/or guidelines - Cacheable? get rulesChannel; - - /// The guild owner's ID - Cacheable get owner; - - /// The guild's members. - Map get members; - - /// The guild's channels. - Iterable get channels; - - /// The guild's roles. - Map get roles; - - /// Guild custom emojis - Map get emojis; - - /// Boost level of guild - PremiumTier get premiumTier; - - /// The number of boosts this server currently has - int? get premiumSubscriptionCount; - - /// the preferred locale of a "PUBLIC" guild used - /// in server discovery and notices from Discord; defaults to "en-US" - String get preferredLocale; - - /// the id of the channel where admins and moderators - /// of "PUBLIC" guilds receive notices from Discord - CacheableTextChannel? get publicUpdatesChannel; - - /// Permission of current(bot) user in this guild - IPermissions? get currentUserPermissions; - - /// Users state cache - Map get voiceStates; - - /// Stage instances in the guild - Iterable get stageInstances; - - /// Nsfw level of guild - GuildNsfwLevel get guildNsfwLevel; - - /// Stickers of this guild - Iterable get stickers; - - /// Returns url to this guild. - String get url; - - /// Getter for @everyone role - IRole get everyoneRole; - - /// Returns member object for bot user - Cacheable get selfMember; - - /// File upload limit for channel in bytes. - int get fileUploadLimit; - - /// Returns this guilds shard - IShard get shard; - - /// Whether the guild has the boost progress bar enabled - bool get boostProgressBarEnabled; - - /// The banner hash of the guild, if any. - String? get banner; - - /// List of partial presences. - /// - /// Will only include non-offline members if the size of the guild is greater than the [ClientOptions.largeThreshold] option. - List get presences; - - /// If this guild is considered large. - bool get large; - - /// The maximum amount of members that can be in this guild. - int get maximumMembers; - - /// The maximum amount of presences that can be in this guild. - int? get maximumPresences; - - /// Explicit content filter level of this guild. - int get explicitContentFilterLevel; - - /// The vanity URL code of this guild. If any. - String? get vanityUrlCode; - - /// The description of this guild. If it's a community guild. - String? get description; - - /// The total amount of members in this guild. - int? get memberCount; - - /// The approximate amount of members in this guild. - int? get approxMemberCount; - - /// The approximate amount of presences in the guild. - int? get approxPresenceCount; - - /// The cached auto moderation rules in the guild. - /// An empty map is returned if none where fetched or added by events. - ICache get autoModerationRules; - - /// The cached guild events in the guild. - /// An empty map is returned if none where fetched or added by events. - ICache get scheduledEvents; - - /// The guild's icon, represented as URL. - /// If guild doesn't have icon it returns null. - String? iconUrl({String format = 'webp', int? size, bool animated = false}); - - /// URL to guild's splash. - /// If guild doesn't have splash it returns null. - String? splashUrl({String format = 'webp', int? size}); - - /// URL to guilds discovery splash - /// If guild doesn't have splash it returns null. - String? discoveryUrl({String format = 'webp', int? size}); - - /// URL to guild's banner. - /// If guild doesn't have banner it returns null. - String? bannerUrl({String format = 'webp', int? size, bool animated = false}); - - /// Allows to download [IGuild] widget aka advert png - /// Possible options for [style]: shield (default), banner1, banner2, banner3, banner4 - String guildWidgetUrl([String style = "shield"]); - - /// Fetches all stickers of current guild - Stream fetchStickers(); - - /// Fetch sticker with given [id] - Future fetchSticker(Snowflake id); - - /// Fetches all roles that are in the server. - Stream fetchRoles(); - - /// Creates sticker in current guild - Future createSticker(StickerBuilder builder); - - /// Fetches emoji from API - Future fetchEmoji(Snowflake emojiId); - - /// Allows to create new guild emoji. [name] is required. You can allow to set [roles] to restrict emoji usage. - /// Put your image in [emojiAttachment] field. - /// - /// ``` - /// var emojiFile = File("weed.png"); - /// var emoji = await guild.createEmoji("weed", emojiAttachment: AttachmentBuilder.file(emojiFile)); - /// ``` - Future createEmoji(String name, {List? roles, AttachmentBuilder? emojiAttachment}); - - /// Returns [int] indicating the number of members that would be removed in a prune operation. - Future pruneCount(int days, {Iterable? includeRoles}); - - /// Prunes the guild, returns the amount of members pruned. - Future prune(int days, {Iterable? includeRoles, String? auditReason}); - - /// Gets the guild's bans. - Stream getBans({int limit = 1000, Snowflake? before, Snowflake? after}); - - /// Change self nickname in guild - Future modifyCurrentMember({String? nick}); - - /// Gets single [Ban] object for given [bannedUserId] - Future getBan(Snowflake bannedUserId); - - /// Change guild owner. - Future changeOwner(SnowflakeEntity memberEntity, {String? auditReason}); - - /// Leaves the guild. - Future leave(); - - /// Returns list of Guilds invites - Stream fetchGuildInvites(); - - /// Returns Audit logs. - /// https://discordapp.com/developers/docs/resources/audit-log - /// ```dart - /// var logs = await guild.fetchAuditLogs(auditType: AuditLogEntryType.guildUpdate); - /// ``` - Future fetchAuditLogs({Snowflake? userId, AuditLogEntryType? auditType, Snowflake? before, int? limit}); - - /// Creates new role - /// ```dart - /// var rb = new RoleBuilder("Dartyy") - /// ..color = DiscordColor.fromInt(0xFF04F2) - /// ..hoist = true; - /// - /// var role = await guild.createRole(roleBuilder); - /// ``` - Future createRole(RoleBuilder roleBuilder, {String? auditReason}); - - /// Returns list of available [VoiceRegion]s - Stream getVoiceRegions(); - - /// Moves channel - Future moveChannel(IChannel channel, int position, {String? auditReason}); - - /// Bans a user and allows to delete messages from [deleteMessageDays] number of days. - /// ```dart - /// await guild.ban(member); - /// ``` - Future ban(SnowflakeEntity user, {int deleteMessageDays = 0, String? auditReason}); - - /// Kicks user from guild. Member is removed from guild and they're able to rejoin if they have a valid invite link. - /// ```dart - /// await guild.kick(member); - /// ``` - Future kick(SnowflakeEntity user, {String? auditReason}); - - /// Unbans a user by ID. - Future unban(Snowflake userId); - - /// Edits the guild. - Future edit(GuildBuilder builder, {String? auditReason}); - - /// Fetches member from API - Future fetchMember(Snowflake memberId); - - /// Allows to fetch guild members. In future will be restricted with `Privileged Intents`. - /// [after] is used to continue from specified user id. - /// By default limits to one user - use [limit] parameter to change that behavior. - Stream fetchMembers({int limit = 1, Snowflake? after}); - - /// Returns a [Stream] of [Member]s objects whose username or nickname starts with a provided string. - /// By default limits to one entry - can be changed with [limit] parameter. - Stream searchMembers(String query, {int limit = 1}); - - /// Returns a [Stream] of [Member]s objects whose username or nickname starts with a provided string. - /// By default limits to one entry - can be changed with [limit] parameter. - Stream searchMembersGateway(String query, {int limit = 0}); - - /// Fetches guild preview for this guild. Allows to download approx member count in guild - Future fetchGuildPreview(); - - /// Request members from gateway. Requires privileged intents in order to work. - void requestChunking(); - - /// Allows to create new guild channel - Future createChannel(ChannelBuilder channelBuilder); - - /// Deletes the guild. - Future delete(); - - /// Creates guild event using [builder] - Future createGuildEvent(GuildEventBuilder builder); - - /// Fetches and returns from api single event with given id - Future fetchGuildEvent(Snowflake guildEventId); - - /// Fetches from api list of events in guild - Stream fetchGuildEvents({bool withUserCount = false}); - - /// Fetches the welcome screen of this guild if it's a community guild. - Future fetchWelcomeScreen(); - - /// Fetches the auto moderation rules. - Stream fetchAutoModerationRules(); - - /// Fetches a sole moderation rule. - Future fetchAutoModerationRule(Snowflake ruleId); - - /// Creates an auto moderation rule. - Future createAutoModerationRule(AutoModerationRuleBuilder builder, {String? reason}); - - /// Edits an auto moderation rule. - Future editAutoModerationRule(AutoModerationRuleBuilder builder, Snowflake ruleId, {String? reason}); - - /// Deletes an auto moderation rule. - Future deleteAutoModerationRule(Snowflake ruleId, {String? reason}); - - /// Returns all active threads in the guild, including public and private threads. - /// Threads are ordered by their id, in descending order. - Future fetchActiveThreads(); -} - -class Guild extends SnowflakeEntity implements IGuild { - /// Reference to [NyxxWebsocket] instance - @override - final INyxx client; - - /// The guild's name. - @override - late final String name; - - /// The guild's icon hash. - @override - late String? icon; - - /// Splash hash - @override - late String? splash; - - /// Discovery splash hash - @override - late String? discoverySplash; - - /// System channel where system messages are sent - @override - late final Cacheable? systemChannel; - - /// enabled guild features - @override - late final Iterable features; - - /// The guild's afk channel ID, null if not set. - @override - late Cacheable? afkChannel; - - /// The channel ID for the guild's widget if enabled. - @override - late final Cacheable? embedChannel; - - /// The guild's AFK timeout. - @override - late final int afkTimeout; - - /// The guild's verification level. - @override - late final int verificationLevel; - - /// The guild's notification level. - @override - late final int notificationLevel; - - /// The guild's MFA level. - @override - late final int mfaLevel; - - /// If the guild's widget is enabled. - @override - late final bool? embedEnabled; - - /// Whether or not the guild is available. - @override - late final bool available; - - /// System Channel Flags - @override - late final int systemChannelFlags; - - /// Channel where "PUBLIC" guilds display rules and/or guidelines - @override - late final Cacheable? rulesChannel; - - /// The guild owner's ID - @override - late final Cacheable owner; - - /// The guild's members. - @override - late final SnowflakeCache members; - - /// The guild's channels. - @override - Iterable get channels => client.channels.values.where((item) => item is IGuildChannel && item.guild.id == id).cast(); - - /// The guild's roles. - @override - late final SnowflakeCache roles; - - /// Guild custom emojis - @override - late final SnowflakeCache emojis; - - /// Boost level of guild - @override - late final PremiumTier premiumTier; - - /// The number of boosts this server currently has - @override - late final int? premiumSubscriptionCount; - - /// the preferred locale of a "PUBLIC" guild used - /// in server discovery and notices from Discord; defaults to "en-US" - @override - late final String preferredLocale; - - /// the id of the channel where admins and moderators - /// of "PUBLIC" guilds receive notices from Discord - @override - late final CacheableTextChannel? publicUpdatesChannel; - - /// Permission of current(bot) user in this guild - @override - late final IPermissions? currentUserPermissions; - - /// Users state cache - @override - late final Map voiceStates; - - /// Stage instances in the guild - @override - late final Iterable stageInstances; - - /// Nsfw level of guild - @override - late final GuildNsfwLevel guildNsfwLevel; - - /// Stickers of this guild - @override - late final Iterable stickers; - - @override - late final bool boostProgressBarEnabled; - - /// The banner hash of the guild. If any. - @override - late final String? banner; - - /// List of partial presences. - /// - /// Will only include non-offline members if the size of the guild is greater than the [ClientOptions.largeThreshold] option. - @override - late final List presences; - - /// If this guild is considered large. - @override - late final bool large; - - /// The maximum amount of members that can be in this guild. - @override - late final int maximumMembers; - - /// The approximate amount of members in this guild. - @override - late final int? approxMemberCount; - - /// The approximate amount of presences in this guild. - @override - late final int? approxPresenceCount; - - /// The maximum amount of presences that can be in this guild. - @override - late final int? maximumPresences; - - /// Explicit content filter level of guild - @override - late final int explicitContentFilterLevel; - - /// The vanity URL code of the guild. If any. - @override - late final String? vanityUrlCode; - - /// The description of the guild. If it's a community guild. - @override - late final String? description; - - /// The total amount of members in the guild. - @override - late final int? memberCount; - - /// Returns url to this guild. - @override - String get url => "https://discordapp.com/guilds/${id.toString()}"; - - /// Getter for @everyone role - @override - IRole get everyoneRole => roles[id]!; - - /// Returns member object for bot user - @override - Cacheable get selfMember { - if (client is! NyxxWebsocket) { - throw UnsupportedError("Cannot use this property with NyxxRest"); - } - - return MemberCacheable(client, (client as NyxxWebsocket).self.id, GuildCacheable(client, id)); - } - - /// File upload limit for channel in bytes. - @override - int get fileUploadLimit { - const megabyte = 1024 * 1024; - - if (premiumTier == PremiumTier.tier2) { - return 50 * megabyte; - } - - if (premiumTier == PremiumTier.tier3) { - return 100 * megabyte; - } - - return 8 * megabyte; - } - - /// Returns this guilds shard - @override - IShard get shard { - if (client is! NyxxWebsocket) { - throw UnsupportedError("Cannot use this property with NyxxRest"); - } - - final shardId = (id.id >> 22) % (client as NyxxWebsocket).shardManager.shards.length; - - return (client as NyxxWebsocket).shardManager.shards.firstWhere( - (element) => element.id == shardId, - orElse: () => - throw InvalidShardException('Cannot find shard for this guild! Calculated shard id for this guild is: $shardId but no such shard exist'), - ); - } - - @override - late final ICache autoModerationRules; - - @override - late final ICache scheduledEvents; - - /// Creates an instance of [Guild] - Guild(this.client, RawApiMap raw, [bool guildCreate = false]) : super(Snowflake(raw["id"])) { - name = raw["name"] as String; - afkTimeout = raw["afk_timeout"] as int; - mfaLevel = raw["mfa_level"] as int; - verificationLevel = raw["verification_level"] as int; - notificationLevel = raw["default_message_notifications"] as int; - available = !(raw["unavailable"] as bool? ?? false); - - icon = raw["icon"] as String?; - discoverySplash = raw["discovery_splash"] as String?; - splash = raw["splash"] as String?; - embedEnabled = raw["widget_enabled"] as bool?; - - systemChannelFlags = raw["system_channel_flags"] as int; - premiumTier = PremiumTier.from(raw["premium_tier"] as int); - premiumSubscriptionCount = raw["premium_subscription_count"] as int?; - preferredLocale = raw["preferred_locale"] as String; - boostProgressBarEnabled = raw['premium_progress_bar_enabled'] as bool; - banner = raw['banner'] as String?; - large = raw["large"] as bool? ?? false; - maximumMembers = raw["max_members"] as int; - maximumPresences = raw["max_presences"] as int?; - explicitContentFilterLevel = raw["explicit_content_filter"] as int; - vanityUrlCode = raw["vanity_url_code"] as String?; - description = raw["description"] as String?; - memberCount = raw["member_count"] as int?; - approxMemberCount = raw["approximate_member_count"] as int?; - approxPresenceCount = raw["approximate_presence_count"] as int?; - - owner = UserCacheable(client, Snowflake(raw["owner_id"])); - - roles = SnowflakeCache(); - if (raw["roles"] != null) { - raw["roles"].forEach((o) { - final role = Role(client, o as RawApiMap, id); - roles[role.id] = role; - }); - } - - emojis = SnowflakeCache(); - if (raw["emojis"] != null) { - raw["emojis"].forEach((dynamic o) { - final emoji = GuildEmoji(client, o as RawApiMap, id); - emojis[emoji.id] = emoji; - }); - } - - if (raw["widget_channel_id"] != null) { - embedChannel = ChannelCacheable(client, Snowflake(raw["widget_channel_id"])); - } else { - embedChannel = null; - } - - if (raw["system_channel_id"] != null) { - systemChannel = ChannelCacheable(client, Snowflake(raw["system_channel_id"])); - } else { - systemChannel = null; - } - - features = (raw["features"] as RawApiList).map((e) => GuildFeature.from(e.toString())); - - if (raw["permissions"] != null) { - currentUserPermissions = Permissions(raw["permissions"] as int); - } else { - currentUserPermissions = null; - } - - afkChannel = raw["afk_channel_id"] != null ? ChannelCacheable(client, Snowflake(raw["afk_channel_id"])) : null; - - members = SnowflakeCache(); - voiceStates = SnowflakeCache(); - - guildNsfwLevel = GuildNsfwLevel.from(raw["nsfw_level"] as int); - - stickers = [ - if (raw["stickers"] != null) - for (final rawSticker in raw["stickers"] as RawApiList) GuildSticker(rawSticker as RawApiMap, client) - ]; - - if (!guildCreate) return; - - raw["channels"].forEach((o) { - final channel = Channel.deserialize(client, o as RawApiMap, id); - client.channels[channel.id] = channel; - }); - - if (client.cacheOptions.memberCachePolicyLocation.objectConstructor) { - raw["members"].forEach((o) { - final member = Member(client, o as RawApiMap, id); - if (client.cacheOptions.memberCachePolicy.canCache(member)) { - members[member.id] = member; - } - }); - } - - if (raw["voice_states"] != null) { - raw["voice_states"].forEach((o) { - final state = VoiceState(client, o as RawApiMap); - voiceStates[state.user.id] = state; - }); - } - - if (raw["rules_channel_id"] != null) { - rulesChannel = ChannelCacheable(client, Snowflake(raw["rules_channel_id"])); - } else { - rulesChannel = null; - } - - if (raw["public_updates_channel_id"] != null) { - publicUpdatesChannel = CacheableTextChannel(client, Snowflake(raw["public_updates_channel_id"])); - } else { - publicUpdatesChannel = null; - } - - presences = [ - if (raw['presences'] != null) - for (final presence in raw['presences'] as RawApiList) PartialPresence(presence as RawApiMap, client) - ]; - - stageInstances = [ - if (raw["stage_instances"] != null) - for (final rawInstance in raw["stage_instances"] as RawApiList) StageChannelInstance(client, rawInstance as RawApiMap) - ]; - - autoModerationRules = SnowflakeCache(); - scheduledEvents = SnowflakeCache(); - } - - /// The guild's icon, represented as URL. - /// If guild doesn't have icon it returns null. - @override - String? iconUrl({String format = 'webp', int? size, bool animated = false}) { - if (icon == null) { - return null; - } - - return client.cdnHttpEndpoints.icon(id, icon!, format: format, size: size, animated: animated); - } - - /// URL to guild's splash. - /// If guild doesn't have splash it returns null. - @override - String? splashUrl({String format = 'webp', int? size}) { - if (splash == null) { - return null; - } - - return client.cdnHttpEndpoints.splash(id, splash!, format: format, size: size); - } - - /// URL to guilds discovery splash - /// If guild doesn't have splash it returns null. - @override - String? discoveryUrl({String format = 'webp', int? size}) { - if (discoverySplash == null) { - return null; - } - - return client.cdnHttpEndpoints.discoverySplash(id, discoverySplash!, format: format, size: size); - } - - /// Allows to download [IGuild] widget aka advert png - /// Possible options for [style]: shield (default), banner1, banner2, banner3, banner4 - @override - String guildWidgetUrl([String style = "shield"]) => client.httpEndpoints.getGuildWidgetUrl(id, style); - - /// Returns the URL to guild's banner. - /// If guild doesn't have banner it returns null. - @override - String? bannerUrl({String format = 'webp', int? size, bool animated = false}) { - if (banner == null) { - return null; - } - - return client.cdnHttpEndpoints.banner(id, banner!, format: format, size: size, animated: animated); - } - - /// Fetches all stickers of current guild - @override - Stream fetchStickers() => client.httpEndpoints.fetchGuildStickers(id); - - /// Fetch sticker with given [id] - @override - Future fetchSticker(Snowflake id) => client.httpEndpoints.fetchGuildSticker(this.id, id); - - /// Fetches all roles that are in the server. - @override - Stream fetchRoles() => client.httpEndpoints.fetchGuildRoles(id); - - /// Creates sticker in current guild - @override - Future createSticker(StickerBuilder builder) => client.httpEndpoints.createGuildSticker(id, builder); - - /// Fetches emoji from API - @override - Future fetchEmoji(Snowflake emojiId) => client.httpEndpoints.fetchGuildEmoji(id, emojiId); - - /// Allows to create new guild emoji. [name] is required. You can allow to set [roles] to restrict emoji usage. - /// Put your image in [emojiAttachment] field. - /// - /// ```dart - /// var emojiFile = File("weed.png"); - /// var emoji = await guild.createEmoji("weed", emojiAttachment: AttachmentBuilder.file(emojiFile)); - /// ``` - @override - Future createEmoji(String name, {List? roles, AttachmentBuilder? emojiAttachment}) => - client.httpEndpoints.createEmoji(id, name, roles: roles, emojiAttachment: emojiAttachment); - - /// Returns [int] indicating the number of members that would be removed in a prune operation. - @override - Future pruneCount(int days, {Iterable? includeRoles}) => client.httpEndpoints.guildPruneCount(id, days, includeRoles: includeRoles); - - /// Prunes the guild, returns the amount of members pruned. - @override - Future prune(int days, {Iterable? includeRoles, String? auditReason}) => - client.httpEndpoints.guildPrune(id, days, includeRoles: includeRoles, auditReason: auditReason); - - /// Gets the guild's bans. - @override - Stream getBans({int limit = 1000, Snowflake? before, Snowflake? after}) => - client.httpEndpoints.getGuildBans(id, limit: limit, before: before, after: after); - - /// Change self nickname in guild - @override - Future modifyCurrentMember({String? nick}) async => client.httpEndpoints.modifyCurrentMember(id, nick: nick); - - /// Gets single [Ban] object for given [bannedUserId] - @override - Future getBan(Snowflake bannedUserId) async => client.httpEndpoints.getGuildBan(id, bannedUserId); - - /// Change guild owner. - @override - Future changeOwner(SnowflakeEntity memberEntity, {String? auditReason}) => - client.httpEndpoints.changeGuildOwner(id, memberEntity, auditReason: auditReason); - - /// Leaves the guild. - @override - Future leave() async => client.httpEndpoints.leaveGuild(id); - - /// Returns list of Guilds invites - @override - Stream fetchGuildInvites() => client.httpEndpoints.fetchGuildInvites(id); - - /// Returns Audit logs. - /// https://discordapp.com/developers/docs/resources/audit-log - /// - /// ```dart - /// var logs = await guild.fetchAuditLogs(auditType: AuditLogEntryType.guildUpdate); - /// ``` - @override - Future fetchAuditLogs({Snowflake? userId, AuditLogEntryType? auditType, Snowflake? before, int? limit}) => - client.httpEndpoints.fetchAuditLogs(id, userId: userId, auditType: auditType, before: before, limit: limit); - - /// Creates new role - /// - /// ```dart - /// var rb = RoleBuilder("Dartyy") - /// ..color = DiscordColor.fromInt(0xFF04F2) - /// ..hoist = true; - /// - /// var role = await guild.createRole(roleBuilder); - /// ``` - @override - Future createRole(RoleBuilder roleBuilder, {String? auditReason}) => client.httpEndpoints.createGuildRole(id, roleBuilder, auditReason: auditReason); - - /// Returns list of available [VoiceRegion]s - @override - Stream getVoiceRegions() => client.httpEndpoints.fetchGuildVoiceRegions(id); - - /// Moves the [channel] for the given [position]. - @override - Future moveChannel(IChannel channel, int position, {String? auditReason}) => - client.httpEndpoints.moveGuildChannel(id, channel.id, position, auditReason: auditReason); - - /// Bans a user and allows to delete messages from [deleteMessageDays] number of days. - /// ```dart - /// await guild.ban(member); - /// ``` - @override - Future ban(SnowflakeEntity user, {int deleteMessageDays = 0, String? auditReason}) => - client.httpEndpoints.guildBan(id, user.id, deleteMessageDays: deleteMessageDays, auditReason: auditReason); - - /// Kicks user from guild. Member is removed from guild and they're able to rejoin if they have a valid invite link. - /// - /// ```dart - /// await guild.kick(member); - /// ``` - @override - Future kick(SnowflakeEntity user, {String? auditReason}) => client.httpEndpoints.guildKick(id, user.id, auditReason: auditReason); - - /// Unbans a user by ID. - @override - Future unban(Snowflake userId) => client.httpEndpoints.guildUnban(id, userId); - - /// Edits the guild. - @override - Future edit(GuildBuilder builder, {String? auditReason}) => client.httpEndpoints.editGuild(id, builder, auditReason: auditReason); - - /// Fetches member from API - @override - Future fetchMember(Snowflake memberId) => client.httpEndpoints.fetchGuildMember(id, memberId); - - /// Allows to fetch guild members. In future will be restricted with `Privileged Intents`. - /// [after] is used to continue from specified user id. - /// By default limits to one user - use [limit] parameter to change that behavior. - @override - Stream fetchMembers({int limit = 1, Snowflake? after}) => client.httpEndpoints.fetchGuildMembers(id, limit: limit, after: after); - - /// Returns a [Stream] of [IMember]s objects whose username or nickname starts with a provided string. - /// By default limits to one entry - can be changed with [limit] parameter. - @override - Stream searchMembers(String query, {int limit = 1}) => client.httpEndpoints.searchGuildMembers(id, query, limit: limit); - - /// Returns a [Stream] of [IMember]s objects whose username or nickname starts with a provided string. - /// By default limits to one entry - can be changed with [limit] parameter. - @override - Stream searchMembersGateway(String query, {int limit = 0}) async* { - final nonce = "$query${id.toString()}"; - shard.requestMembers(id, query: query, limit: limit, nonce: nonce); - - final first = (await shard.onMemberChunk.take(1).toList()).first; - - for (final member in first.members) { - yield member; - } - - if (first.chunkCount > 1) { - await for (final event in shard.onMemberChunk.where((event) => event.nonce == nonce).take(first.chunkCount - 1)) { - for (final member in event.members) { - yield member; - } - } - } - } - - /// Fetches guild preview for this guild. Allows to download approx member count in guild - @override - Future fetchGuildPreview() async => client.httpEndpoints.fetchGuildPreview(id); - - /// Request members from gateway. Requires privileged intents in order to work. - @override - void requestChunking() => shard.requestMembers(id); - - /// Allows to create new guild channel - @override - Future createChannel(ChannelBuilder channelBuilder) => client.httpEndpoints.createGuildChannel(id, channelBuilder); - - /// Deletes the guild. - @override - Future delete() => client.httpEndpoints.deleteGuild(id); - - @override - Future createGuildEvent(GuildEventBuilder builder) => client.httpEndpoints.createGuildEvent(id, builder); - - @override - Future fetchGuildEvent(Snowflake guildEventId) => client.httpEndpoints.fetchGuildEvent(id, guildEventId); - - @override - Stream fetchGuildEvents({bool withUserCount = false}) => client.httpEndpoints.fetchGuildEvents(id); - - @override - Future fetchWelcomeScreen() => client.httpEndpoints.fetchGuildWelcomeScreen(id); - - @override - Stream fetchAutoModerationRules() => client.httpEndpoints.fetchAutoModerationRules(id); - - @override - Future fetchAutoModerationRule(Snowflake ruleId) => client.httpEndpoints.fetchAutoModerationRule(id, ruleId); - - @override - Future createAutoModerationRule(AutoModerationRuleBuilder builder, {String? reason}) => - client.httpEndpoints.createAutoModerationRule(id, builder, auditReason: reason); - - @override - Future editAutoModerationRule(AutoModerationRuleBuilder builder, Snowflake ruleId, {String? reason}) => - client.httpEndpoints.editAutoModerationRule(id, ruleId, builder, auditReason: reason); - - @override - Future deleteAutoModerationRule(Snowflake ruleId, {String? reason}) => client.httpEndpoints.deleteAutoModerationRule(id, ruleId, auditReason: reason); - - @override - Future fetchActiveThreads() => client.httpEndpoints.fetchGuildActiveThreads(id); -} diff --git a/lib/src/core/guild/guild_feature.dart b/lib/src/core/guild/guild_feature.dart deleted file mode 100644 index b1dfcce72..000000000 --- a/lib/src/core/guild/guild_feature.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -/// Guild features -class GuildFeature extends IEnum { - /// Guild has Auto Moderation - static const GuildFeature autoModeration = GuildFeature._create("AUTO_MODERATION"); - - /// Guild has access to set an animated guild icon - static const GuildFeature animatedIcon = GuildFeature._create("ANIMATED_ICON"); - - /// Guild has access to set an animated guild banner image - static const GuildFeature animatedBanner = GuildFeature._create('ANIMATED_BANNER'); - - /// Guild has access to set a guild banner image - static const GuildFeature banner = GuildFeature._create("BANNER"); - - /// Guild can enable welcome screen, Membership Screening, stage channels and discovery, and receives community updates - static const GuildFeature community = GuildFeature._create('COMMUNITY'); - - /// Guild is able to be discovered in the directory - static const GuildFeature discoverable = GuildFeature._create("DISCOVERABLE"); - - /// Guild is able to be featured in the directory - static const GuildFeature featurable = GuildFeature._create('FEATURABLE'); - - /// Guild has paused invites, preventing new users from joining - static const GuildFeature invitesDisabled = GuildFeature._create('INVITES_DISABLED'); - - /// Guild has access to set an invite splash background - static const GuildFeature inviteSplash = GuildFeature._create("INVITE_SPLASH"); - - /// Guild has enabled [Membership Screening](https://discord.com/developers/docs/resources/guild#membership-screening-object) - static const GuildFeature memberVerificationGateEnabled = GuildFeature._create('MEMBER_VERIFICATION_GATE_ENABLED'); - - /// Guild has enabled monetization - static const GuildFeature monetizationEnabled = GuildFeature._create("MONETIZATION_ENABLED"); - - /// Guild has increased custom sticker slots - static const GuildFeature moreStickers = GuildFeature._create("MORE_STICKERS"); - - /// Guild has access to create news channels - static const GuildFeature news = GuildFeature._create("NEWS"); - - /// Guild is partnered - static const GuildFeature partnered = GuildFeature._create("PARTNERED"); - - /// Guild can be previewed before joining via Membership Screening or the directory - static const GuildFeature previewEnabled = GuildFeature._create('PREVIEW_ENABLED'); - - /// Guild has access to create private threads - static const GuildFeature privateThreadsEnabled = GuildFeature._create("PRIVATE_THREADS"); - - /// Guild is able to set role icons - static const GuildFeature roleIcons = GuildFeature._create('ROLE_ICONS'); - - /// Guild has enabled ticketed events - static const GuildFeature ticketsEventEnabled = GuildFeature._create("TICKETED_EVENTS_ENABLED"); - - /// Guild has access to set a vanity URL - static const GuildFeature vanityUrl = GuildFeature._create("VANITY_URL"); - - /// Guild is verified - static const GuildFeature verified = GuildFeature._create("VERIFIED"); - - /// Guild has access to set 384kbps bitrate in voice (previously VIP voice servers) - static const GuildFeature vipRegions = GuildFeature._create("VIP_REGIONS"); - - /// Guild has enabled the welcome screen - static const GuildFeature welcomeScreenEnabled = GuildFeature._create("WELCOME_SCREEN_ENABLED"); - - /// Guild has access to use commerce features (i.e. create store channels) - /// Discord no longer offers the ability to purchase a license to sell PC games. - /// See https://support-dev.discord.com/hc/en-us/articles/6309018858647-Self-serve-Game-Selling-Deprecation for more information - static const GuildFeature commerce = GuildFeature._create("COMMERCE"); - - /// Guild cannot be public - No longer has meaning - static const GuildFeature publicDisabled = GuildFeature._create("PUBLIC_DISABLED"); - - /// Guild is a Student Hub - Was not documented but exists, this can be removed at any time - static const GuildFeature studentHub = GuildFeature._create("HUB"); - - /// Creates instance of [GuildFeature] from [value]. - GuildFeature.from(String? value) : super(value ?? ""); - const GuildFeature._create(String? value) : super(value ?? ""); - - @override - bool operator ==(dynamic other) { - if (other is String) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/guild/guild_nsfw_level.dart b/lib/src/core/guild/guild_nsfw_level.dart deleted file mode 100644 index 2d6f8d84f..000000000 --- a/lib/src/core/guild/guild_nsfw_level.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -class GuildNsfwLevel extends IEnum { - static const GuildNsfwLevel def = GuildNsfwLevel._create(0); - static const GuildNsfwLevel explicit = GuildNsfwLevel._create(1); - static const GuildNsfwLevel safe = GuildNsfwLevel._create(2); - static const GuildNsfwLevel ageRestricted = GuildNsfwLevel._create(3); - - const GuildNsfwLevel._create(int value) : super(value); - - /// Create [StageChannelInstancePrivacyLevel] from [value] - GuildNsfwLevel.from(int value) : super(value); -} diff --git a/lib/src/core/guild/guild_preview.dart b/lib/src/core/guild/guild_preview.dart deleted file mode 100644 index e0e559e97..000000000 --- a/lib/src/core/guild/guild_preview.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/guild/guild_feature.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IGuildPreview implements SnowflakeEntity { - /// Reference to client - INyxx get client; - - /// Guild name - String get name; - - /// Hash of guild icon. To get url use [iconUrl] - String? get iconHash; - - /// Hash of guild spash image. To get url use [splashUrl] - String? get splashHash; - - /// Hash of guild discovery image. To get url use [discoveryUrl] - String? get discoveryHash; - - /// List of guild's emojis - List get emojis; - - /// List of guild's features - Iterable get features; - - /// Approximate number of members in this guild - int get approxMemberCount; - - /// Approximate number of online members in this guild - int get approxOnlineMembers; - - /// The description for the guild - String? get description; - - /// The guild's icon, represented as URL. - /// If guild doesn't have icon it returns null. - String? iconUrl({String format = 'webp', int? size, bool animated = false}); - - /// URL to guild's splash. - /// If guild doesn't have splash it returns null. - String? splashUrl({String format = 'webp', int? size}); - - /// URL to guild's splash. - /// If guild doesn't have discovery it returns null. - String? discoveryUrl({String format = 'webp', int? size}); -} - -/// Returns guild even if the user is not in the guild. -/// This endpoint is only for Public guilds. -class GuildPreview extends SnowflakeEntity implements IGuildPreview { - /// Reference to client - @override - final INyxx client; - - /// Guild name - @override - late final String name; - - /// Hash of guild icon. To get url use [iconUrl] - @override - String? iconHash; - - /// Hash of guild spash image. To get url use [splashUrl] - @override - String? splashHash; - - /// Hash of guild discovery image. To get url use [discoveryUrl] - @override - String? discoveryHash; - - /// List of guild's emojis - @override - late final List emojis; - - /// List of guild's features - @override - late final Iterable features; - - /// Approximate number of members in this guild - @override - late final int approxMemberCount; - - /// Approximate number of online members in this guild - @override - late final int approxOnlineMembers; - - /// The description for the guild - @override - String? description; - - /// Creates an instance of [GuildPreview] - GuildPreview(this.client, RawApiMap raw) : super(Snowflake(raw["id"])) { - name = raw["name"] as String; - - iconHash = raw["icon"] as String?; - - splashHash = raw["splash"] as String?; - - discoveryHash = raw["discovery_splash"] as String?; - - emojis = [for (final rawEmoji in raw["emojis"] as RawApiList) GuildEmoji(client, rawEmoji as RawApiMap, id)]; - - features = (raw["features"] as RawApiList).map((e) => GuildFeature.from(e.toString())); - - approxMemberCount = raw["approximate_member_count"] as int; - approxOnlineMembers = raw["approximate_presence_count"] as int; - - description = raw["description"] as String?; - } - - /// The guild's icon, represented as URL. - /// If guild doesn't have icon it returns null. - @override - String? iconUrl({String format = 'webp', int? size, bool animated = false}) { - if (iconHash == null) { - return null; - } - - return client.cdnHttpEndpoints.icon(id, iconHash!, format: format, size: size, animated: animated); - } - - /// URL to guild's splash. - /// If guild doesn't have splash it returns null. - @override - String? splashUrl({String format = 'webp', int? size}) { - if (splashHash == null) { - return null; - } - - return client.cdnHttpEndpoints.splash(id, splashHash!, format: format, size: size); - } - - /// URL to guild's splash. - /// If guild doesn't have splash it returns null. - @override - String? discoveryUrl({String format = 'webp', int? size}) { - if (discoveryHash == null) { - return null; - } - - return client.cdnHttpEndpoints.discoverySplash(id, discoveryHash!, format: format, size: size); - } -} diff --git a/lib/src/core/guild/guild_welcome_screen.dart b/lib/src/core/guild/guild_welcome_screen.dart deleted file mode 100644 index 3d19fbe20..000000000 --- a/lib/src/core/guild/guild_welcome_screen.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/message/unicode_emoji.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IGuildWelcomeScreen { - /// The server description shown in the welcome screen. - String? get description; - - /// The channels shown in the welcome screen. - /// Up to 5 channels. - List get channels; -} - -class GuildWelcomeScreen implements IGuildWelcomeScreen { - /// The server description shown in the welcome screen. - @override - late final String? description; - - /// The channels shown in the welcome screen. - /// Up to 5 channels. - @override - late final List channels; - - /// Creates an instance of [GuildWelcomeScreen] - GuildWelcomeScreen(RawApiMap raw, INyxx client) { - description = raw["description"] as String?; - channels = [for (final rawChannel in raw["welcome_channels"] as RawApiList) GuildWelcomeChannel(rawChannel as RawApiMap, client)]; - } -} - -abstract class IGuildWelcomeChannel { - /// The channel of this welcome screen. - Cacheable get channel; - - /// The description shown for the channel. - String? get description; - - /// The emoji id if [emojiName] is a custom emoji. - Snowflake? get emojiId; - - /// The name of the emoji if custom, otherwise the unicode character. - /// Or `null` if no emoji is set. - String? get emojiName; - - /// The emoji in the channel. - /// This can be a [UnicodeEmoji] or a [IResolvableGuildEmojiPartial] - IEmoji? get emoji; -} - -class GuildWelcomeChannel implements IGuildWelcomeChannel { - /// The channel of this welcome screen. - @override - late final Cacheable channel; - - /// The description shown for the channel. - @override - late final String? description; - - /// The emoji id if [emojiName] is a custom emoji. - @override - late final Snowflake? emojiId; - - /// The name of the emoji if custom, otherwise the unicode character. - /// Or `null` if no emoji is set. - @override - late final String? emojiName; - - /// The emoji in the channel. - /// This can be a [UnicodeEmoji] or a [IResolvableGuildEmojiPartial] - @override - late final IEmoji? emoji; - - /// Creates an instance of [GuildWelcomeChannel] - GuildWelcomeChannel(RawApiMap raw, INyxx client) { - channel = ChannelCacheable(client, Snowflake(raw["channel_id"])); - description = raw["description"] as String?; - emojiName = raw["emoji_name"] as String?; - - if (raw['emoji_id'] != null) { - emojiId = Snowflake(raw['emoji_id']); - // Used because ResolvableEmoji takes a map and not a sole id - emoji = ResolvableGuildEmojiPartial({'id': emojiId}, client); - } else { - emoji = UnicodeEmoji(emojiName!); - } - } -} diff --git a/lib/src/core/guild/premium_tier.dart b/lib/src/core/guild/premium_tier.dart deleted file mode 100644 index 8b10b2356..000000000 --- a/lib/src/core/guild/premium_tier.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -/// Boost level of guild -class PremiumTier extends IEnum { - static const PremiumTier none = PremiumTier._create(0); - static const PremiumTier tier1 = PremiumTier._create(1); - static const PremiumTier tier2 = PremiumTier._create(2); - static const PremiumTier tier3 = PremiumTier._create(3); - - const PremiumTier._create(int? value) : super(value ?? 0); - PremiumTier.from(int? value) : super(value ?? 0); - - @override - bool operator ==(dynamic other) { - if (other is int) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/guild/role.dart b/lib/src/core/guild/role.dart deleted file mode 100644 index d8783c66a..000000000 --- a/lib/src/core/guild/role.dart +++ /dev/null @@ -1,213 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/discord_color.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/permissions/permissions.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/internal/interfaces/mentionable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/guild_builder.dart'; - -abstract class IRole implements SnowflakeEntity, Mentionable { - /// Reference to client - INyxx get client; - - /// Cacheable or guild attached to this role instance - Cacheable get guild; - - /// The role's name. - String get name; - - /// The role's color, 0 if no color. - DiscordColor get color; - - /// The role's position. - int get position; - - /// If the role is pinned in the user listing. - bool get hoist; - - /// Whether or not the role is managed by an integration. - bool get managed; - - /// Whether or not the role is mentionable. - bool get mentionable; - - /// The role's permissions. - IPermissions get permissions; - - /// Additional role data like if role is managed by integration or role is from server boosting. - IRoleTags? get roleTags; - - /// Hash of role icon - String? get iconHash; - - /// Emoji that represents role. - /// For now emoji data is not validated and this can be any arbitrary string - String? get iconEmoji; - - /// Mention of role. If role cannot be mentioned it returns name of role (@name) - @override - String get mention; - - /// Returns URL to role icon. - String? iconUrl({String format = 'webp', int? size}); - - /// Edits the role. - Future edit(RoleBuilder role, {String? auditReason}); - - /// Deletes the role. - Future delete(); -} - -class Role extends SnowflakeEntity implements IRole { - /// Reference to client - @override - final INyxx client; - - /// Cacheable or guild attached to this role instance - @override - late final Cacheable guild; - - /// The role's name. - @override - late final String name; - - /// The role's color, 0 if no color. - @override - late final DiscordColor color; - - /// The role's position. - @override - late final int position; - - /// If the role is pinned in the user listing. - @override - late final bool hoist; - - /// Whether or not the role is managed by an integration. - @override - late final bool managed; - - /// Whether or not the role is mentionable. - @override - late final bool mentionable; - - /// The role's permissions. - @override - late final IPermissions permissions; - - /// Additional role data like if role is managed by integration or role is from server boosting. - @override - late final IRoleTags? roleTags; - - /// Hash of role icon - @override - late final String? iconHash; - - /// Emoji that represents role. - /// For now emoji data is not validated and this can be any arbitrary string - @override - late final String? iconEmoji; - - /// Mention of role. If role cannot be mentioned it returns name of role (@name) - @override - String get mention { - String mentionString; - - if (mentionable) { - if (id == guild.id) { - mentionString = name; - } else { - mentionString = '<@&$id>'; - } - } else { - if (id == guild.id) { - mentionString = name; - } else { - mentionString = '@$name'; - } - } - - return mentionString; - } - - /// Creates an instance of [Role] - Role(this.client, RawApiMap raw, Snowflake guildId) : super(Snowflake(raw["id"])) { - name = raw["name"] as String; - position = raw["position"] as int; - hoist = raw["hoist"] as bool; - managed = raw["managed"] as bool; - mentionable = raw["mentionable"] as bool? ?? false; - permissions = Permissions(int.parse(raw["permissions"] as String)); - color = DiscordColor.fromInt(raw["color"] as int); - guild = GuildCacheable(client, guildId); - iconEmoji = raw["unicode_emoji"] as String?; - iconHash = raw["icon"] as String?; - - if (raw["tags"] != null) { - roleTags = RoleTags(raw["tags"] as RawApiMap); - } else { - roleTags = null; - } - } - - /// Returns url to role icon - @override - String? iconUrl({String format = 'webp', int? size}) { - if (iconHash == null) { - return null; - } - - return client.cdnHttpEndpoints.roleIcon(id, iconHash!, format: format, size: size); - } - - /// Edits the role. - @override - Future edit(RoleBuilder role, {String? auditReason}) async => client.httpEndpoints.editRole(guild.id, id, role, auditReason: auditReason); - - /// Deletes the role. - @override - Future delete() async => client.httpEndpoints.deleteRole(guild.id, id); -} - -abstract class IRoleTags { - /// Holds [Snowflake] of bot id if role is for bot user - Snowflake? get botId; - - /// True if role is for server nitro boosting - bool get nitroRole; - - /// Holds [Snowflake] of integration if role is part of twitch/other integration - Snowflake? get integrationId; - - /// Returns true if role is for bot. - bool get isBotRole; -} - -/// Additional [Role] role tags which hold optional data about role -class RoleTags implements IRoleTags { - /// Holds [Snowflake] of bot id if role is for bot user - @override - late final Snowflake? botId; - - /// True if role is for server nitro boosting - @override - late final bool nitroRole; - - /// Holds [Snowflake] of integration if role is part of twitch/other integration - @override - late final Snowflake? integrationId; - - /// Returns true if role is for bot. - @override - bool get isBotRole => botId != null; - - /// Creates an instance of [RoleTags] - RoleTags(RawApiMap raw) { - botId = raw["bot_id"] != null ? Snowflake(raw["bot_id"]) : null; - nitroRole = raw["premium_subscriber"] != null ? raw["premium_subscriber"] as bool : false; - integrationId = raw["integration_id"] != null ? Snowflake(raw["integration_id"]) : null; - } -} diff --git a/lib/src/core/guild/scheduled_event.dart b/lib/src/core/guild/scheduled_event.dart deleted file mode 100644 index 6c1d36905..000000000 --- a/lib/src/core/guild/scheduled_event.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'package:nyxx/src/core/channel/guild/voice_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/guild_event_builder.dart'; -import 'package:nyxx/src/utils/enum.dart'; - -/// A representation of a scheduled event in a guild. -abstract class IGuildEvent implements SnowflakeEntity { - /// Reference to [INyxx] - INyxx get client; - - /// The guild id which the scheduled event belongs to - Cacheable get guild; - - /// The id of the user that created the scheduled event - Cacheable? get creatorId; - - /// The channel id in which the scheduled event will be hosted, or null if scheduled entity type is EXTERNAL - Cacheable? get channel; - - /// The user that created the scheduled event - IUser? get creator; - - /// The name of the scheduled event - String get name; - - /// The description of the scheduled event - String? get description; - - /// The time the scheduled event will start - DateTime get startDate; - - /// The time the scheduled event will start - DateTime? get endDate; - - /// The privacy level of the scheduled event - GuildEventPrivacyLevel get privacyLevel; - - /// The status of the scheduled event - GuildEventStatus get status; - - /// The type of the scheduled event - GuildEventType get type; - - /// The id of an entity associated with a guild scheduled event - Snowflake? get entityId; - - /// The number of users subscribed to the scheduled event - int? get userCount; - - /// Additional metadata for the guild scheduled event - IEntityMetadata? get metadata; - - /// The cover image hash. - String? get image; - - /// Deletes guild event - Future delete(); - - /// Allows editing guild event details and transitioning event between states - Future edit(GuildEventBuilder builder); - - /// Allows getting users that are taking part in event - Stream fetchUsers({int limit = 100, bool withMember = false, Snowflake? before, Snowflake? after}); - - /// Returns URL to the cover, with given [format] and [size]. - String? coverUrl({String format = 'webp', int? size}); -} - -class GuildEvent extends SnowflakeEntity implements IGuildEvent { - @override - final INyxx client; - - @override - late final IUser? creator; - - @override - late final Cacheable? creatorId; - - @override - late final Cacheable? channel; - - @override - late final String? description; - - @override - late final Snowflake? entityId; - - @override - late final Cacheable guild; - - @override - late final String name; - - @override - late final GuildEventPrivacyLevel privacyLevel; - - @override - late final DateTime startDate; - - @override - late final DateTime? endDate; - - @override - late final GuildEventStatus status; - - @override - late final GuildEventType type; - - @override - late final int? userCount; - - @override - late final IEntityMetadata? metadata; - - @override - late final String? image; - - GuildEvent(RawApiMap raw, this.client) : super(Snowflake(raw['id'])) { - creator = raw['creator'] != null ? User(client, raw['creator'] as RawApiMap) : null; - creatorId = raw['creator_id'] != null ? UserCacheable(client, Snowflake(raw['creator_id'])) : null; - entityId = raw['entity_id'] != null ? Snowflake(raw['entity_id']) : null; - description = raw['description'] as String?; - guild = GuildCacheable(client, Snowflake(raw['guild_id'])); - name = raw['name'] as String; - privacyLevel = GuildEventPrivacyLevel.from(raw['privacy_level'] as int); - startDate = DateTime.parse(raw['scheduled_start_time'] as String); - status = GuildEventStatus.from(raw['status'] as int); - type = GuildEventType.from(raw['entity_type'] as int); - userCount = raw['user_count'] as int?; - - channel = raw['channel_id'] != null ? ChannelCacheable(client, Snowflake(raw['channel_id'])) : null; - - endDate = raw['scheduled_end_time'] != null ? DateTime.parse(raw['scheduled_end_time'] as String) : null; - - metadata = raw['entity_metadata'] != null ? EntityMetadata(raw['entity_metadata'] as RawApiMap) : null; - image = raw['image'] as String?; - } - - @override - Future delete() => client.httpEndpoints.deleteGuildEvent(guild.id, id); - - @override - Future edit(GuildEventBuilder builder) => client.httpEndpoints.editGuildEvent(guild.id, id, builder); - - @override - Stream fetchUsers({int limit = 100, bool withMember = false, Snowflake? before, Snowflake? after}) => - client.httpEndpoints.fetchGuildEventUsers(guild.id, id, limit: limit, withMember: withMember, before: before, after: after); - - @override - String? coverUrl({String format = 'webp', int? size}) { - if (image == null) { - return null; - } - - return client.cdnHttpEndpoints.guildEventCoverImage(id, image!, format: format, size: size); - } -} - -abstract class IEntityMetadata { - /// Location of the event - String get location; -} - -class EntityMetadata implements IEntityMetadata { - @override - late final String location; - - EntityMetadata(RawApiMap raw) { - location = raw['location'] as String; - } -} - -abstract class IGuildEventUser { - /// The scheduled event id which the user subscribed to - Snowflake get scheduledEventId; - - /// User which subscribed to an event - IUser get user; - - /// Guild member data for this user for the guild which this event belongs to, if any - IMember? get member; -} - -class GuildEventUser implements IGuildEventUser { - @override - late final Snowflake scheduledEventId; - - @override - late final IUser user; - - @override - late final IMember? member; - - GuildEventUser(RawApiMap raw, INyxx client, Snowflake guildId) { - scheduledEventId = Snowflake(raw['guild_scheduled_event_id']); - user = User(client, raw['user'] as RawApiMap); - member = raw['member'] != null ? Member(client, raw['member'] as RawApiMap, guildId) : null; - } -} - -class GuildEventPrivacyLevel extends IEnum { - static const guildOnly = GuildEventPrivacyLevel(2); - - const GuildEventPrivacyLevel(int value) : super(value); - GuildEventPrivacyLevel.from(int value) : super(value); -} - -class GuildEventStatus extends IEnum { - static const scheduled = GuildEventStatus(1); - static const active = GuildEventStatus(2); - static const completed = GuildEventStatus(3); - static const canceled = GuildEventStatus(4); - - const GuildEventStatus(int value) : super(value); - GuildEventStatus.from(int value) : super(value); -} - -class GuildEventType extends IEnum { - static const stage = GuildEventType(1); - static const voice = GuildEventType(2); - static const external = GuildEventType(3); - - const GuildEventType(int value) : super(value); - GuildEventType.from(int value) : super(value); -} diff --git a/lib/src/core/guild/status.dart b/lib/src/core/guild/status.dart deleted file mode 100644 index 21b03ed38..000000000 --- a/lib/src/core/guild/status.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/enum.dart'; - -/// Provides values for user status. -class UserStatus extends IEnum { - static const UserStatus dnd = UserStatus._create("dnd"); - static const UserStatus offline = UserStatus._create("offline"); - static const UserStatus online = UserStatus._create("online"); - static const UserStatus idle = UserStatus._create("idle"); - - /// Creates instance of [UserStatus] from [value]. - UserStatus.from(String? value) : super(value ?? "offline"); - const UserStatus._create(String? value) : super(value ?? "offline"); - - /// Returns if user is online - bool get isOnline => this != UserStatus.offline; - - @override - String toString() => value; - - @override - bool operator ==(dynamic other) { - if (other is String) { - return other.toString() == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} - -abstract class IClientStatus { - /// The user's status set for an active desktop (Windows, Linux, Mac) application session - UserStatus get desktop; - - /// The user's status set for an active mobile (iOS, Android) application session - UserStatus get web; - - /// The user's status set for an active web (browser, bot account) application session - UserStatus get phone; - - /// Returns if user is online - bool get isOnline; -} - -/// Provides status of user on different devices -class ClientStatus implements IClientStatus { - /// The user's status set for an active desktop (Windows, Linux, Mac) application session - @override - late final UserStatus desktop; - - /// The user's status set for an active mobile (iOS, Android) application session - @override - late final UserStatus web; - - /// The user's status set for an active web (browser, bot account) application session - @override - late final UserStatus phone; - - /// Returns if user is online - @override - bool get isOnline => desktop.isOnline || phone.isOnline || web.isOnline; - - /// Creates an instance of [ClientStatus] - ClientStatus(RawApiMap raw) { - desktop = UserStatus.from(raw["desktop"] as String?); - web = UserStatus.from(raw["web"] as String?); - phone = UserStatus.from(raw["phone"] as String?); - } - - @override - int get hashCode => desktop.hashCode * web.hashCode * phone.hashCode; - - @override - bool operator ==(other) { - if (other is ClientStatus) { - return other.desktop == desktop && other.phone == phone && other.web == web; - } - - return false; - } -} diff --git a/lib/src/core/guild/system_channel_flags.dart b/lib/src/core/guild/system_channel_flags.dart deleted file mode 100644 index cbb4d76ec..000000000 --- a/lib/src/core/guild/system_channel_flags.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -class SystemChannelFlags extends IEnum { - static const suppressJoinNotifications = SystemChannelFlags._create(1 << 0); - static const suppressPremiumSubscriptions = SystemChannelFlags._create(1 << 1); - static const suppressGuildReminderNotifications = SystemChannelFlags._create(1 << 2); - static const suppressJoinNotificationReplies = SystemChannelFlags._create(1 << 3); - - const SystemChannelFlags._create(int? value) : super(value ?? 0); - SystemChannelFlags.from(int? value) : super(value ?? 0); - - @override - bool operator ==(dynamic other) { - if (other is int) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/guild/webhook.dart b/lib/src/core/guild/webhook.dart deleted file mode 100644 index 10883d104..000000000 --- a/lib/src/core/guild/webhook.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/internal/interfaces/message_author.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/enum.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; - -/// Type of webhook. Either [incoming] if it its normal webhook executable with token, -/// or [channelFollower] if its discord internal webhook -class WebhookType extends IEnum { - /// Incoming Webhooks can post messages to channels with a generated token - static const WebhookType incoming = WebhookType._create(1); - - /// Channel Follower Webhooks are internal webhooks used with Channel Following to post new messages into channels - static const WebhookType channelFollower = WebhookType._create(2); - - /// Creates instance of [WebhookType] from [value]. Default value is 0 - WebhookType.from(int? value) : super(value ?? 0); - const WebhookType._create(int? value) : super(value ?? 0); - - @override - bool operator ==(dynamic other) { - if (other is int) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} - -///Webhooks are a low-effort way to post messages to channels in Discord. -///They do not require a bot user or authentication to use. -abstract class IWebhook implements SnowflakeEntity, IMessageAuthor { - /// The webhook's name. - String? get name; - - /// The webhook's token. Defaults to empty string - String get token; - - /// The webhook's channel, if this is accessed using a normal client and the client has that channel in it's cache. - ICacheableTextChannel? get channel; - - /// The webhook's guild, if this is accessed using a normal client and the client has that guild in it's cache. - Cacheable? get guild; - - /// The user, if this is accessed using a normal client. - IUser? get user; - - /// Webhook type - WebhookType? get type; - - /// Webhooks avatar hash - String? get avatarHash; - - /// Default webhook avatar id - int get defaultAvatarId; - - /// Reference to [NyxxWebsocket] object - INyxx get client; - - /// Executes webhook. - /// - /// [wait] - waits for server confirmation of message send before response, - /// and returns the created message body (defaults to false; when false a message that is not save does not return an error) - /// [threadId] is the id of thread in the channel to send to. - /// If [threadName] is specified, this will create a thread in the forum channel with the given name - **this is only available for forum channels.** - Future execute(MessageBuilder builder, {bool wait = true, Snowflake? threadId, String? threadName, String? avatarUrl, String? username}); - - @override - String avatarUrl({String format = 'webp', int? size, bool animated = false}); - - /// Edits the webhook. - Future edit({String? name, SnowflakeEntity? channel, AttachmentBuilder? avatarAttachment, String? auditReason}); - - /// Deletes the webhook. - Future delete({String? auditReason}); -} - -///Webhooks are a low-effort way to post messages to channels in Discord. -///They do not require a bot user or authentication to use. -class Webhook extends SnowflakeEntity implements IWebhook { - /// The webhook's name. - @override - late final String? name; - - /// The webhook's token. Defaults to empty string - @override - late final String token; - - /// The webhook's channel, if this is accessed using a normal client and the client has that channel in it's cache. - @override - late final ICacheableTextChannel? channel; - - /// The webhook's guild, if this is accessed using a normal client and the client has that guild in it's cache. - @override - late final Cacheable? guild; - - /// The user, if this is accessed using a normal client. - @override - late final IUser? user; - - /// Webhook type - @override - late final WebhookType? type; - - /// Webhooks avatar hash - @override - late final String? avatarHash; - - /// Default webhook avatar id - @override - int get defaultAvatarId => 0; - - @override - String get username => name.toString(); - - @override - late final int discriminator; - - @override - bool get bot => true; - - @override - String get tag => ""; - - /// Reference to [NyxxWebsocket] object - @override - final INyxx client; - - @override - bool get isInteractionWebhook => discriminator != -1; - - @override - String get formattedDiscriminator => discriminator.toString().padLeft(4, "0"); - - /// Creates an instance of [Webhook] - Webhook(RawApiMap raw, this.client) : super(Snowflake(raw["id"] as String)) { - name = raw["name"] as String? ?? raw['username'] as String?; - token = raw["token"] as String? ?? ""; - avatarHash = raw["avatar"] as String?; - discriminator = int.tryParse(raw['discriminator'] as String? ?? '-1') ?? -1; - - if (raw["type"] != null) { - type = WebhookType.from(raw["type"] as int); - } else { - type = null; - } - - if (raw["channel_id"] != null) { - channel = CacheableTextChannel(client, Snowflake(raw["channel_id"]), ChannelType.text); - } else { - channel = null; - } - - if (raw["guild_id"] != null) { - guild = GuildCacheable(client, Snowflake(raw["guild_id"] as String)); - } else { - guild = null; - } - - if (raw["user"] != null) { - user = User(client, raw["user"] as RawApiMap); - } else { - user = null; - } - } - - /// Executes webhook. - /// - /// [wait] - waits for server confirmation of message send before response, - /// and returns the created message body (defaults to false; when false a message that is not save does not return an error) - @override - Future execute(MessageBuilder builder, {bool wait = true, Snowflake? threadId, String? threadName, String? avatarUrl, String? username}) => - client.httpEndpoints - .executeWebhook(id, builder, token: token, threadId: threadId, username: username, wait: wait, avatarUrl: avatarUrl, threadName: threadName); - - @override - String avatarUrl({String format = 'webp', int? size, bool animated = false}) { - if (avatarHash == null) { - return client.cdnHttpEndpoints.defaultAvatar(defaultAvatarId); - } - - return client.cdnHttpEndpoints.avatar(id, avatarHash!, format: format, size: size, animated: animated); - } - - /// Edits the webhook. - @override - Future edit({String? name, SnowflakeEntity? channel, AttachmentBuilder? avatarAttachment, String? auditReason}) => - client.httpEndpoints.editWebhook(id, token: token, name: name, channel: channel, avatarAttachment: avatarAttachment, auditReason: auditReason); - - /// Deletes the webhook. - @override - Future delete({String? auditReason}) => client.httpEndpoints.deleteWebhook(id, token: token, auditReason: auditReason); -} diff --git a/lib/src/core/message/attachment.dart b/lib/src/core/message/attachment.dart deleted file mode 100644 index 6a3869189..000000000 --- a/lib/src/core/message/attachment.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; - -abstract class IAttachment implements SnowflakeEntity, Convertable { - /// The attachment's filename. - String get filename; - - /// The attachment's URL. - String get url; - - /// The attachment's proxy URL. - String? get proxyUrl; - - /// The attachment's file size. - int get size; - - /// The attachment's height, if an image. - int? get height; - - /// The attachment's width, if an image. - int? get width; - - /// whether this attachment is ephemeral - /// Note: Ephemeral attachments will automatically be removed after a set period of time. - /// Ephemeral attachments on messages are guaranteed to be available as long as the message itself exists. - bool get ephemeral; - - /// Indicates if attachment is spoiler - bool get isSpoiler; - - /// Description for the file - String? get description; -} - -/// A message attachment. -class Attachment extends SnowflakeEntity implements IAttachment { - /// The attachment's filename. - @override - late final String filename; - - /// The attachment's URL. - @override - late final String url; - - /// The attachment's proxy URL. - @override - late final String? proxyUrl; - - /// The attachment's file size. - @override - late final int size; - - /// The attachment's height, if an image. - @override - late final int? height; - - /// The attachment's width, if an image. - @override - late final int? width; - - /// whether this attachment is ephemeral - /// Note: Ephemeral attachments will automatically be removed after a set period of time. - /// Ephemeral attachments on messages are guaranteed to be available as long as the message itself exists. - @override - late final bool ephemeral; - - /// Description for the file - @override - late final String? description; - - /// Indicates if attachment is spoiler - @override - bool get isSpoiler => filename.startsWith("SPOILER_"); - - /// Creates an instance of [Attachment] - Attachment(RawApiMap raw) : super(Snowflake(raw["id"])) { - filename = raw["filename"] as String; - url = raw["url"] as String; - proxyUrl = raw["proxyUrl"] as String?; - size = raw["size"] as int; - description = raw['description'] as String?; - - height = raw["height"] as int?; - width = raw["width"] as int?; - - ephemeral = raw["ephemeral"] as bool? ?? false; - } - - @override - bool operator ==(other) { - if (other is Attachment) { - return other.id == id; - } - - if (other is Snowflake) { - return other == id; - } - - return false; - } - - @override - int get hashCode => id.hashCode; - - @override - AttachmentMetadataBuilder toBuilder() => AttachmentMetadataBuilder(id, filename); -} diff --git a/lib/src/core/message/components/component_style.dart b/lib/src/core/message/components/component_style.dart deleted file mode 100644 index e5795eac2..000000000 --- a/lib/src/core/message/components/component_style.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -/// Style for a button. -class ButtonStyle extends IEnum { - /// A blurple button - static const primary = ButtonStyle._create(1); - - /// A grey button - static const secondary = ButtonStyle._create(2); - - /// A green button - static const success = ButtonStyle._create(3); - - /// A red button - static const danger = ButtonStyle._create(4); - - /// A button that navigates to a URL - static const link = ButtonStyle._create(5); - - /// Creates instance of [ComponentStyle] - ButtonStyle.from(int value) : super(value); - const ButtonStyle._create(int value) : super(value); -} diff --git a/lib/src/core/message/components/message_component.dart b/lib/src/core/message/components/message_component.dart deleted file mode 100644 index 9d247a28d..000000000 --- a/lib/src/core/message/components/message_component.dart +++ /dev/null @@ -1,394 +0,0 @@ -import 'dart:convert'; - -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/message/unicode_emoji.dart'; -import 'package:nyxx/src/core/message/components/component_style.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/enum.dart'; - -/// Type of interaction component -class ComponentType extends IEnum { - /// Row where other components can be placed - static const ComponentType row = ComponentType._create(1); - - /// Button object. - static const ComponentType button = ComponentType._create(2); - - /// Select menu for picking from defined text options. - static const ComponentType multiSelect = ComponentType._create(3); - - /// Text input object. - static const ComponentType text = ComponentType._create(4); - - /// Select menu for users. - static const ComponentType userMultiSelect = ComponentType._create(5); - - /// Select menu for roles. - static const ComponentType roleMultiSelect = ComponentType._create(6); - - /// Select menu for mentionables (users and roles). - static const ComponentType mentionableMultiSelect = ComponentType._create(7); - - /// Select menu for channels. - static const ComponentType channelMultiSelect = ComponentType._create(8); - - const ComponentType._create(int value) : super(value); - - /// Create [ComponentType] from [value] - ComponentType.from(int value) : super(value); -} - -abstract class IMessageComponentEmoji { - /// Reference to [INyxx]. - INyxx get client; - - /// Name of the emoji if unicode emoji - String? get name; - - /// Id of the emoji if guid emoji - String? get id; - - /// True if emoji is animated - bool get animated; - - /// Returns emoji from button as emoji button native for nyxx - IEmoji get parsedEmoji; -} - -/// Spacial emoji object for [MessageComponent] -class MessageComponentEmoji implements IMessageComponentEmoji { - @override - final INyxx client; - - /// Name of the emoji if unicode emoji - @override - late final String? name; - - /// Id of the emoji if guid emoji - @override - late final String? id; - - /// True if emoji is animated - @override - late final bool animated; - - /// Returns emoji from button as emoji button native for nyxx - @override - IEmoji get parsedEmoji { - if (name != null) { - return UnicodeEmoji(name!); - } - - if (id != null) { - return IBaseGuildEmoji.fromId(Snowflake(id), client); - } - - throw ArgumentError("Tried to parse emojis from invalid payload"); - } - - /// Creates an instance of [MessageComponentEmoji] - MessageComponentEmoji(RawApiMap raw, this.client) { - name = raw["name"] as String?; - id = raw["id"] as String?; - animated = raw["animated"] as bool? ?? false; - } -} - -abstract class IMessageComponent { - /// The [ComponentType] - ComponentType get type; - - /// The custom id of this component, set by the user, if there isn't one, an empty string is returned. - // TODO: Add nullable string in next major release. - String get customId; -} - -/// Generic container for components that can be attached to message -abstract class MessageComponent implements IMessageComponent { - /// Type of component. - @override - ComponentType get type; - - @override - late final String customId; - - MessageComponent(RawApiMap raw) { - customId = raw['custom_id'] as String? ?? ''; - } - - factory MessageComponent.deserialize(RawApiMap raw, INyxx client) { - final type = raw["type"] as int; - - switch (type) { - case 2: - return MessageButton.deserialize(raw, client); - case 3: - return MessageMultiselect(raw, client); - case 4: - return MessageTextInput(raw); - case 5: - return MessageUserMultiSelect(raw); - case 6: - return MessageRoleMultiSelect(raw); - case 7: - return MessageMentionableMultiSelect(raw); - case 8: - return MessageChannelMultiSelect(raw); - } - - throw ArgumentError("Unknown interaction type: [$type]: ${jsonEncode(raw)}"); - } -} - -abstract class MultiSelectAbstract extends MessageComponent { - /// Custom placeholder when no option selected - late final String? placeholder; - - /// Min value of selected options - late final int minValues; - - /// Max value of selected options - late final int maxValues; - - /// Whether this multiselect is disabled. - late final bool isDisabled; - - MultiSelectAbstract(RawApiMap raw) : super(raw) { - placeholder = raw['placeholder'] as String?; - minValues = raw['min_values'] as int? ?? 1; - maxValues = raw['max_values'] as int? ?? 1; - isDisabled = raw['disabled'] as bool? ?? false; - } -} - -/// Text input component -abstract class IMessageTextInput implements IMessageComponent { - /// Value of component - String get value; -} - -class MessageTextInput extends MessageComponent implements IMessageTextInput { - @override - ComponentType get type => ComponentType.text; - - @override - late final String value; - - MessageTextInput(RawApiMap raw) : super(raw) { - value = raw['value'] as String; - } -} - -abstract class IMessageMultiselectOption { - /// Reference to [INyxx]. - INyxx get client; - - /// Option label - String get label; - - /// Value of option - String get value; - - /// Additional description for option - String? get description; - - /// Additional emoji that is displayed before label - IMessageComponentEmoji? get emoji; - - /// True of option will be preselected in UI - bool get isDefault; -} - -class MessageMultiselectOption implements IMessageMultiselectOption { - @override - final INyxx client; - - /// Option label - @override - late final String label; - - /// Value of option - @override - late final String value; - - /// Additional description for option - @override - late final String? description; - - /// Additional emoji that is displayed before label - @override - late final IMessageComponentEmoji? emoji; - - /// True of option will be preselected in UI - @override - late final bool isDefault; - - /// Creates an instance of [MessageMultiselectOption] - MessageMultiselectOption(RawApiMap raw, this.client) { - label = raw["label"] as String; - value = raw["value"] as String; - - description = raw["description"] as String?; - if (raw["emoji"] != null) { - emoji = MessageComponentEmoji(raw["emoji"] as Map, client); - } else { - emoji = null; - } - isDefault = raw["default"] as bool? ?? false; - } -} - -abstract class IMessageMultiselect implements MultiSelectAbstract { - /// Reference to [INyxx]. - INyxx get client; - - /// Possible options of multiselect. - Iterable get options; -} - -class MessageMultiselect extends MultiSelectAbstract implements IMessageMultiselect { - @override - final INyxx client; - - @override - ComponentType get type => ComponentType.multiSelect; - - /// Possible options of multiselect - @override - late final Iterable options; - - /// Creates an instance of [MessageMultiselect] - MessageMultiselect(RawApiMap raw, this.client) : super(raw) { - options = [for (final rawOption in raw["options"] as RawApiList) MessageMultiselectOption(rawOption as Map, client)]; - } -} - -abstract class IMessageUserMultiSelect implements MultiSelectAbstract {} - -class MessageUserMultiSelect extends MultiSelectAbstract implements IMessageUserMultiSelect { - @override - ComponentType get type => ComponentType.userMultiSelect; - - MessageUserMultiSelect(super.raw); -} - -abstract class IMessageRoleMultiSelect implements MultiSelectAbstract {} - -class MessageRoleMultiSelect extends MultiSelectAbstract implements IMessageRoleMultiSelect { - @override - ComponentType get type => ComponentType.roleMultiSelect; - - MessageRoleMultiSelect(super.raw); -} - -abstract class IMessageMentionableMultiSelect implements MultiSelectAbstract {} - -class MessageMentionableMultiSelect extends MultiSelectAbstract implements IMessageMentionableMultiSelect { - @override - ComponentType get type => ComponentType.mentionableMultiSelect; - - MessageMentionableMultiSelect(super.raw); -} - -abstract class IMessageChannelMultiSelect implements MultiSelectAbstract { - /// The channel types of this select. - Iterable? get channelTypes; -} - -class MessageChannelMultiSelect extends MultiSelectAbstract implements IMessageChannelMultiSelect { - @override - ComponentType get type => ComponentType.channelMultiSelect; - - @override - late final Iterable? channelTypes; - - MessageChannelMultiSelect(RawApiMap raw) : super(raw) { - channelTypes = raw['channel_types'] != null ? (raw['channel_types'] as List).cast().map(ChannelType.from) : null; - } -} - -abstract class IMessageButton implements IMessageComponent { - /// What the button says (max 80 characters) - String? get label; - - /// Component style, appearance - ButtonStyle get style; - - /// Additional emoji that will be displayed before label - IMessageComponentEmoji? get emoji; - - /// True if button is disabled - bool get disabled; -} - -/// Button component for Message -class MessageButton extends MessageComponent implements IMessageButton { - @override - ComponentType get type => ComponentType.button; - - /// What the button says (max 80 characters) - @override - late final String? label; - - /// Component style, appearance - @override - late final ButtonStyle style; - - /// Additional emoji that will be displayed before label - @override - late final IMessageComponentEmoji? emoji; - - /// True if button is disabled - @override - late final bool disabled; - - factory MessageButton.deserialize(RawApiMap raw, INyxx client) { - if (raw["style"] == ButtonStyle.link.value) { - return LinkMessageButton(raw, client); - } - - return MessageButton(raw, client); - } - - /// Creates an instance of [MessageButton] - MessageButton(RawApiMap raw, INyxx client) : super(raw) { - label = raw["label"] as String?; - style = ButtonStyle.from(raw["style"] as int); - - if (raw["emoji"] != null) { - emoji = MessageComponentEmoji(raw["emoji"] as RawApiMap, client); - } else { - emoji = null; - } - - disabled = raw["disabled"] as bool? ?? false; - } -} - -abstract class ILinkMessageButton implements IMessageButton { - /// Url where button points - String get url; - - /// buttons url as [Uri] - Uri get uri; -} - -/// Button with a link that user will be redirected after clicking -class LinkMessageButton extends MessageButton implements ILinkMessageButton { - /// Url where button points - @override - late final String url; - - /// buttons url as [Uri] - @override - Uri get uri => Uri.parse(url); - - /// Creates an instance of [LinkMessageButton] - LinkMessageButton(RawApiMap raw, INyxx client) : super(raw, client) { - url = raw["url"] as String; - } -} diff --git a/lib/src/core/message/emoji.dart b/lib/src/core/message/emoji.dart deleted file mode 100644 index e09e7335c..000000000 --- a/lib/src/core/message/emoji.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// Represents emoji. Subclasses provides abstraction to custom emojis(like [GuildEmoji]). -abstract class IEmoji { - /// Returns encoded emoji for API usage - String encodeForAPI(); - - /// Returns encoded emoji for usage in message - String formatForMessage(); -} diff --git a/lib/src/core/message/guild_emoji.dart b/lib/src/core/message/guild_emoji.dart deleted file mode 100644 index f85200960..000000000 --- a/lib/src/core/message/guild_emoji.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IBaseGuildEmoji implements SnowflakeEntity, IEmoji { - /// Reference to [INyxx]. - INyxx get client; - - /// True if emoji is partial. - bool get isPartial; - - /// The name of the emoji. - String get name; - - /// Whether this emoji is animated. - bool get animated; - - /// Creates partial emoji from given String or Snowflake. - factory IBaseGuildEmoji.fromId(Snowflake id, INyxx client) => GuildEmojiPartial({"id": id.toString()}, client); - - /// Returns the CDN URL for this emoji with given [format] and [size]. - /// If [animated] is set as `true`, an animated version of the emoji (if applicable) will be displayed. - String cdnUrl({String format = 'webp', int? size, bool animated = false}); -} - -abstract class BaseGuildEmoji extends SnowflakeEntity implements IBaseGuildEmoji { - @override - final INyxx client; - - /// True if emoji is partial. - @override - bool get isPartial; - - /// Whether this emoji is animated. - @override - bool get animated; - - /// The name of the emoji. - @override - String get name; - - /// Creates an instance of [BaseGuildEmoji] - BaseGuildEmoji(RawApiMap raw, this.client) : super(Snowflake(raw["id"])); - - /// Returns cdn url to emoji - @override - String cdnUrl({String format = 'webp', int? size, bool animated = false}) { - return client.cdnHttpEndpoints.emoji(id, format: this.animated && animated ? 'gif' : format, size: size); - } - - @override - String formatForMessage() => "<${animated ? 'a' : ''}:$name:$id>"; - - @override - String encodeForAPI() => '$name:$id'; - - /// Returns encoded string ready to send via message. - @override - String toString() => formatForMessage(); -} - -abstract class IGuildEmojiPartial implements IBaseGuildEmoji {} - -abstract class IResolvableGuildEmojiPartial implements IGuildEmojiPartial { - /// Resolves this [IResolvableGuildEmojiPartial] to [IGuildEmoji] - IGuildEmoji resolve(); -} - -class GuildEmojiPartial extends BaseGuildEmoji implements IGuildEmojiPartial { - /// True if emoji is partial. - @override - bool get isPartial => true; - - /// The name of the emoji. - @override - late final String name; - - /// Whether this emoji is animated. - @override - late final bool animated; - - /// Creates an instance of [GuildEmojiPartial] - GuildEmojiPartial(RawApiMap raw, INyxx client) : super({"id": raw["id"]}, client) { - name = raw["name"] as String? ?? "nyxx"; - animated = raw["animated"] as bool? ?? false; - } -} - -class ResolvableGuildEmojiPartial extends BaseGuildEmoji implements IResolvableGuildEmojiPartial { - /// Whether this emoji is animated. - @override - late final bool animated; - - /// Whether this emoji is partial. - @override - bool get isPartial => true; - - /// The name of the emoji. - @override - late final String name; - - /// Creates an instance of [ResolvableGuildEmojiPartial] - ResolvableGuildEmojiPartial(RawApiMap raw, INyxx client) : super(raw, client) { - name = raw["name"] as String? ?? "nyxx"; - animated = raw["animated"] as bool? ?? false; - } - - /// Resolves this [IResolvableGuildEmojiPartial] to [IGuildEmoji] - @override - IGuildEmoji resolve() => client.guilds.values.expand((guild) => guild.emojis.values).firstWhere((emoji) => emoji.id == id) as IGuildEmoji; -} - -abstract class IGuildEmoji implements IBaseGuildEmoji { - /// Reference to guild where emoji belongs to - Cacheable get guild; - - /// Roles which can use this emote - Iterable> get roles; - - /// whether this emoji must be wrapped in colons - bool get requireColons; - - /// whether this emoji is managed - bool get managed; - - /// Fetches the creator of this emoji - Future fetchCreator(); - - /// Allows to delete guild emoji - Future delete(); - - /// Allows to edit guild emoji - Future edit({String? name, List? roles}); -} - -class GuildEmoji extends BaseGuildEmoji implements IGuildEmoji { - /// Reference to guild where emoji belongs to - @override - late final Cacheable guild; - - /// Roles which can use this emote - @override - late final Iterable> roles; - - /// whether this emoji must be wrapped in colons - @override - late final bool requireColons; - - /// whether this emoji is managed - @override - late final bool managed; - - /// whether this emoji is animated - @override - late final bool animated; - - /// The name of the emoji. - @override - late final String name; - - /// True if emoji is partial. - @override - bool get isPartial => false; - - /// Creates an instance of [GuildEmoji] - GuildEmoji(INyxx client, RawApiMap raw, Snowflake guildId) : super(raw, client) { - guild = GuildCacheable(client, guildId); - - name = raw["name"] as String; - requireColons = raw["require_colons"] as bool? ?? false; - managed = raw["managed"] as bool? ?? false; - animated = raw["animated"] as bool? ?? false; - roles = [for (final roleId in raw["roles"] as RawApiList) RoleCacheable(client, Snowflake(roleId), guild)]; - } - - /// Returns encoded emoji for usage in message - @override - String formatForMessage() => "<${animated ? 'a' : ''}:$name:$id>"; - - /// Fetches the creator of this emoji - @override - Future fetchCreator() => client.httpEndpoints.fetchEmojiCreator(guild.id, id); - - /// Allows to delete guild emoji - @override - Future delete() => client.httpEndpoints.deleteGuildEmoji(guild.id, id); - - /// Allows to edit guild emoji - @override - Future edit({String? name, List? roles}) => client.httpEndpoints.editGuildEmoji(guild.id, id, name: name, roles: roles); -} diff --git a/lib/src/core/message/message.dart b/lib/src/core/message/message.dart deleted file mode 100644 index 6984f423d..000000000 --- a/lib/src/core/message/message.dart +++ /dev/null @@ -1,510 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/channel/thread_preview_channel.dart'; -import 'package:nyxx/src/core/embed/embed.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/guild/webhook.dart'; -import 'package:nyxx/src/core/message/attachment.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/message/message_flags.dart'; -import 'package:nyxx/src/core/message/message_reference.dart'; -import 'package:nyxx/src/core/message/message_time_stamp.dart'; -import 'package:nyxx/src/core/message/message_type.dart'; -import 'package:nyxx/src/core/message/reaction.dart'; -import 'package:nyxx/src/core/message/referenced_message.dart'; -import 'package:nyxx/src/core/message/sticker.dart'; -import 'package:nyxx/src/core/message/components/message_component.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/internal/interfaces/disposable.dart'; -import 'package:nyxx/src/internal/interfaces/message_author.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; -import 'package:nyxx/src/utils/builders/thread_builder.dart'; - -abstract class IMessage implements SnowflakeEntity, Disposable, Convertable { - /// Reference to bot instance - INyxx get client; - - /// The message's content. - String get content; - - /// Channel in which message was sent - CacheableTextChannel get channel; - - /// The timestamp of when the message was last edited, null if not edited. - DateTime? get editedTimestamp; - - /// The message's author. - IMessageAuthor get author; - - /// The mentions in the message. - List> get mentions; - - /// A collection of the embeds in the message. - List get embeds; - - /// The attachments in the message. - List get attachments; - - /// Whether or not the message is pinned. - bool get pinned; - - /// Whether or not the message was sent with TTS enabled. - bool get tts; - - /// Whether or @everyone was mentioned in the message. - bool get mentionEveryone; - - /// List of message reactions - List get reactions; - - /// Type of message - MessageType get type; - - /// Extra features of the message - MessageFlags? get flags; - - /// Returns clickable url to this message. - String get url; - - /// The stickers sent with the message - Iterable get partialStickers; - - /// Message reply - IReferencedMessage? get referencedMessage; - - /// List of components attached to message. - List> get components; - - /// A nonce that can be used for optimistic message sending (up to 25 characters) - /// You will be able to identify that message when receiving it through gateway - String? get nonce; - - /// If the message is a response to an Interaction, this is the id of the interaction's application - Snowflake? get applicationId; - - /// Inline timestamps of current message - Iterable get timestamps; - - /// The message's guild. - Cacheable? get guild; - - /// Member data of message author - IMember? get member; - - /// Reference to original message if this message cross posts other message - IMessageReference? get crossPostReference; - - /// True if this message is cross posts other message - bool get isCrossPosting; - - /// True if message is sent by a webhook - bool get isByWebhook; - - /// Role mentions in this message - List> get roleMentions; - - /// Cross post a Message into all guilds what follow the news channel indicated. - /// This endpoint requires the "DISCOVERY" feature to be present for the guild. - Future crossPost(); - - /// Suppresses embeds in message. Can be executed in other users messages. - Future suppressEmbeds(); - - /// Edits the message. - Future edit(MessageBuilder builder); - - /// Add reaction to message. - Future createReaction(IEmoji emoji); - - /// Deletes reaction of bot. - Future deleteSelfReaction(IEmoji emoji); - - /// Deletes reaction of given user. - Future deleteUserReaction(IEmoji emoji, SnowflakeEntity entity); - - /// Deletes all reactions - Future deleteAllReactions(); - - /// Fetches the users that reacted to this message with a given emoji. - Stream fetchReactionUsers(IEmoji emoji); - - /// Deletes reactions to this message with a given emoji - Future deleteReactions(IEmoji emoji); - - /// Deletes the message. - Future delete({String? auditReason}); - - /// Pins [Message] in message's channel - Future pinMessage(); - - /// Unpins [Message] in message's channel - Future unpinMessage(); - - /// Creates a thread based on this message, that only retrieves a [ThreadPreviewChannel] - Future createThread(ThreadBuilder builder); - - /// Creates a thread in a message - Future createAndGetThread(ThreadBuilder builder); -} - -class Message extends SnowflakeEntity implements IMessage { - /// Reference to bot instance - @override - final INyxx client; - - /// The message's author. - @override - late final IMessageAuthor author; - - /// The message's content. - @override - late String content; - - /// Channel in which message was sent - @override - late final CacheableTextChannel channel; - - /// The timestamp of when the message was last edited, null if not edited. - @override - late DateTime? editedTimestamp; - - /// The mentions in the message. - @override - List> mentions = []; - - /// A collection of the embeds in the message. - @override - late List embeds; - - /// The attachments in the message. - @override - late List attachments; - - /// Whether or not the message is pinned. - @override - late bool pinned; - - /// Whether or not the message was sent with TTS enabled. - @override - late final bool tts; - - /// Whether or @everyone was mentioned in the message. - @override - late final bool mentionEveryone; - - /// List of message reactions - @override - late final List reactions; - - /// Type of message - @override - late final MessageType type; - - /// Extra features of the message - @override - late MessageFlags? flags; - - /// The stickers sent with the message - @override - late final Iterable partialStickers; - - /// Message reply - @override - late final ReferencedMessage? referencedMessage; - - /// List of components attached to message. - @override - late List> components; - - /// A nonce that can be used for optimistic message sending (up to 25 characters) - /// You will be able to identify that message when receiving it through gateway - @override - late final String? nonce; - - /// If the message is a response to an Interaction, this is the id of the interaction's application - @override - late final Snowflake? applicationId; - - /// Inline timestamps of current message - @override - Iterable get timestamps sync* { - for (final match in IMessageTimestamp.regex.allMatches(content)) { - yield MessageTimestamp(match); - } - } - - /// The message's guild. - @override - late final Cacheable? guild; - - /// Reference to original message if this message cross posts other message - @override - late final IMessageReference? crossPostReference; - - /// True if this message is cross posts other message - @override - bool get isCrossPosting => crossPostReference != null; - - /// Returns clickable url to this message. - @override - String get url => "https://discordapp.com/channels/${guild?.id ?? '@me'}" - "/${channel.id}/$id"; - - /// Member data of message author - @override - late final IMember? member; - - /// True if message is sent by a webhook - @override - bool get isByWebhook => author is IWebhook; - - /// Role mentions in this message - @override - late final List> roleMentions; - - /// Creates an instance of [Message] - Message(this.client, RawApiMap raw) : super(Snowflake(raw["id"])) { - content = raw["content"] as String; - channel = CacheableTextChannel(client, Snowflake(raw["channel_id"])); - - pinned = raw["pinned"] as bool; - tts = raw["tts"] as bool; - mentionEveryone = raw["mention_everyone"] as bool; - type = MessageType.from(raw["type"] as int); - - partialStickers = [ - if (raw["sticker_items"] != null) - for (final rawSticker in raw["sticker_items"] as RawApiList) PartialSticker(rawSticker as RawApiMap, client) - ]; - - flags = raw["flags"] != null ? MessageFlags(raw["flags"] as int) : null; - editedTimestamp = raw["edited_timestamp"] != null ? DateTime.parse(raw["edited_timestamp"] as String).toUtc() : null; - - embeds = [ - if (raw["embeds"] != null && raw["embeds"].isNotEmpty as bool) - for (var r in raw["embeds"] as RawApiList) Embed(r as RawApiMap) - ]; - - attachments = [ - if (raw["attachments"] != null && raw["attachments"].isNotEmpty as bool) - for (var r in raw["attachments"] as RawApiList) Attachment(r as RawApiMap) - ]; - - reactions = [ - if (raw["reactions"] != null && raw["reactions"].isNotEmpty as bool) - for (var r in raw["reactions"] as RawApiList) Reaction(r as RawApiMap, client) - ]; - - if (raw["mentions"] != null && raw["mentions"].isNotEmpty as bool) { - for (final rawUser in raw["mentions"] as RawApiList) { - final user = User(client, rawUser as RawApiMap); - - if (client.cacheOptions.userCachePolicyLocation.objectConstructor) { - client.users[user.id] = user; - } - - mentions.add(UserCacheable(client, user.id)); - } - } - - if (raw["type"] == 19) { - referencedMessage = ReferencedMessage(client, raw); - } else { - referencedMessage = null; - } - - if (raw["nonce"] != null) { - nonce = raw["nonce"].toString(); - } else { - nonce = null; - } - - applicationId = raw["application_id"] != null ? Snowflake(raw["application_id"]) : null; - - if (raw["components"] != null) { - components = [ - for (final rawRow in raw["components"] as RawApiList) - [for (final componentRaw in rawRow["components"] as RawApiList) MessageComponent.deserialize(componentRaw as RawApiMap, client)] - ]; - } else { - components = []; - } - - if (raw["message_reference"] != null) { - crossPostReference = MessageReference(raw["message_reference"] as RawApiMap, client); - } else { - crossPostReference = null; - } - - guild = raw["guild_id"] != null ? GuildCacheable(client, Snowflake(raw["guild_id"])) : null; - - if (raw["webhook_id"] != null) { - author = Webhook(raw["author"] as RawApiMap, client); - } else { - author = User(client, raw["author"] as RawApiMap); - - if (client.cacheOptions.userCachePolicyLocation.objectConstructor) { - client.users[author.id] = author as User; - } - } - - if (raw["member"] != null) { - // In case member object doesnt have id property and we need it for member object - raw["member"]["user"] = {"id": raw["author"]["id"]}; - member = Member(client, raw["member"] as RawApiMap, guild!.id); - - if (client.cacheOptions.memberCachePolicyLocation.objectConstructor && client.cacheOptions.memberCachePolicy.canCache(member!)) { - guild?.getFromCache()?.members[member!.id] = member!; - } - } else { - member = null; - } - - roleMentions = [ - if (raw["mention_roles"] != null && guild != null) - for (var roleId in raw["mention_roles"] as RawApiList) RoleCacheable(client, Snowflake(roleId), guild!) - ]; - } - - Message.copy(Message other) - : client = other.client, - super(other.id) { - author = other.author; - content = other.content; - channel = other.channel; - editedTimestamp = other.editedTimestamp; - mentions = other.mentions; - embeds = other.embeds; - attachments = other.attachments; - pinned = other.pinned; - tts = other.tts; - mentionEveryone = other.mentionEveryone; - reactions = other.reactions; - type = other.type; - flags = other.flags; - partialStickers = other.partialStickers; - referencedMessage = other.referencedMessage; - components = other.components; - nonce = other.nonce; - applicationId = other.applicationId; - crossPostReference = other.crossPostReference; - member = other.member; - roleMentions = other.roleMentions; - } - - /// Suppresses embeds in message. Can be executed in other users messages. - @override - Future suppressEmbeds() => client.httpEndpoints.suppressMessageEmbeds(channel.id, id); - - /// Edits the message. - @override - Future edit(MessageBuilder builder) => client.httpEndpoints.editMessage(channel.id, id, builder); - - /// Add reaction to message. - @override - Future createReaction(IEmoji emoji) => client.httpEndpoints.createMessageReaction(channel.id, id, emoji); - - /// Deletes reaction of bot. - @override - Future deleteSelfReaction(IEmoji emoji) => client.httpEndpoints.deleteMessageReaction(channel.id, id, emoji); - - /// Deletes reaction of given user. - @override - Future deleteUserReaction(IEmoji emoji, SnowflakeEntity entity) => client.httpEndpoints.deleteMessageUserReaction(channel.id, id, emoji, entity.id); - - /// Deletes all reactions - @override - Future deleteAllReactions() => client.httpEndpoints.deleteMessageAllReactions(channel.id, id); - - @override - Stream fetchReactionUsers(IEmoji emoji) => client.httpEndpoints.fetchMessageReactionUsers(channel.id, id, emoji); - - @override - Future deleteReactions(IEmoji emoji) => client.httpEndpoints.deleteMessageReactions(channel.id, id, emoji); - - /// Deletes the message. - @override - Future delete({String? auditReason}) => client.httpEndpoints.deleteMessage(channel.id, id); - - /// Pins [Message] in message's channel - @override - Future pinMessage() => client.httpEndpoints.pinMessage(channel.id, id); - - /// Unpins [Message] in message's channel - @override - Future unpinMessage() => client.httpEndpoints.unpinMessage(channel.id, id); - - /// Creates a thread based on this message, that only retrieves a [ThreadPreviewChannel] - @override - Future createThread(ThreadBuilder builder) async => client.httpEndpoints.createThreadWithMessage(channel.id, id, builder); - - /// Creates a thread in a message - @override - Future createAndGetThread(ThreadBuilder builder) async { - final preview = await client.httpEndpoints.createThreadWithMessage(channel.id, id, builder); - return preview.getThreadChannel().getOrDownload(); - } - - /// Cross post a Message into all guilds what follow the news channel indicated. - /// This endpoint requires the "DISCOVERY" feature to be present for the guild. - @override - Future crossPost() async => client.httpEndpoints.crossPostGuildMessage(channel.id, id); - - @override - Future dispose() => Future.value(null); - - @override - MessageBuilder toBuilder() => MessageBuilder.fromMessage(this); - - @override - bool operator ==(other) { - if (other is Message) { - return id == other.id; - } - - if (other is Snowflake) { - return id == other; - } - - if (other is int) { - return id == other; - } - - if (other is String) { - return id == other; - } - - return false; - } - - @override - int get hashCode => id.hashCode; -} - -class WebhookMessage extends Message { - final Snowflake webhookId; - final Snowflake? threadId; - late final String? token; - - WebhookMessage(INyxx client, RawApiMap raw, this.webhookId, String? token, this.threadId) : super(client, raw) { - this.token = token != null && token.isEmpty ? null : token; - } - - /// Edits the message. - @override - Future edit(MessageBuilder builder) => client.httpEndpoints.editWebhookMessage(webhookId, id, builder, token: token, threadId: threadId); - - /// Deletes the message. - @override - Future delete({String? auditReason}) => - client.httpEndpoints.deleteWebhookMessage(webhookId, id, token: token, threadId: threadId, auditReason: auditReason); -} diff --git a/lib/src/core/message/message_flags.dart b/lib/src/core/message/message_flags.dart deleted file mode 100644 index 6051fb555..000000000 --- a/lib/src/core/message/message_flags.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:nyxx/src/utils/permissions.dart'; - -/// Extra features of the message -class MessageFlags { - /// Raw bitfield - final int raw; - - /// This message has been published to subscribed channels (via Channel Following) - bool get crossPosted => PermissionsUtils.isApplied(raw, 1 << 0); - - /// This message originated from a message in another channel (via Channel Following) - bool get isCrossPost => PermissionsUtils.isApplied(raw, 1 << 1); - - /// Do not include any embeds when serializing this message - bool get suppressEmbeds => PermissionsUtils.isApplied(raw, 1 << 2); - - /// The source message for this cross post has been deleted (via Channel Following) - bool get sourceMessageDeleted => PermissionsUtils.isApplied(raw, 1 << 3); - - /// This message came from the urgent message system - bool get urgent => PermissionsUtils.isApplied(raw, 1 << 4); - - /// This message has an associated thread, with the same id as the message - bool get hasThread => PermissionsUtils.isApplied(raw, 1 << 5); - - /// This message is only visible to the user who invoked the Interaction - bool get ephemeral => PermissionsUtils.isApplied(raw, 1 << 6); - - /// This message is an Interaction Response and the bot is "thinking" - bool get loading => PermissionsUtils.isApplied(raw, 1 << 7); - - /// This message failed to mention some roles and add their members to the thread - bool get failedToMentionSomeRolesInThread => PermissionsUtils.isApplied(raw, 1 << 8); - - /// This message will not trigger push and desktop notifications - bool get suppressNotifications => PermissionsUtils.isApplied(raw, 1 << 12); - - /// Creates an instance of [MessageFlags] - MessageFlags(this.raw); -} diff --git a/lib/src/core/message/message_reference.dart b/lib/src/core/message/message_reference.dart deleted file mode 100644 index d689e674f..000000000 --- a/lib/src/core/message/message_reference.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IMessageReference { - /// Original message - late final Cacheable? message; - - /// Original channel - late final CacheableTextChannel channel; - - /// Original guild - late final Cacheable? guild; -} - -/// Reference data to cross posted message -class MessageReference implements IMessageReference { - /// Original message - @override - late final Cacheable? message; - - /// Original channel - @override - late final CacheableTextChannel channel; - - /// Original guild - @override - late final Cacheable? guild; - - /// Creates an instance of [MessageReference] - MessageReference(RawApiMap raw, INyxx client) { - channel = CacheableTextChannel(client, Snowflake(raw["channel_id"])); - - if (raw["message_id"] != null) { - message = MessageCacheable(client, Snowflake(raw["message_id"]), channel); - } else { - message = null; - } - - if (raw["guild_id"] != null) { - guild = GuildCacheable(client, Snowflake(raw["guild_id"])); - } else { - guild = null; - } - } -} diff --git a/lib/src/core/message/message_time_stamp.dart b/lib/src/core/message/message_time_stamp.dart deleted file mode 100644 index 3a5bbaff6..000000000 --- a/lib/src/core/message/message_time_stamp.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -/// Style of inline timestamp that can be embedded into message -class TimeStampStyle extends IEnum { - /// Short Time - static const TimeStampStyle shortTime = TimeStampStyle._create("t"); - - /// Long Time - static const TimeStampStyle longTime = TimeStampStyle._create("T"); - - /// Short Date - static const TimeStampStyle shortDate = TimeStampStyle._create("d"); - - /// Long Date - static const TimeStampStyle longDate = TimeStampStyle._create("D"); - - /// Short Date/Time - static const TimeStampStyle shortDateTime = TimeStampStyle._create("f"); - - /// Long Date/Time - static const TimeStampStyle longDateTime = TimeStampStyle._create("F"); - - /// Relative Time - static const TimeStampStyle relativeTime = TimeStampStyle._create("R"); - - /// Default style - static const TimeStampStyle def = TimeStampStyle.shortDateTime; - - /// Create instance of [TimeStampStyle] from [value] - TimeStampStyle.from(String value) : super(value); - const TimeStampStyle._create(String value) : super(value); - - /// Return - String format(DateTime dateTime) => ""; -} - -abstract class IMessageTimestamp { - /// Regex to parse message timestamp - static final regex = RegExp(r""); - - /// Style of timestamp - TimeStampStyle get style; - - /// [DateTime] of timestamp - DateTime get timeStamp; -} - -class MessageTimestamp implements IMessageTimestamp { - /// Style of timestamp - @override - late final TimeStampStyle style; - - /// [DateTime] of timestamp - @override - late final DateTime timeStamp; - - /// Creates an instance of [MessageTimestamp] - MessageTimestamp(Match match) { - timeStamp = DateTime.fromMillisecondsSinceEpoch(int.parse(match.group(1)!) * 1000); - - final styleMatch = match.group(3); - style = styleMatch != null ? TimeStampStyle.from(styleMatch) : TimeStampStyle.def; - } -} diff --git a/lib/src/core/message/message_type.dart b/lib/src/core/message/message_type.dart deleted file mode 100644 index 500fe4ef0..000000000 --- a/lib/src/core/message/message_type.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -/// Represents messgae type -class MessageType extends IEnum { - static const MessageType def = MessageType._create(0); - static const MessageType recipientAdd = MessageType._create(1); - static const MessageType recipientRemove = MessageType._create(2); - static const MessageType call = MessageType._create(3); - static const MessageType channelNameChange = MessageType._create(4); - static const MessageType channelIconChange = MessageType._create(5); - static const MessageType channelPinnedMessage = MessageType._create(6); - static const MessageType guildMemberJoin = MessageType._create(7); - static const MessageType userPremiumGuildSubscription = MessageType._create(8); - static const MessageType userPremiumGuildSubscriptionTier1 = MessageType._create(9); - static const MessageType userPremiumGuildSubscriptionTier2 = MessageType._create(10); - static const MessageType userPremiumGuildSubscriptionTier3 = MessageType._create(11); - static const MessageType channelFollowAdd = MessageType._create(12); - static const MessageType guildDiscoveryDisqualified = MessageType._create(14); - static const MessageType guildStream = MessageType._create(13); - static const MessageType guildDiscoveryRequalified = MessageType._create(15); - static const MessageType guildDiscoveryGracePeriodInitialWarning = MessageType._create(16); - static const MessageType guildDiscoveryGracePeriodFinalWarning = MessageType._create(17); - static const MessageType threadCreated = MessageType._create(18); - static const MessageType reply = MessageType._create(19); - static const MessageType chatInputCommand = MessageType._create(20); - static const MessageType threadStarterMessage = MessageType._create(21); - static const MessageType guildInviteRemainder = MessageType._create(22); - static const MessageType contextMenuCommand = MessageType._create(23); - static const MessageType autoModerationAction = MessageType._create(24); - static const MessageType roleSubscriptionPurchase = MessageType._create(25); - static const MessageType interactionPremiumUpsell = MessageType._create(26); - static const MessageType stageStart = MessageType._create(27); - static const MessageType stageEnd = MessageType._create(28); - static const MessageType stageSpeaker = MessageType._create(29); - static const MessageType stageRaiseHand = MessageType._create(30); - static const MessageType stageTopic = MessageType._create(31); - static const MessageType guildApplicationPremiumSubscription = MessageType._create(32); - - /// Creates instance of [MessageType] from [value]. - MessageType.from(int? value) : super(value ?? 0); - const MessageType._create(int? value) : super(value ?? 0); - - @override - bool operator ==(dynamic other) { - if (other is int) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/message/reaction.dart b/lib/src/core/message/reaction.dart deleted file mode 100644 index c7929b1af..000000000 --- a/lib/src/core/message/reaction.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; - -abstract class IReaction { - /// Time this emoji has been used to react - int get count; - - /// Whether the current user reacted using this emoji - bool get me; - - /// Emoji information - IEmoji get emoji; -} - -/// Reaction object. [emoji] field can be partial [GuildEmoji]. -class Reaction implements IReaction { - /// Time this emoji has been used to react - @override - late int count; - - /// Whether the current user reacted using this emoji - @override - late final bool me; - - /// Emoji information - @override - late final IEmoji emoji; - - /// Creates an instance of [Reaction] - Reaction(RawApiMap raw, INyxx client) { - count = raw["count"] as int; - me = raw["me"] as bool; - - final rawEmoji = raw["emoji"] as RawApiMap; - if (rawEmoji["id"] == null) { - emoji = UnicodeEmoji(rawEmoji["name"] as String); - } else { - emoji = ResolvableGuildEmojiPartial(rawEmoji, client); - } - } - - /// Creates an instance of [Reaction] - Reaction.event(this.emoji, this.me) { - count = 1; - } -} diff --git a/lib/src/core/message/referenced_message.dart b/lib/src/core/message/referenced_message.dart deleted file mode 100644 index 50f1acce0..000000000 --- a/lib/src/core/message/referenced_message.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/reply_builder.dart'; - -abstract class IReferencedMessage implements Convertable { - /// Message object of reply - IMessage? get message; - - /// If true the backend couldn't fetch the message - bool get isBackendFetchError; - - /// If true message was deleted - bool get isDeleted; - - /// True if references message exists and is available - bool get exists; -} - -/// Message wrapper that other message replies to. -/// [message] field can be null of two reasons: backend error or message was deleted. -/// In first case [isBackendFetchError] will be true and [isDeleted] in second case. -class ReferencedMessage implements IReferencedMessage { - /// Message object of reply - @override - late final IMessage? message; - - /// If true the backend couldn't fetch the message - @override - late final bool isBackendFetchError; - - /// If true message was deleted - @override - late final bool isDeleted; - - /// True if references message exists and is available - @override - bool get exists => !isDeleted && !isBackendFetchError; - - ReferencedMessage(INyxx client, RawApiMap raw) { - if (!raw.containsKey("referenced_message")) { - message = null; - isBackendFetchError = true; - isDeleted = false; - return; - } - - if (raw["referenced_message"] == null) { - message = null; - isBackendFetchError = false; - isDeleted = true; - return; - } - - message = Message(client, raw["referenced_message"] as RawApiMap); - isBackendFetchError = false; - isDeleted = false; - } - - @override - ReplyBuilder toBuilder() => ReplyBuilder(message?.id ?? Snowflake(0), false); -} diff --git a/lib/src/core/message/sticker.dart b/lib/src/core/message/sticker.dart deleted file mode 100644 index 882792268..000000000 --- a/lib/src/core/message/sticker.dart +++ /dev/null @@ -1,324 +0,0 @@ -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/sticker_builder.dart'; -import 'package:nyxx/src/utils/enum.dart'; - -/// Base interface for all sticker types -abstract class ISticker implements SnowflakeEntity { - /// Reference to client - INyxx get client; - - /// Name of the sticker - String get name; - - /// Description of the sticker - String? get description; - - /// Type of sticker - StickerType get type; - - /// Format of sticker - StickerFormat get format; - - /// Url for sticker image - String stickerUrl(); -} - -/// Base class for all sticker types -abstract class Sticker extends SnowflakeEntity implements ISticker { - /// Reference to client - @override - final INyxx client; - - /// Name of the sticker - @override - String get name; - - /// Description of the sticker - @override - String? get description; - - /// Type of sticker - @override - StickerType get type; - - /// Format of sticker - @override - StickerFormat get format; - - /// Url for sticker image - @override - String stickerUrl() => client.cdnHttpEndpoints.sticker(id, format: format.getExtension()); - - /// Creates an instance of [Sticker] - Sticker(RawApiMap raw, this.client) : super(Snowflake(raw["id"])); -} - -abstract class IPartialSticker implements ISticker {} - -/// Partial sticker for message object -class PartialSticker extends Sticker implements IPartialSticker { - /// Name of sticker - @override - late final String name; - - /// Format of the sticker - @override - late final StickerFormat format; - - /// Description of the sticker - @override - String? get description => null; - - /// Type of sticker - @override - StickerType get type => StickerType.partial; - - /// Creates an instance of [PartialSticker] - PartialSticker(RawApiMap raw, INyxx client) : super(raw, client) { - name = raw["name"] as String; - format = StickerFormat.from(raw["format_type"] as int); - } -} - -abstract class IGuildSticker implements ISticker { - /// The Discord name of a unicode emoji representing the sticker's expression. - String get tags; - - /// Whether this guild sticker can be used, may be false due to loss of Server Boosts - bool? get available; - - /// Guild that owns this sticker - Cacheable get guild; - - /// User that uploaded the guild sticker - IUser? get user; - - /// Edits current sticker - Future edit(StickerBuilder builder); - - /// Removed current sticker - Future delete(); -} - -/// Sticker that is available through guild and nitro users that joined that guild -/// have access to them. -class GuildSticker extends Sticker implements IGuildSticker { - @override - late final String name; - - @override - late final String? description; - - @override - late final StickerType type; - - @override - late final StickerFormat format; - - /// The Discord name of a unicode emoji representing the sticker's expression. - @override - late final String tags; - - /// Whether this guild sticker can be used, may be false due to loss of Server Boosts - @override - late final bool? available; - - /// Guild that owns this sticker - @override - late final Cacheable guild; - - /// User that uploaded the guild sticker - @override - late final IUser? user; - - /// Create an instance of [GuildSticker] - GuildSticker(RawApiMap raw, INyxx client) : super(raw, client) { - name = raw["name"] as String; - description = raw["description"] as String?; - format = StickerFormat.from(raw["format_type"] as int); - type = StickerType.from(raw["type"] as int); - - tags = raw["tags"] as String; - available = raw["available"] as bool?; - guild = GuildCacheable(client, Snowflake(raw["guild_id"])); - if (raw["user"] != null) { - user = User(client, raw["user"] as RawApiMap); - } else { - user = null; - } - } - - /// Edits current sticker - @override - Future edit(StickerBuilder builder) => client.httpEndpoints.editGuildSticker(guild.id, id, builder); - - /// Removed current sticker - @override - Future delete() => client.httpEndpoints.deleteGuildSticker(guild.id, id); -} - -/// Animated (or not) image like emoji -abstract class IStandardSticker implements ISticker { - /// Id of the pack the sticker is from - Snowflake get packId; - - /// Comma-separated list of tags for the sticker. - /// Available in list form: [tagsList]. - String? get tags; - - /// [IStandardSticker] tags in list form - Iterable get tagsList; -} - -/// Animated (or not) image like emoji -class StandardSticker extends Sticker implements IStandardSticker { - @override - late final String name; - - @override - late final String? description; - - @override - late final StickerType type; - - @override - late final StickerFormat format; - - /// Id of the pack the sticker is from - @override - late final Snowflake packId; - - /// Comma-separated list of tags for the sticker. - /// Available in list form: [tagsList]. - @override - late final String? tags; - - /// [StandardSticker] tags in list form - @override - Iterable get tagsList => tags!.split(", ").map((e) => e.trim()); - - /// Creates an instance of [StandardSticker] - StandardSticker(RawApiMap raw, INyxx client) : super(raw, client) { - name = raw["name"] as String; - description = raw["description"] as String; - format = StickerFormat.from(raw["format_type"] as int); - type = StickerType.from(raw["type"] as int); - - packId = Snowflake(raw["pack_id"]); - tags = raw["tags"] as String; - } -} - -abstract class IStickerPack implements SnowflakeEntity { - /// The stickers in the pack - List get stickers; - - /// Name of the sticker pack - String get name; - - /// Id of the pack's SKU - Snowflake get skuId; - - /// Id of a sticker in the pack which is shown as the pack's icon - Snowflake get coverStickerId; - - /// Description of the sticker pack - String get description; - - /// Id of the sticker pack's banner image - Snowflake get bannerAssetId; - - /// Returns the banner URL for this pack, with specified [format] and [size]. - String bannerUrl({String format = 'webp', int? size}); -} - -/// Represents a pack of standard stickers. -class StickerPack extends SnowflakeEntity implements IStickerPack { - /// Reference to [INyxx]. - final INyxx client; - - /// The stickers in the pack - @override - late final List stickers; - - /// Name of the sticker pack - @override - late final String name; - - /// Id of the pack's SKU - @override - late final Snowflake skuId; - - /// Id of a sticker in the pack which is shown as the pack's icon - @override - late final Snowflake coverStickerId; - - /// Description of the sticker pack - @override - late final String description; - - /// Id of the sticker pack's banner image - @override - late final Snowflake bannerAssetId; - - /// Creates an instance of [StickerPack] - StickerPack(RawApiMap raw, this.client) : super(Snowflake(raw["id"])) { - stickers = [for (final rawSticker in raw["stickers"] as RawApiList) StandardSticker(rawSticker as RawApiMap, client)]; - name = raw["name"] as String; - skuId = Snowflake(raw["sku_id"]); - coverStickerId = Snowflake(raw["cover_sticker_id"]); - description = raw["description"] as String; - bannerAssetId = Snowflake(raw["banner_asset_id"]); - } - - @override - String bannerUrl({String format = 'webp', int? size}) { - return client.cdnHttpEndpoints.stickerPackBanner(bannerAssetId, format: format, size: size); - } -} - -/// Enumerates different possible format of sticker -class StickerType extends IEnum { - /// Standard nitro sticker - static const StickerType standard = StickerType._create(1); - - /// Sticker that was upload to guild, available to nitro users. - static const StickerType guild = StickerType._create(2); - - /// Internal nyxx sticker type used in guilds - static const StickerType partial = StickerType._create(99); - - /// Creates [StickerType] from [value] - StickerType.from(int value) : super(value); - const StickerType._create(int value) : super(value); -} - -/// Enumerates different possible format of sticker -class StickerFormat extends IEnum { - static const StickerFormat png = StickerFormat._create(1); - static const StickerFormat apng = StickerFormat._create(2); - static const StickerFormat lottie = StickerFormat._create(3); - - /// Creates [StickerFormat] from [value] - StickerFormat.from(int value) : super(value); - const StickerFormat._create(int value) : super(value); - - /// Returns extension for given Sticker type - String getExtension() { - switch (value) { - case 1: - case 2: - return "png"; - case 3: - return "json"; - default: - throw ArgumentError("Invalid value for IEnum: `$value`"); - } - } -} diff --git a/lib/src/core/message/unicode_emoji.dart b/lib/src/core/message/unicode_emoji.dart deleted file mode 100644 index 62fa12736..000000000 --- a/lib/src/core/message/unicode_emoji.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:nyxx/src/core/message/emoji.dart'; - -abstract class IUnicodeEmoji implements IEmoji { - /// Codepoint for emoji - String get code; -} - -/// Represents unicode emoji. Contains only emoji code. -class UnicodeEmoji implements IUnicodeEmoji, IEmoji { - /// Codepoint for emoji - @override - final String code; - - /// Constructs new Unicode emoji from given [String] - UnicodeEmoji(this.code); - - @override - String formatForMessage() => code; - - @override - String encodeForAPI() => code; - - @override - String toString() => formatForMessage(); -} diff --git a/lib/src/core/permissions/permission_overrides.dart b/lib/src/core/permissions/permission_overrides.dart deleted file mode 100644 index 33610218f..000000000 --- a/lib/src/core/permissions/permission_overrides.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/permissions/permissions.dart'; -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/permissions_builder.dart'; - -abstract class IPermissionsOverrides implements SnowflakeEntity, Convertable { - /// Type of entity. Either 0 (role) or 1 (member) - int get type; - - /// Permissions - Permissions get permissions; - - /// Value of permissions allowed - int get allow; - - /// Value of permissions denied - int get deny; -} - -/// Holds permissions overrides for channel -class PermissionsOverrides extends SnowflakeEntity implements IPermissionsOverrides { - /// Type of entity. Either 0 (role) or 1 (member) - @override - late final int type; - - /// Permissions - @override - late final Permissions permissions; - - /// Value of permissions allowed - @override - late final int allow; - - /// Value of permissions denied - @override - late final int deny; - - /// Creates an instance of [PermissionsOverrides] - PermissionsOverrides(RawApiMap raw) : super(Snowflake(raw["id"])) { - allow = int.parse(raw["allow"] as String); - deny = int.parse(raw["deny"] as String); - - permissions = Permissions.fromOverwrite(0, allow, deny); - type = raw["type"] as int; - } - - @override - PermissionOverrideBuilder toBuilder() => PermissionOverrideBuilder.from(type, id, permissions); -} diff --git a/lib/src/core/permissions/permissions.dart b/lib/src/core/permissions/permissions.dart deleted file mode 100644 index e6523f1b1..000000000 --- a/lib/src/core/permissions/permissions.dart +++ /dev/null @@ -1,333 +0,0 @@ -import 'package:nyxx/src/core/permissions/permissions_constants.dart'; -import 'package:nyxx/src/internal/interfaces/convertable.dart'; -import 'package:nyxx/src/utils/builders/permissions_builder.dart'; -import 'package:nyxx/src/utils/permissions.dart'; - -abstract class IPermissions implements Convertable { - /// The raw permission code. - late final int raw; - - /// True if user can create InstantInvite. - bool get createInstantInvite; - - /// True if user can kick members. - bool get kickMembers; - - /// True if user can ban members. - bool get banMembers; - - /// True if user is administrator. - bool get administrator; - - /// True if user can manager channels. - bool get manageChannels; - - /// True if user can manager guilds. - bool get manageGuild; - - /// Allows to add reactions. - bool get addReactions; - - /// Allow to view audit logs. - bool get viewAuditLog; - - /// Allows for using priority speaker in a voice channel. - bool get prioritySpeaker; - - /// Allows the user to go live. - bool get stream; - - /// Allow viewing channels (OLD READ_MESSAGES) - bool get viewChannel; - - /// True if user can send messages. - bool get sendMessages; - - /// True if user can send TTS messages. - bool get sendTtsMessages; - - /// True if user can manage messages. - bool get manageMessages; - - /// True if user can send links in messages. - bool get embedLinks; - - /// True if user can attach files in messages. - bool get attachFiles; - - /// True if user can read messages history. - bool get readMessageHistory; - - /// True if user can mention everyone. - bool get mentionEveryone; - - /// True if user can use external emojis. - bool get useExternalEmojis; - - /// Allows for viewing guild insights. - bool get viewGuildInsights; - - /// True if user can connect to voice channel. - bool get connect; - - /// True if user can speak. - bool get speak; - - /// True if user can mute members. - bool get muteMembers; - - /// True if user can deafen members. - bool get deafenMembers; - - /// True if user can move members. - bool get moveMembers; - - /// Allows for using voice-activity-detection in a voice channel. - bool get useVad; - - /// True if user can change nick. - bool get changeNickname; - - /// True if user can manager others nicknames. - bool get manageNicknames; - - /// True if user can manage server's roles. - bool get manageRoles; - - /// True if user can manage webhooks. - bool get manageWebhooks; - - /// Allows management and editing of emojis and stickers. - bool get manageEmojisAndStickers; - - /// Allows for creating, editing, and deleting scheduled events. - bool get manageEvents; - - /// Allows members to use slash commands in text channels. - bool get useSlashCommands; - - /// Allows for requesting to speak in stage channels. - bool get requestToSpeak; - - /// Allows for deleting and archiving threads, and viewing all private threads. - bool get manageThreads; - - /// Allows for creating and participating in threads. - bool get createPublicThreads; - - /// Allows for creating and participating in private threads. - bool get createPrivateThreads; - - /// Allows the usage of custom stickers from other servers. - bool get useExternalStickers; - - /// True if user can send messages in threads. - bool get sendMessagesInThreads; - - /// Allows for launching activities in a voice channel. - bool get startEmbeddedActivities; - - /// Allows for timing out users to prevent them from sending or reacting to messages in chat and threads, and from speaking in voice and stage channels. - bool get moderateMembers; - - /// Returns true if this permissions has [permission] - bool hasPermission(int permission); -} - -/// Permissions for a role or channel override. -class Permissions implements IPermissions { - /// The raw permission code. - @override - late final int raw; - - /// True if user can create InstantInvite. - @override - bool get createInstantInvite => PermissionsUtils.isApplied(raw, PermissionsConstants.createInstantInvite); - - /// True if user can kick members. - @override - bool get kickMembers => PermissionsUtils.isApplied(raw, PermissionsConstants.kickMembers); - - /// True if user can ban members. - @override - bool get banMembers => PermissionsUtils.isApplied(raw, PermissionsConstants.banMembers); - - /// True if user is administrator. - @override - bool get administrator => PermissionsUtils.isApplied(raw, PermissionsConstants.administrator); - - /// True if user can manager channels. - @override - bool get manageChannels => PermissionsUtils.isApplied(raw, PermissionsConstants.manageChannels); - - /// True if user can manager guilds. - @override - bool get manageGuild => PermissionsUtils.isApplied(raw, PermissionsConstants.manageGuild); - - /// Allows to add reactions. - @override - bool get addReactions => PermissionsUtils.isApplied(raw, PermissionsConstants.addReactions); - - /// Allows for using priority speaker in a voice channel. - @override - bool get prioritySpeaker => PermissionsUtils.isApplied(raw, PermissionsConstants.prioritySpeaker); - - /// Allow to view audit logs. - @override - bool get viewAuditLog => PermissionsUtils.isApplied(raw, PermissionsConstants.viewAuditLog); - - /// Allow viewing channels (OLD READ_MESSAGES) - @override - bool get viewChannel => PermissionsUtils.isApplied(raw, PermissionsConstants.viewChannel); - - /// True if user can send messages. - @override - bool get sendMessages => PermissionsUtils.isApplied(raw, PermissionsConstants.sendMessages); - - /// True if user can send messages in threads. - @override - bool get sendMessagesInThreads => PermissionsUtils.isApplied(raw, PermissionsConstants.sendMessagesInThreads); - - /// True if user can send TTF messages. - @override - bool get sendTtsMessages => PermissionsUtils.isApplied(raw, PermissionsConstants.sendTtsMessages); - - /// True if user can manage messages. - @override - bool get manageMessages => PermissionsUtils.isApplied(raw, PermissionsConstants.manageMessages); - - /// True if user can send links in messages. - @override - bool get embedLinks => PermissionsUtils.isApplied(raw, PermissionsConstants.embedLinks); - - /// True if user can attach files in messages. - @override - bool get attachFiles => PermissionsUtils.isApplied(raw, PermissionsConstants.attachFiles); - - /// True if user can read messages history. - @override - bool get readMessageHistory => PermissionsUtils.isApplied(raw, PermissionsConstants.readMessageHistory); - - /// True if user can mention everyone. - @override - bool get mentionEveryone => PermissionsUtils.isApplied(raw, PermissionsConstants.mentionEveryone); - - /// True if user can use external emojis. - @override - bool get useExternalEmojis => PermissionsUtils.isApplied(raw, PermissionsConstants.useExternalEmojis); - - /// True if user can connect to voice channel. - @override - bool get connect => PermissionsUtils.isApplied(raw, PermissionsConstants.connect); - - /// True if user can speak. - @override - bool get speak => PermissionsUtils.isApplied(raw, PermissionsConstants.speak); - - /// True if user can mute members. - @override - bool get muteMembers => PermissionsUtils.isApplied(raw, PermissionsConstants.muteMembers); - - /// True if user can deafen members. - @override - bool get deafenMembers => PermissionsUtils.isApplied(raw, PermissionsConstants.deafenMembers); - - /// True if user can move members. - @override - bool get moveMembers => PermissionsUtils.isApplied(raw, PermissionsConstants.moveMembers); - - /// Allows for using voice-activity-detection in a voice channel. - @override - bool get useVad => PermissionsUtils.isApplied(raw, PermissionsConstants.useVad); - - /// True if user can change nick. - @override - bool get changeNickname => PermissionsUtils.isApplied(raw, PermissionsConstants.changeNickname); - - /// True if user can manager others nicknames. - @override - bool get manageNicknames => PermissionsUtils.isApplied(raw, PermissionsConstants.manageNicknames); - - /// True if user can manage server's roles. - @override - bool get manageRoles => PermissionsUtils.isApplied(raw, PermissionsConstants.manageRoles); - - /// True if user can manage webhooks. - @override - bool get manageWebhooks => PermissionsUtils.isApplied(raw, PermissionsConstants.manageWebhooks); - - @override - bool get manageEmojisAndStickers => PermissionsUtils.isApplied(raw, PermissionsConstants.manageEmojisAndStickers); - - /// Allows the user to go live. - @override - bool get stream => PermissionsUtils.isApplied(raw, PermissionsConstants.stream); - - /// Allows for viewing guild insights. - @override - bool get viewGuildInsights => PermissionsUtils.isApplied(raw, PermissionsConstants.viewGuildInsights); - - /// Allows members to use slash commands in text channels. - @override - bool get useSlashCommands => PermissionsUtils.isApplied(raw, PermissionsConstants.useSlashCommands); - - /// Allows for requesting to speak in stage channels. - @override - bool get requestToSpeak => PermissionsUtils.isApplied(raw, PermissionsConstants.useSlashCommands); - - /// Allows for deleting and archiving threads, and viewing all private threads. - @override - bool get manageThreads => PermissionsUtils.isApplied(raw, PermissionsConstants.manageThreads); - - /// Allows for creating and participating in threads. - @override - bool get createPublicThreads => PermissionsUtils.isApplied(raw, PermissionsConstants.createPublicThreads); - - /// Allows for creating and participating in private threads. - @override - bool get createPrivateThreads => PermissionsUtils.isApplied(raw, PermissionsConstants.createPrivateThreads); - - /// Allows the usage of custom stickers from other servers. - @override - bool get useExternalStickers => PermissionsUtils.isApplied(raw, PermissionsConstants.useExternalStickers); - - /// Allows for launching activities in a voice channel. - @override - bool get startEmbeddedActivities => PermissionsUtils.isApplied(raw, PermissionsConstants.startEmbeddedActivities); - - @override - bool get moderateMembers => PermissionsUtils.isApplied(raw, PermissionsConstants.moderateMembers); - - @override - bool get manageEvents => PermissionsUtils.isApplied(raw, PermissionsConstants.manageEvents); - - /// Creates an instance of [Permissions] - Permissions(this.raw); - - /// Permissions with value of 0. - factory Permissions.empty() => Permissions(0); - - /// Permissions with max value. - factory Permissions.all() => Permissions(PermissionsConstants.allPermissions); - - /// Makes a [Permissions] object from overwrite object. - factory Permissions.fromOverwrite(int permissions, int allow, int deny) => Permissions(PermissionsUtils.apply(permissions, allow, deny)); - - /// Returns true if this permissions has [permission] - @override - bool hasPermission(int permission) => PermissionsUtils.isApplied(raw, permission); - - @override - int get hashCode => raw.hashCode; - - @override - bool operator ==(dynamic other) { - if (other is Permissions) return other.raw == raw; - if (other is int) return other == raw; - - return false; - } - - @override - PermissionsBuilder toBuilder() => PermissionsBuilder.from(this); -} diff --git a/lib/src/core/permissions/permissions_constants.dart b/lib/src/core/permissions/permissions_constants.dart deleted file mode 100644 index e06018c82..000000000 --- a/lib/src/core/permissions/permissions_constants.dart +++ /dev/null @@ -1,168 +0,0 @@ -/// Permissions constants -class PermissionsConstants { - /// Allows to create instant invite. - static const int createInstantInvite = 1 << 0; - - /// Allows to kick members. - static const int kickMembers = 1 << 1; - - /// Allows to ban members. - static const int banMembers = 1 << 2; - - /// Given to administrator. - static const int administrator = 1 << 3; - - /// Allows to manage channels(renaming, changing permissions) - static const int manageChannels = 1 << 4; - - /// Allows to manager guild. - static const int manageGuild = 1 << 5; - - /// Allows for the addition of reactions to messages. - static const int addReactions = 1 << 6; - - /// Allows for viewing of audit logs. - static const int viewAuditLog = 1 << 7; - - /// Allows for using priority speaker in a voice channel. - static const int prioritySpeaker = 1 << 8; - - /// Allows the user to go live. - static const int stream = 1 << 9; - - /// Allows guild members to view a channel, which includes reading messages in text channels. - static const int viewChannel = 1 << 10; - - /// Allows to send messages. - static const int sendMessages = 1 << 11; - - /// Allows to send TTS messages. - static const int sendTtsMessages = 1 << 12; - - /// Allows to deletes, edit messages. - static const int manageMessages = 1 << 13; - - /// Links sent by users with this permission will be auto-embedded. - static const int embedLinks = 1 << 14; - - /// Allows for uploading images and files. - static const int attachFiles = 1 << 15; - - /// Allows for reading of message history. - static const int readMessageHistory = 1 << 16; - - /// Allows for using the @everyone tag to notify all users in a channel, and the @here tag to notify all online users in a channel. - static const int mentionEveryone = 1 << 17; - - /// Allows the usage of custom emojis from other servers. - static const int useExternalEmojis = 1 << 18; - - /// Allows for viewing guild insights. - static const int viewGuildInsights = 1 << 19; - - /// Allows for joining of a voice channel. - static const int connect = 1 << 20; - - /// Allows for speaking in a voice channel. - static const int speak = 1 << 21; - - /// Allows for muting members in a voice channel. - static const int muteMembers = 1 << 22; - - /// Allows for deafening of members in a voice channel. - static const int deafenMembers = 1 << 23; - - /// Allows for moving of members between voice channels. - static const int moveMembers = 1 << 24; - - /// Allows for using voice-activity-detection in a voice channel. - static const int useVad = 1 << 25; - - /// Allows for modification of own nickname. - static const int changeNickname = 1 << 26; - - /// Allows for modification of other users nicknames. - static const int manageNicknames = 1 << 27; - - /// Allows management and editing of roles. - static const int manageRoles = 1 << 28; - - /// Allows management and editing of webhooks. - static const int manageWebhooks = 1 << 29; - - static const int manageEmojisAndStickers = 1 << 30; - - /// Allows members to use slash commands in text channels. - static const int useSlashCommands = 1 << 31; - - /// Allows for requesting to speak in stage channels. - static const int requestToSpeak = 1 << 32; - - /// Allows for creating, editing, and deleting scheduled events. - static const int manageEvents = 1 << 33; - - /// Allows for deleting and archiving threads, and viewing all private threads. - static const int manageThreads = 1 << 34; - - /// Allows for creating and participating in threads. - static const int createPublicThreads = 1 << 35; - - /// Allows for creating and participating in private threads. - static const int createPrivateThreads = 1 << 36; - - /// Allows the usage of custom stickers from other servers. - static const int useExternalStickers = 1 << 37; - - /// Allows to send messages in threads. - static const int sendMessagesInThreads = 1 << 38; - - /// Allows for launching activities in a voice channel. - static const int startEmbeddedActivities = 1 << 39; - - /// Allows for timing out users to prevent them from sending or reacting to messages in chat and threads, and from speaking in voice and stage channels. - static const int moderateMembers = 1 << 40; - - /// All of the permissions. - static int get allPermissions => - createInstantInvite | - kickMembers | - banMembers | - administrator | - manageChannels | - manageGuild | - addReactions | - viewAuditLog | - prioritySpeaker | - stream | - viewChannel | - sendMessages | - sendTtsMessages | - manageMessages | - embedLinks | - attachFiles | - readMessageHistory | - mentionEveryone | - useExternalEmojis | - viewGuildInsights | - connect | - speak | - muteMembers | - deafenMembers | - moveMembers | - useVad | - changeNickname | - manageNicknames | - manageRoles | - manageWebhooks | - manageEmojisAndStickers | - useSlashCommands | - requestToSpeak | - manageEvents | - manageThreads | - createPublicThreads | - createPrivateThreads | - useExternalStickers | - sendMessagesInThreads | - startEmbeddedActivities | - moderateMembers; -} diff --git a/lib/src/core/snowflake.dart b/lib/src/core/snowflake.dart deleted file mode 100644 index 9445182ea..000000000 --- a/lib/src/core/snowflake.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/internal/exceptions/invalid_snowflake_exception.dart'; - -/// [Snowflake] represents id system used by Discord. -/// [id] property is actual id of entity which holds [Snowflake]. -class Snowflake implements Comparable { - /// START OF DISCORD EPOCH - static const discordEpoch = 1420070400000; - - /// Offset of date in snowflake - static const snowflakeDateOffset = 1 << 22; - - final int _id; - - /// Full snowflake id - int get id => _id; - - /// Returns timestamp included in [Snowflake] - /// [Snowflake reference](https://discordapp.com/developers/docs/reference#snowflakes) - DateTime get timestamp => DateTime.fromMillisecondsSinceEpoch((_id >> 22).toInt() + discordEpoch, isUtc: true); - - /// Returns true if snowflake is zero - bool get isZero => id == 0; - - /// Creates new instance of [Snowflake]. - const Snowflake.value(this._id); - - /// Creates new instance with value of 0 - const Snowflake.zero() : _id = 0; - - /// Creates instance of a Snowflake - factory Snowflake(dynamic id) { - if (id is int) { - return Snowflake.value(id); - } else { - try { - return Snowflake.value(int.parse(id.toString())); - } on FormatException { - throw InvalidSnowflakeException(id); - } - } - } - - /// Creates synthetic snowflake based on current time - Snowflake.fromNow() : _id = _parseId(DateTime.now()); - - /// Creates first snowflake which can be deleted by `bulk-delete messages` - Snowflake.bulk() : _id = _parseId(DateTime.now().subtract(const Duration(days: 14))); - - /// Creates synthetic snowflake based on given [date]. - Snowflake.fromDateTime(DateTime date) : _id = _parseId(date); - - /// Returns [SnowflakeEntity] from current [Snowflake] - SnowflakeEntity toSnowflakeEntity() => SnowflakeEntity(this); - - /// Checks if given [Snowflake] [s] is created before this instance - bool isBefore(Snowflake s) => timestamp.isBefore(s.timestamp); - - /// Checks if given [Snowflake] [s] is created after this instance - bool isAfter(Snowflake s) => timestamp.isAfter(s.timestamp); - - /// Compares two Snowflakes based on creation date - static int compareDates(Snowflake first, Snowflake second) => first.timestamp.compareTo(second.timestamp); - - // Parses id from dateTime - static int _parseId(DateTime timestamp) => (timestamp.millisecondsSinceEpoch - discordEpoch) * snowflakeDateOffset; - - @override - String toString() => _id.toString(); - - @override - bool operator ==(dynamic other) { - if (other is Snowflake) return other.id == _id; - if (other is int) return other == _id; - if (other is String) return other == _id.toString(); - if (other is SnowflakeEntity) return other.id == _id; - - return false; - } - - @override - int get hashCode => _id.hashCode; - - @override - int compareTo(Snowflake other) => compareDates(this, other); -} diff --git a/lib/src/core/snowflake_entity.dart b/lib/src/core/snowflake_entity.dart deleted file mode 100644 index 9fce9d316..000000000 --- a/lib/src/core/snowflake_entity.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; - -/// Marks a snowflake entity. Snowflake entities are ones that have an id that uniquely identifies them. -/// Includes only actual id of entity and [createdAt] which is timestamp when entity was created. -class SnowflakeEntity { - /// ID of entity as Snowflake - final Snowflake id; - - /// Creates new snowflake - const SnowflakeEntity(this.id); - - /// Gets creation timestamp included in [Snowflake] - DateTime get createdAt => id.timestamp; - - @override - int get hashCode => id.hashCode; - - @override - String toString() => id.toString(); - - @override - bool operator ==(dynamic other) { - if (other is SnowflakeEntity) return id == other.id; - if (other is Snowflake) return id == other; - if (other is String) return id.id.toString() == other; - if (other is int) return id.id == other; - - return false; - } -} diff --git a/lib/src/core/user/member.dart b/lib/src/core/user/member.dart deleted file mode 100644 index 24f8ad688..000000000 --- a/lib/src/core/user/member.dart +++ /dev/null @@ -1,262 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/permissions/permissions.dart'; -import 'package:nyxx/src/core/permissions/permissions_constants.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/core/voice/voice_state.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/internal/interfaces/mentionable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/member_builder.dart'; -import 'package:nyxx/src/utils/permissions.dart'; -import 'package:nyxx/src/core/user/member_flags.dart'; - -abstract class IMember implements SnowflakeEntity, Mentionable { - /// Reference to client - INyxx get client; - - /// [Cacheable] for this [Guild] member - Cacheable get user; - - /// The members nickname, null if not set. - String? get nickname; - - /// When the member joined the guild. - DateTime get joinedAt; - - /// Weather or not the member is deafened. - bool get deaf; - - /// Weather or not the member is muted. - bool get mute; - - /// Cacheable of guild where member is located - Cacheable get guild; - - /// Roles of member - Iterable> get roles; - - /// When the user starting boosting the guild - DateTime? get boostingSince; - - /// Member's avatar in [IGuild] - String? get avatarHash; - - /// Voice state of member. Null if not connected to channel or voice state not cached - IVoiceState? get voiceState; - - /// The channel's mention string. - @override - String get mention; - - /// Returns total permissions of user. - Future get effectivePermissions; - - /// When the user's timeout will expire and the user will be able to communicate in the guild again, null or a time in the past if the user is not timed out - DateTime? get timeoutUntil; - - /// True if user is timed out - bool get isTimedOut; - - /// True if member is currently pending by [Membership Screening](https://discord.com/developers/docs/resources/guild#membership-screening-object). - /// When completed, an [IGuildMemberUpdateEvent] will be fired with [isPending] set to `false`. - bool get isPending; - - /// [Guild member flags](https://discord.com/developers/docs/resources/guild#guild-member-object-guild-member-flags) - IMemberFlags get flags; - - /// The member's avatar, represented as URL. With given [format] and [size]. - /// If [animated] is set as `true`, if available, the url will be a gif, otherwise the [format] or fallback to "webp". - String? avatarUrl({String format = 'webp', int? size, bool animated = false}); - - /// Bans the member and optionally deletes [deleteMessageDays] days worth of messages. - Future ban({int? deleteMessageDays, String? reason, String? auditReason}); - - /// Adds role to user. - /// - /// ``` - /// var r = guild.roles.values.first; - /// await member.addRole(r); - /// ``` - Future addRole(SnowflakeEntity role, {String? auditReason}); - - /// Removes [role] from user. - Future removeRole(SnowflakeEntity role, {String? auditReason}); - - /// Kicks the member from guild - Future kick({String? auditReason}); - - /// Edits members. Allows to move user in voice channel, mute or deaf, change nick, roles. - Future edit({required MemberBuilder builder, String? auditReason}); -} - -class Member extends SnowflakeEntity implements IMember { - /// Reference to client - @override - final INyxx client; - - /// [Cacheable] for this [Guild] member - @override - late final Cacheable user; - - /// The members nickname, null if not set. - @override - String? nickname; - - /// When the member joined the guild. - @override - late final DateTime joinedAt; - - /// Weather or not the member is deafened. - @override - late final bool deaf; - - /// Weather or not the member is muted. - @override - late final bool mute; - - /// Cacheable of guild where member is located - @override - late final Cacheable guild; - - /// Roles of member - @override - late Iterable> roles; - - /// When the user starting boosting the guild - @override - late DateTime? boostingSince; - - /// Member's avatar in [Guild] - @override - late final String? avatarHash; - - /// Voice state of member. Null if not connected to channel or voice state not cached - @override - IVoiceState? get voiceState => guild.getFromCache()?.voiceStates[id]; - - /// The channel's mention string. - @override - String get mention => "<@$id>"; - - @override - bool get isTimedOut => timeoutUntil != null && timeoutUntil!.isAfter(DateTime.now()); - - @override - late final DateTime? timeoutUntil; - - /// Returns total permissions of user. - @override - Future get effectivePermissions async { - final guildInstance = await guild.getOrDownload(); - final owner = await guildInstance.owner.getOrDownload(); - if (id == owner.id) { - return Permissions.all(); - } - - var total = guildInstance.everyoneRole.permissions.raw; - for (final role in roles) { - final roleInstance = await role.getOrDownload(); - - total |= roleInstance.permissions.raw; - - if (PermissionsUtils.isApplied(total, PermissionsConstants.administrator)) { - return Permissions(PermissionsConstants.allPermissions); - } - } - - return Permissions(total); - } - - @override - late final bool isPending; - - @override - late final IMemberFlags flags; - - /// Creates an instance of [Member] - Member(this.client, RawApiMap raw, Snowflake guildId) : super(Snowflake(raw["user"]["id"])) { - nickname = raw["nick"] as String?; - deaf = raw["deaf"] as bool? ?? false; - mute = raw["mute"] as bool? ?? false; - user = UserCacheable(client, id); - guild = GuildCacheable(client, guildId); - boostingSince = DateTime.tryParse(raw["premium_since"] as String? ?? ""); - avatarHash = raw["avatar"] as String?; - timeoutUntil = raw['communication_disabled_until'] != null ? DateTime.parse(raw['communication_disabled_until'] as String) : null; - - roles = [for (var id in raw["roles"] as RawApiList) RoleCacheable(client, Snowflake(id), guild)]; - - joinedAt = DateTime.parse(raw["joined_at"] as String).toUtc(); - - if (client.cacheOptions.userCachePolicyLocation.objectConstructor) { - final userRaw = raw["user"] as RawApiMap; - - if (userRaw["id"] != null && userRaw.length != 1) { - client.users[id] = User(client, userRaw); - } - } - - isPending = (raw['pending'] as bool?) ?? false; - flags = MemberFlags(raw['flags'] as int); - } - - /// Returns url to member avatar - @override - String? avatarUrl({String format = 'webp', int? size, bool animated = false}) { - if (avatarHash == null) { - return null; - } - - return client.cdnHttpEndpoints.memberAvatar(guild.id, id, avatarHash!, format: format, size: size, animated: animated); - } - - /// Bans the member and optionally deletes [deleteMessageDays] days worth of messages. - @override - Future ban({int? deleteMessageDays, String? reason, String? auditReason}) async => - client.httpEndpoints.guildBan(guild.id, id, auditReason: auditReason); - - /// Adds role to user. - /// - /// ``` - /// var r = guild.roles.values.first; - /// await member.addRole(r); - /// ``` - @override - Future addRole(SnowflakeEntity role, {String? auditReason}) => client.httpEndpoints.addRoleToUser(guild.id, role.id, id, auditReason: auditReason); - - /// Removes [role] from user. - @override - Future removeRole(SnowflakeEntity role, {String? auditReason}) => - client.httpEndpoints.removeRoleFromUser(guild.id, role.id, id, auditReason: auditReason); - - /// Kicks the member from guild - @override - Future kick({String? auditReason}) => client.httpEndpoints.guildKick(guild.id, id); - - /// Edits members. Allows to move user in voice channel, mute or deaf, change nick, roles. - @override - Future edit({required MemberBuilder builder, String? auditReason}) => - client.httpEndpoints.editGuildMember(guild.id, id, builder: builder, auditReason: auditReason); - - void updateMember(String? nickname, List roles, DateTime? boostingSince) { - if (this.nickname != nickname) { - this.nickname = nickname; - } - - // Check if new collection has different length (removing adding new roles) or if every role is same - // as in old collection (removing and adding new roles at the same time) - if (this.roles.length != roles.length || !this.roles.every((role) => roles.contains(role.id))) { - this.roles = roles.map((e) => RoleCacheable(client, e, guild)); - } - - if (this.boostingSince == null && boostingSince != null) { - this.boostingSince = boostingSince; - } - } -} diff --git a/lib/src/core/user/member_flags.dart b/lib/src/core/user/member_flags.dart deleted file mode 100644 index 0b2a0a2a6..000000000 --- a/lib/src/core/user/member_flags.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:nyxx/src/utils/permissions.dart'; - -/// The flags associated with a member. -abstract class IMemberFlags { - /// Whether the member has left and rejoined the guild. - bool get didRejoin; - - /// Whether the member has completed onboarding. - bool get completedOnboarding; - - /// Whether the member is exempt from guild verification requirements. - bool get bypassesVerification; - - /// Wether the member has started onboarding. - bool get startedOnBoarding; -} - -class MemberFlags implements IMemberFlags { - @override - bool get didRejoin => PermissionsUtils.isApplied(raw, 1 << 0); - - @override - bool get completedOnboarding => PermissionsUtils.isApplied(raw, 1 << 1); - - @override - bool get bypassesVerification => PermissionsUtils.isApplied(raw, 1 << 2); - - @override - bool get startedOnBoarding => PermissionsUtils.isApplied(raw, 1 << 3); - - final int raw; - - const MemberFlags(this.raw); - - @override - String toString() => 'MemberFlags(didRejoin: $didRejoin,' - ' completedOnboarding: $completedOnboarding, bypassesVerification: $bypassesVerification, startedOnBoarding: $startedOnBoarding)'; -} diff --git a/lib/src/core/user/nitro_type.dart b/lib/src/core/user/nitro_type.dart deleted file mode 100644 index ca714d8cf..000000000 --- a/lib/src/core/user/nitro_type.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:nyxx/src/utils/enum.dart'; - -///Premium types denote the level of premium a user has. -class NitroType extends IEnum { - static const NitroType none = NitroType._create(0); - static const NitroType classic = NitroType._create(1); - static const NitroType nitro = NitroType._create(2); - - /// Creates [NitroType] from [value]. [value] is 0 by default. - NitroType.from(int? value) : super(value ?? 0); - const NitroType._create(int? value) : super(value ?? 0); - - @override - bool operator ==(dynamic other) { - if (other is int) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} diff --git a/lib/src/core/user/presence.dart b/lib/src/core/user/presence.dart deleted file mode 100644 index 6c07e1938..000000000 --- a/lib/src/core/user/presence.dart +++ /dev/null @@ -1,527 +0,0 @@ -import 'package:nyxx/src/core/guild/status.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/enum.dart'; -import 'package:nyxx/src/utils/permissions.dart'; -import 'package:nyxx/src/core/user/user.dart'; - -abstract class IActivity { - /// The activity name. - String get name; - - /// The activity type. - ActivityType get type; - - /// The game URL, if provided. - String? get url; - - /// Timestamp of when the activity was added to the user's session - DateTime get createdAt; - - /// Timestamps for start and/or end of the game - IActivityTimestamps? get timestamps; - - /// Application id for the game - Snowflake? get applicationId; - - /// What the player is currently doing - String? get details; - - /// The user's current party status - String? get state; - - /// The emoji used for a custom status - IActivityEmoji? get customStatusEmoji; - - /// Information for the current party of the player - IActivityParty? get party; - - /// Images for the presence and their hover texts - IGameAssets? get assets; - - /// Secrets for Rich Presence joining and spectating - IGameSecrets? get secrets; - - /// Whether or not the activity is an instanced game session - bool? get instance; - - /// Activity flags ORd together, describes what the payload includes - IActivityFlags get activityFlags; - - /// Activity buttons. List of button labels - Iterable get buttons; - - /// Reference to [INyxx]. - INyxx get client; -} - -/// Presence is game or activity which user is playing/user participate. -/// Can be game, eg. Dota 2, VS Code or activity like Listening to song on Spotify. -class Activity implements IActivity { - /// The activity name. - @override - late final String name; - - /// The activity type. - @override - late final ActivityType type; - - /// The game URL, if provided. - @override - late final String? url; - - /// Timestamp of when the activity was added to the user's session - @override - late final DateTime createdAt; - - /// Timestamps for start and/or end of the game - @override - late final ActivityTimestamps? timestamps; - - /// Application id for the game - @override - late final Snowflake? applicationId; - - /// What the player is currently doing - @override - late final String? details; - - /// The user's current party status - @override - late final String? state; - - /// The emoji used for a custom status - @override - late final ActivityEmoji? customStatusEmoji; - - /// Information for the current party of the player - @override - late final ActivityParty? party; - - /// Images for the presence and their hover texts - @override - late final GameAssets? assets; - - /// Secrets for Rich Presence joining and spectating - @override - late final GameSecrets? secrets; - - /// Whether or not the activity is an instanced game session - @override - late final bool? instance; - - /// Activity flags ORd together, describes what the payload includes - @override - late final ActivityFlags activityFlags; - - /// Activity buttons. List of button labels - @override - late final Iterable buttons; - - @override - final INyxx client; - - /// Creates an instance of [Activity] - Activity(RawApiMap raw, this.client) { - name = raw["name"] as String; - url = raw["url"] as String?; - type = ActivityType.from(raw["type"] as int); - createdAt = DateTime.fromMillisecondsSinceEpoch(raw["created_at"] as int); - details = raw["details"] as String?; - state = raw["state"] as String?; - - if (raw["timestamps"] != null) { - timestamps = ActivityTimestamps(raw["timestamps"] as RawApiMap); - } else { - timestamps = null; - } - - if (raw["application_id"] != null) { - applicationId = Snowflake(raw["application_id"]); - } else { - applicationId = null; - } - - if (raw["emoji"] != null) { - customStatusEmoji = ActivityEmoji(raw["emoji"] as RawApiMap); - } else { - customStatusEmoji = null; - } - - if (raw["party"] != null) { - party = ActivityParty(raw["party"] as RawApiMap); - } else { - party = null; - } - - if (raw["assets"] != null) { - assets = GameAssets(raw["assets"] as RawApiMap, this); - } else { - assets = null; - } - - if (raw["secrets"] != null) { - secrets = GameSecrets(raw["secrets"] as RawApiMap); - } else { - secrets = null; - } - - instance = raw["instance"] as bool?; - activityFlags = ActivityFlags(raw["flags"] as int?); - buttons = [if (raw["buttons"] != null) ...(raw["buttons"] as RawApiList).cast()]; - } -} - -abstract class IActivityFlags { - /// Flags value - int get value; - - bool get isInstance; - bool get isJoin; - bool get isSpectate; - bool get isJoinRequest; - bool get isSync; - bool get isPlay; -} - -/// Flags of the activity -class ActivityFlags implements IActivityFlags { - /// Flags value - @override - late final int value; - - @override - bool get isInstance => PermissionsUtils.isApplied(value, 1 << 0); - @override - bool get isJoin => PermissionsUtils.isApplied(value, 1 << 1); - @override - bool get isSpectate => PermissionsUtils.isApplied(value, 1 << 2); - @override - bool get isJoinRequest => PermissionsUtils.isApplied(value, 1 << 3); - @override - bool get isSync => PermissionsUtils.isApplied(value, 1 << 4); - @override - bool get isPlay => PermissionsUtils.isApplied(value, 1 << 5); - - /// Creates an instance of [ActivityFlags] - ActivityFlags(int? value) { - this.value = value ?? 0; - } -} - -abstract class IActivityEmoji { - /// Id of emoji. - Snowflake? get id; - - /// True if emoji is animated - bool get animated; -} - -/// Represent emoji within activity -class ActivityEmoji implements IActivityEmoji { - /// Id of emoji. - @override - late final Snowflake? id; - - /// True if emoji is animated - @override - late final bool animated; - - /// Creates an instance of [ActivityEmoji] - ActivityEmoji(RawApiMap raw) { - if (raw["id"] != null) { - id = Snowflake(raw["id"]); - } - - if (raw["animated"] != null) { - animated = raw["animated"] as bool? ?? false; - } - } -} - -abstract class IActivityTimestamps { - /// DateTime when activity started - DateTime? get start; - - /// DateTime when activity ends - DateTime? get end; -} - -/// Timestamp of activity -class ActivityTimestamps implements IActivityTimestamps { - /// DateTime when activity started - @override - late final DateTime? start; - - /// DateTime when activity ends - @override - late final DateTime? end; - - /// Creates an instance of [ActivityTimestamps] - ActivityTimestamps(RawApiMap raw) { - if (raw["start"] != null) { - start = DateTime.fromMillisecondsSinceEpoch(raw["start"] as int); - } - - if (raw["end"] != null) { - end = DateTime.fromMillisecondsSinceEpoch(raw["end"] as int); - } - } -} - -/// Represents type of presence activity -class ActivityType extends IEnum { - /// Status type when playing a game - static const ActivityType game = ActivityType._create(0); - - /// Status type when streaming a game. Only supports twitch.tv or youtube.com url - static const ActivityType streaming = ActivityType._create(1); - - /// Status type when listening to Spotify - static const ActivityType listening = ActivityType._create(2); - - /// Status type when watching - static const ActivityType watching = ActivityType._create(3); - - /// Custom status, not supported for bot accounts - static const ActivityType custom = ActivityType._create(4); - - /// Competing in something - static const ActivityType competing = ActivityType._create(5); - - /// Creates [ActivityType] from [value] - ActivityType.from(int value) : super(value); - const ActivityType._create(int value) : super(value); - - @override - bool operator ==(dynamic other) { - if (other is int) { - return other == value; - } - - return super == other; - } - - @override - int get hashCode => value.hashCode; -} - -abstract class IActivityParty { - /// Party id. - String? get id; - - /// Current size of party. - int? get currentSize; - - /// Max size of party. - int? get maxSize; -} - -/// Represents party of game. -class ActivityParty implements IActivityParty { - /// Party id. - @override - late final String? id; - - /// Current size of party. - @override - late final int? currentSize; - - /// Max size of party. - @override - late final int? maxSize; - - /// Creates an instance of [ActivityParty] - ActivityParty(RawApiMap raw) { - id = raw["id"] as String?; - - if (raw["size"] != null) { - currentSize = raw["size"].first as int; - maxSize = raw["size"].last as int; - } else { - currentSize = null; - maxSize = null; - } - } -} - -abstract class IGameAssets { - /// The id for a large asset of the activity, usually a snowflake. - String? get largeImage; - - /// Text displayed when hovering over the large image of the activity. - String? get largeText; - - /// The id for a small asset of the activity, usually a snowflake - String? get smallImage; - - /// Text displayed when hovering over the small image of the activity - String? get smallText; - - /// Reference to the [IActivity]. - IActivity get activity; - - /// Returns CDN URL to the small image. - String? smallImageUrl({String format = 'webp', int? size}); - - /// Returns CDN URL to the large image. - String? largeImageUrl({String format = 'webp', int? size}); -} - -/// Presences assets -class GameAssets implements IGameAssets { - /// The id for a large asset of the activity, usually a snowflake. - @override - late final String? largeImage; - - /// Text displayed when hovering over the large image of the activity. - @override - late final String? largeText; - - /// The id for a small asset of the activity, usually a snowflake - @override - late final String? smallImage; - - /// Text displayed when hovering over the small image of the activity - @override - late final String? smallText; - - @override - final IActivity activity; - - /// Creates an instance of [GameAssets] - GameAssets(RawApiMap raw, this.activity) { - largeImage = raw["large_image"] as String?; - largeText = raw["large_text"] as String?; - smallImage = raw["small_image"] as String?; - smallText = raw["small_text"] as String?; - } - - @override - String? smallImageUrl({String format = 'webp', int? size}) { - if (smallImage == null) { - return null; - } - if (smallImage!.contains(':')) { - final splittedSmallImage = smallImage!.split(':'); - - // The platform the user is currently on; e.g: spotify - switch (splittedSmallImage[0]) { - case 'mp': - return 'https://media.discordapp.com/${splittedSmallImage[1]}'; - default: - // Not related to discord - return null; - } - } - - return activity.client.cdnHttpEndpoints.appAsset(activity.applicationId!, smallImage!, format: format, size: size); - } - - @override - String? largeImageUrl({String format = 'webp', int? size}) { - if (largeImage == null) { - return null; - } - if (largeImage!.contains(':')) { - final splittedLargeImage = largeImage!.split(':'); - - // The platform the user is currently on; e.g: spotify - switch (splittedLargeImage[0]) { - case 'mp': - return 'https://media.discordapp.com/${splittedLargeImage[1]}'; - default: - // Not related to discord - return null; - } - } - - return activity.client.cdnHttpEndpoints.appAsset(activity.applicationId!, largeImage!, format: format, size: size); - } -} - -abstract class IGameSecrets { - /// Join secret - String get join; - - /// Spectate secret - String get spectate; - - /// Match secret - String get match; -} - -/// Represents presences secrets -class GameSecrets implements IGameSecrets { - /// Join secret - @override - late final String join; - - /// Spectate secret - @override - late final String spectate; - - /// Match secret - @override - late final String match; - - /// Creates an instance of [GameSecrets] - GameSecrets(RawApiMap raw) { - join = raw["join"] as String; - spectate = raw["spectate"] as String; - match = raw["match"] as String; - } -} - -abstract class IPartialPresence { - /// Reference to [INyxx] - INyxx get client; - - /// The [IPartialPresence]'s [IUser] - Cacheable? get user; - - /// The status of the user indicating the platform they are on. - IClientStatus? get clientStatus; - - /// The status of the user eg. online, idle, dnd, invisible, offline - UserStatus? get status; - - /// The activities of the user - List get activities; -} - -class PartialPresence implements IPartialPresence { - /// Reference to [INyxx] - @override - final INyxx client; - - /// The [IPartialPresence]'s [IUser] - @override - late final Cacheable? user; - - /// The status of the user indicating the platform they are on. - @override - late final IClientStatus? clientStatus; - - /// The status of the user eg. online, idle, dnd, invisible, offline - @override - late final UserStatus? status; - - /// The activities of the user - @override - late final List activities; - - /// Creates an instance of [PartialPresence] - PartialPresence(RawApiMap raw, this.client) { - user = raw["user"] != null ? UserCacheable(client, Snowflake(raw['user']['id'])) : null; - clientStatus = raw["client_status"] != null ? ClientStatus(raw["client_status"] as RawApiMap) : null; - status = raw["status"] != null ? UserStatus.from(raw["status"] as String) : null; - - activities = [ - if (raw['activities'] != null) - for (final activity in raw["activities"] as RawApiList) Activity(activity as RawApiMap, client) - ]; - } -} diff --git a/lib/src/core/user/user.dart b/lib/src/core/user/user.dart deleted file mode 100644 index e32b8f016..000000000 --- a/lib/src/core/user/user.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/discord_color.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/dm_channel.dart'; -import 'package:nyxx/src/core/guild/status.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/user/nitro_type.dart'; -import 'package:nyxx/src/core/user/presence.dart'; -import 'package:nyxx/src/core/user/user_flags.dart'; -import 'package:nyxx/src/internal/interfaces/message_author.dart'; -import 'package:nyxx/src/internal/interfaces/send.dart'; -import 'package:nyxx/src/internal/interfaces/mentionable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; - -abstract class IUser implements SnowflakeEntity, ISend, Mentionable, IMessageAuthor { - /// Reference to client - INyxx get client; - - /// The user's avatar hash. - String? get avatar; - - /// Whether the user is an Official Discord System user (part of the urgent message system) - bool get system; - - /// The member's status. `offline`, `online`, `idle`, or `dnd`. - IClientStatus? get status; - - /// The member's presence. - IActivity? get presence; - - /// Additional flags associated with user account. Describes if user has certain - /// features like joined into one of houses or is discord employee. - IUserFlags? get userFlags; - - /// Premium types denote the level of premium a user has. - NitroType? get nitroType; - - /// Hash of user banner - String? get bannerHash; - - /// Color of the banner - DiscordColor? get accentColor; - - /// Gets the [DMChannel] for the user. - FutureOr get dmChannel; - - /// The hash of the user's avatar decoration. - String? avatarDecorationHash; - - /// The user's banner url. - String? bannerUrl({String format = 'webp', int? size, bool animated = false}); - - /// The user's avatar decoration url, if any. - String? avatarDecorationUrl({int size}); -} - -/// Represents a single user of Discord, either a human or a bot, outside of any specific guild's context. -class User extends SnowflakeEntity implements IUser { - /// Reference to client - @override - final INyxx client; - - /// The user's username. - @override - late final String username; - - /// The user's discriminator. - @override - late final int discriminator; - - /// Formatted discriminator with leading zeros if needed - @override - String get formattedDiscriminator => discriminator.toString().padLeft(4, "0"); - - /// The user's avatar hash. - @override - late final String? avatar; - - /// The string to mention the user. - @override - String get mention => "<@!$id>"; - - /// Returns String with username#discriminator - @override - String get tag => "$username#$formattedDiscriminator"; - - /// Whether the user belongs to an OAuth2 application - @override - late final bool bot; - - /// Whether the user is an Official Discord System user (part of the urgent message system) - @override - late final bool system; - - /// The member's status. `offline`, `online`, `idle`, or `dnd`. - @override - IClientStatus? status; - - /// The member's presence. - @override - IActivity? presence; - - /// Additional flags associated with user account. Describes if user has certain - /// features like joined into one of houses or is discord employee. - @override - late final IUserFlags? userFlags; - - /// Premium types denote the level of premium a user has. - @override - late final NitroType? nitroType; - - /// Hash of user banner - @override - late final String? bannerHash; - - /// Color of the banner - @override - late final DiscordColor? accentColor; - - @override - bool get isInteractionWebhook => false; - - @override - late final String? avatarDecorationHash; - - /// Creates an instance of [User] - User(this.client, RawApiMap raw) : super(Snowflake(raw["id"])) { - username = raw["username"] as String; - discriminator = int.parse(raw["discriminator"] as String); - avatar = raw["avatar"] as String?; - bot = raw["bot"] as bool? ?? false; - system = raw["system"] as bool? ?? false; - - if (raw["public_flags"] != null) { - userFlags = UserFlags(raw["public_flags"] as int); - } else { - userFlags = null; - } - - if (raw["premium_type"] != null) { - nitroType = NitroType.from(raw["premium_type"] as int); - } else { - nitroType = null; - } - - bannerHash = raw["banner"] as String?; - if (raw["accent_color"] != null) { - accentColor = DiscordColor.fromInt(raw["accent_color"] as int); - } else { - accentColor = null; - } - - avatarDecorationHash = raw['avatar_decoration'] as String?; - } - - /// Gets the [DMChannel] for the user. - @override - FutureOr get dmChannel { - try { - return client.channels.values.firstWhere((item) => item is IDMChannel && item.participants.contains(this)) as Future; - } on StateError { - return client.httpEndpoints.createDMChannel(id); - } - } - - /// The user's avatar, represented as URL. - /// In case if user does not have avatar, default discord avatar will be returned with specified size and png format. - @override - String avatarUrl({String format = 'webp', int? size, bool animated = false}) { - if (avatar == null) { - return client.cdnHttpEndpoints.defaultAvatar(discriminator); - } - - return client.cdnHttpEndpoints.avatar(id, avatar!, format: format, size: size, animated: animated); - } - - /// The user's banner url. - @override - String? bannerUrl({String format = 'webp', int? size, bool animated = false}) { - if (bannerHash == null) { - return null; - } - - return client.cdnHttpEndpoints.banner(id, bannerHash!, format: format, size: size, animated: animated); - } - - /// Sends a message to user. - @override - Future sendMessage(MessageBuilder builder) async { - final channel = await dmChannel; - return channel.sendMessage(builder); - } - - @override - String? avatarDecorationUrl({int? size}) { - if (avatarDecorationHash == null) { - return null; - } - - return client.cdnHttpEndpoints.avatarDecoration(id, avatarDecorationHash!, size: size); - } -} diff --git a/lib/src/core/user/user_flags.dart b/lib/src/core/user/user_flags.dart deleted file mode 100644 index 60f0e06c1..000000000 --- a/lib/src/core/user/user_flags.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:nyxx/src/utils/permissions.dart'; - -abstract class IUserFlags { - /// True if user is discord employee - bool get discordEmployee; - - /// True if user is discord partner - bool get discordPartner; - - /// True if user has HypeSquad Events badge - bool get hypeSquadEvents; - - /// True if user has level one of Bug Hunter badge - bool get bugHunterLevel1; - - /// True if user has HypeSquad Bravery badge - bool get houseBravery; - - /// True if user has HypeSquad Brilliance badge - bool get houseBrilliance; - - /// True if user has HypeSquad Balance badge - bool get houseBalance; - - /// True if user has Early Supporter badge - bool get earlySupporter; - - /// Team User - bool get teamUser; - - /// If user is system user - bool get system; - - /// True if user has level two of Bug Hunter badge - bool get bugHunterLevel2; - - /// True if user is verified bot - bool get verifiedBot; - - /// True if user is Early Verified Bot Developer - bool get earlyVerifiedBotDeveloper; - - /// True if user is Discord Certified Moderator - bool get certifiedModerator; - - /// True if user is an [Active Developer](https://support-dev.discord.com/hc/articles/10113997751447). - bool get activeDeveloper; - - /// Raw flags value - int get raw; -} - -/// Additional flags associated with user account. Describes if user has certain -/// features like joined into one of houses or is discord employee. -class UserFlags implements IUserFlags { - /// True if user is discord employee - @override - bool get discordEmployee => PermissionsUtils.isApplied(raw, 1 << 0); - - /// True if user is discord partner - @override - bool get discordPartner => PermissionsUtils.isApplied(raw, 1 << 1); - - /// True if user has HypeSquad Events badge - @override - bool get hypeSquadEvents => PermissionsUtils.isApplied(raw, 1 << 2); - - /// True if user has level one of Bug Hunter badge - @override - bool get bugHunterLevel1 => PermissionsUtils.isApplied(raw, 1 << 3); - - /// True if user has HypeSquad Bravery badge - @override - bool get houseBravery => PermissionsUtils.isApplied(raw, 1 << 6); - - /// True if user has HypeSquad Brilliance badge - @override - bool get houseBrilliance => PermissionsUtils.isApplied(raw, 1 << 7); - - /// True if user has HypeSquad Balance badge - @override - bool get houseBalance => PermissionsUtils.isApplied(raw, 1 << 8); - - /// True if user has Early Supporter badge - @override - bool get earlySupporter => PermissionsUtils.isApplied(raw, 1 << 9); - - @override - bool get teamUser => PermissionsUtils.isApplied(raw, 1 << 10); - - /// If user is system user - @override - bool get system => PermissionsUtils.isApplied(raw, 1 << 12); - - /// True if user has level two of Bug Hunter badge - @override - bool get bugHunterLevel2 => PermissionsUtils.isApplied(raw, 1 << 14); - - /// True if user is verified bot - @override - bool get verifiedBot => PermissionsUtils.isApplied(raw, 1 << 16); - - /// True if user is Early Verified Bot Developer - @override - bool get earlyVerifiedBotDeveloper => PermissionsUtils.isApplied(raw, 1 << 17); - - /// rue if user is Discord Certified Moderator - @override - bool get certifiedModerator => PermissionsUtils.isApplied(raw, 1 << 18); - - @override - bool get activeDeveloper => PermissionsUtils.isApplied(raw, 1 << 22); - - /// Raw flags value - @override - final int raw; - - /// Creates an instance of [UserFlags] - UserFlags(this.raw); -} diff --git a/lib/src/core/voice/voice_region.dart b/lib/src/core/voice/voice_region.dart deleted file mode 100644 index 138d14670..000000000 --- a/lib/src/core/voice/voice_region.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; - -abstract class IVoiceRegion { - /// Unique id for region - String get id; - - /// Name of the region - String get name; - - /// True if this is a vip-only server - bool get vip; - - /// True for a single server that is closest to the current user's client - bool get optimal; - - /// Whether this is a deprecated voice region (avoid switching to these) - bool get deprecated; - - /// Whether this is a custom voice region (used for events/etc) - bool get custom; -} - -/// Represents voice region on which discord guild takes place -class VoiceRegion implements IVoiceRegion { - /// Unique id for region - @override - late final String id; - - /// Name of the region - @override - late final String name; - - /// True if this is a vip-only server - @override - late final bool vip; - - /// True for a single server that is closest to the current user's client - @override - late final bool optimal; - - /// Whether this is a deprecated voice region (avoid switching to these) - @override - late final bool deprecated; - - /// Whether this is a custom voice region (used for events/etc) - @override - late final bool custom; - - /// Creates an instance of [VoiceRegion] - VoiceRegion(RawApiMap raw) { - id = raw["id"] as String; - name = raw["name"] as String; - vip = raw["vip"] as bool; - optimal = raw["optimal"] as bool; - deprecated = raw["deprecated"] as bool; - custom = raw["custom"] as bool; - } -} diff --git a/lib/src/core/voice/voice_state.dart b/lib/src/core/voice/voice_state.dart deleted file mode 100644 index af91770b3..000000000 --- a/lib/src/core/voice/voice_state.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IVoiceState { - /// User this voice state is for - Cacheable get user; - - /// Session id for this voice state - String get sessionId; - - /// Guild this voice state update is - Cacheable? get guild; - - /// Channel id user is connected - Cacheable? get channel; - - /// Whether this user is muted by the server - bool get deaf; - - /// Whether this user is locally deafened - bool get selfDeaf; - - /// Whether this user is locally muted - bool get selfMute; - - /// Whether this user is muted by the current user - bool get suppress; - - /// Whether this user is streaming using "Go Live" - bool get selfStream; - - /// Whether this user's camera is enabled - bool get selfVideo; - - /// The time at which the user requested to speak - DateTime? get requestToSpeakTimeStamp; -} - -/// Used to represent a user"s voice connection status. -/// If [channel] is null, it means that user left channel. -class VoiceState implements IVoiceState { - /// User this voice state is for - @override - late final Cacheable user; - - /// Session id for this voice state - @override - late final String sessionId; - - /// Guild this voice state update is - @override - late final Cacheable? guild; - - /// Channel id user is connected - @override - late final Cacheable? channel; - - /// Whether this user is muted by the server - @override - late final bool deaf; - - /// Whether this user is locally deafened - @override - late final bool selfDeaf; - - /// Whether this user is locally muted - @override - late final bool selfMute; - - /// Whether this user is muted by the current user - @override - late final bool suppress; - - /// Whether this user is streaming using "Go Live" - @override - late final bool selfStream; - - /// Whether this user's camera is enabled - @override - late final bool selfVideo; - - /// The time at which the user requested to speak - @override - late final DateTime? requestToSpeakTimeStamp; - - /// Creates an instance of [VoiceState] - VoiceState(INyxx client, RawApiMap raw) { - if (raw["channel_id"] != null) { - channel = ChannelCacheable(client, Snowflake(raw["channel_id"])); - } else { - channel = null; - } - - deaf = raw["deaf"] as bool; - selfDeaf = raw["self_deaf"] as bool; - selfMute = raw["self_mute"] as bool; - - selfStream = raw["self_stream"] as bool? ?? false; - selfVideo = raw["self_video"] as bool; - - requestToSpeakTimeStamp = raw["request_to_speak_timestamp"] == null ? null : DateTime.parse(raw["request_to_speak_timestamp"] as String); - - suppress = raw["suppress"] as bool; - sessionId = raw["session_id"] as String; - - if (raw["guild_id"] == null) { - guild = null; - } else { - guild = GuildCacheable(client, Snowflake(raw["guild_id"])); - } - - user = UserCacheable(client, Snowflake(raw["user_id"])); - } -} diff --git a/lib/src/errors.dart b/lib/src/errors.dart new file mode 100644 index 000000000..f70e65399 --- /dev/null +++ b/lib/src/errors.dart @@ -0,0 +1,117 @@ +import 'package:nyxx/src/gateway/shard.dart'; +import 'package:nyxx/src/models/gateway/gateway.dart'; +import 'package:nyxx/src/models/interaction.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// The base class for all exceptions thrown by nyxx. +class NyxxException implements Exception { + /// The message for this exception. + final String message; + + /// Create a new [NyxxException] with the provided [message]. + NyxxException(this.message); + + @override + String toString() => message; +} + +/// An exception thrown when an unexpected event is received on the Gateway. +class InvalidEventException extends NyxxException { + /// Create a new [InvalidEventException] with the provided [message]. + InvalidEventException(String message) : super('Invalid gateway event: $message'); +} + +/// An exception thrown when a member already exists in a guild. +class MemberAlreadyExistsException extends NyxxException { + /// The ID of the guild. + final Snowflake guildId; + + /// The ID of the member. + final Snowflake memberId; + + /// Create a new [MemberAlreadyExistsException]. + MemberAlreadyExistsException(this.guildId, this.memberId) : super('Member $memberId already exists in guild $guildId'); +} + +/// An exception thrown when a role is not found in a guild. +class RoleNotFoundException extends NyxxException { + /// The ID of the guild. + final Snowflake guildId; + + /// The ID of the role. + final Snowflake roleId; + + /// Create a new [RoleNotFoundException]. + RoleNotFoundException(this.guildId, this.roleId) : super('Role $roleId not found in guild $guildId'); +} + +/// An exception thrown when a integration is not found in a guild. +class IntegrationNotFoundException extends NyxxException { + /// The ID of the guild. + final Snowflake guildId; + + /// The ID of the integration. + final Snowflake integrationId; + + /// Create a new [IntegrationNotFoundException]. + IntegrationNotFoundException(this.guildId, this.integrationId) : super('Integration $integrationId not found in guild $guildId'); +} + +/// An exception thrown when an audit log entry is not found in a guild. +class AuditLogEntryNotFoundException extends NyxxException { + /// The ID of the guild. + final Snowflake guildId; + + /// The ID of the audit log entry. + final Snowflake auditLogEntryId; + + /// Create a new [AuditLogEntryNotFoundException]. + AuditLogEntryNotFoundException(this.guildId, this.auditLogEntryId) : super('Audit log entry $auditLogEntryId not found in guild $guildId'); +} + +/// An error thrown when a shard disconnects unexpectedly. +class ShardDisconnectedError extends Error { + /// The shard that was disconnected. + final Shard shard; + + /// Create a new [ShardDisconnectedError]. + ShardDisconnectedError(this.shard); + + @override + String toString() => 'Shard ${shard.id} disconnected unexpectedly'; +} + +/// An error thrown when the number of remaining sessions becomes too low. +/// +/// The threshold for this can be configured in [GatewayClientOptions.minimumSessionStarts]. +class OutOfRemainingSessionsError extends Error { + /// The [GatewayBot] containing the information that triggered the error. + final GatewayBot gatewayBot; + + /// Create a new [OutOfRemainingSessionsError]. + OutOfRemainingSessionsError(this.gatewayBot); + + @override + String toString() => 'Out of remaining session starts (${gatewayBot.sessionStartLimit.remaining} left)'; +} + +/// An error thrown when [MessageResponse.acknowledge] is called on an already acknowledged interaction. +class AlreadyAcknowledgedError extends Error { + /// The interaction that was acknowledged. + final MessageResponse interaction; + + /// Create a new [AlreadyAcknowledgedError]. + AlreadyAcknowledgedError(this.interaction); + + @override + String toString() => 'Interaction has already been acknowledged'; +} + +/// An error thrown when [MessageResponse.respond] is called on an interaction that has already been responded to. +class AlreadyRespondedError extends AlreadyAcknowledgedError { + /// Create a new [AlreadyRespondedError]. + AlreadyRespondedError(super.interaction); + + @override + String toString() => 'Interaction has already been responded to'; +} diff --git a/lib/src/event_mixin.dart b/lib/src/event_mixin.dart new file mode 100644 index 000000000..74ded31a7 --- /dev/null +++ b/lib/src/event_mixin.dart @@ -0,0 +1,248 @@ +import 'dart:async'; + +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/gateway/events/application_command.dart'; +import 'package:nyxx/src/models/gateway/events/auto_moderation.dart'; +import 'package:nyxx/src/models/gateway/events/channel.dart'; +import 'package:nyxx/src/models/gateway/events/guild.dart'; +import 'package:nyxx/src/models/gateway/events/integration.dart'; +import 'package:nyxx/src/models/gateway/events/interaction.dart'; +import 'package:nyxx/src/models/gateway/events/invite.dart'; +import 'package:nyxx/src/models/gateway/events/message.dart'; +import 'package:nyxx/src/models/gateway/events/presence.dart'; +import 'package:nyxx/src/models/gateway/events/ready.dart'; +import 'package:nyxx/src/models/gateway/events/stage_instance.dart'; +import 'package:nyxx/src/models/gateway/events/voice.dart'; +import 'package:nyxx/src/models/gateway/events/webhook.dart'; +import 'package:nyxx/src/models/interaction.dart'; +import 'package:nyxx/src/utils/iterable_extension.dart'; + +/// An internal mixin to add event streams to a NyxxGateway client. +mixin EventMixin implements Nyxx { + /// A [Stream] of gateway dispatch events received by this client. + Stream get onEvent => (this as NyxxGateway).gateway.events; + + /// A [Stream] of [DispatchEvent]s which are unknown to the current version of nyxx. + Stream get onUnknownEvent => onEvent.whereType(); + + /// A helper function to listen to events of a specific type. + /// + /// Specifying the type parameter is important, as the callback will otherwise be invoked for every event received by the client. + /// + /// The following two code examples are equivalent: + /// ```dart + /// client.on((event) => print(event.message.content)); + /// ``` + /// + /// ```dart + /// client.onMessageCreate.listen((event) => print(event.message.content)); + /// ``` + StreamSubscription on(void Function(T event) onData) => onEvent.whereType().listen(onData); + + /// A [Stream] of [ReadyEvent]s received by this client. + Stream get onReady => onEvent.whereType(); + + /// A [Stream] of [ResumedEvent]s received by this client. + Stream get onResumed => onEvent.whereType(); + + /// A [Stream] of [ApplicationCommandPermissionsUpdateEvent]s received by this client. + Stream get onApplicationCommandPermissionsUpdate => onEvent.whereType(); + + /// A [Stream] of [AutoModerationRuleCreateEvent]s received by this client. + Stream get onAutoModerationRuleCreate => onEvent.whereType(); + + /// A [Stream] of [AutoModerationRuleUpdateEvent]s received by this client. + Stream get onAutoModerationRuleUpdate => onEvent.whereType(); + + /// A [Stream] of [AutoModerationRuleDeleteEvent]s received by this client. + Stream get onAutoModerationRuleDelete => onEvent.whereType(); + + /// A [Stream] of [AutoModerationActionExecutionEvent]s received by this client. + Stream get onAutoModerationActionExecution => onEvent.whereType(); + + /// A [Stream] of [ChannelCreateEvent]s received by this client. + Stream get onChannelCreate => onEvent.whereType(); + + /// A [Stream] of [ChannelUpdateEvent]s received by this client. + Stream get onChannelUpdate => onEvent.whereType(); + + /// A [Stream] of [ChannelDeleteEvent]s received by this client. + Stream get onChannelDelete => onEvent.whereType(); + + /// A [Stream] of [ThreadCreateEvent]s received by this client. + Stream get onThreadCreate => onEvent.whereType(); + + /// A [Stream] of [ThreadUpdateEvent]s received by this client. + Stream get onThreadUpdate => onEvent.whereType(); + + /// A [Stream] of [ThreadDeleteEvent]s received by this client. + Stream get onThreadDelete => onEvent.whereType(); + + /// A [Stream] of [ThreadListSyncEvent]s received by this client. + Stream get onThreadListSync => onEvent.whereType(); + + /// A [Stream] of [ThreadMemberUpdateEvent]s received by this client. + Stream get onThreadMemberUpdate => onEvent.whereType(); + + /// A [Stream] of [ThreadMembersUpdateEvent]s received by this client. + Stream get onThreadMembersUpdate => onEvent.whereType(); + + /// A [Stream] of [ChannelPinsUpdateEvent]s received by this client. + Stream get onChannelPinsUpdate => onEvent.whereType(); + + /// A [Stream] of [UnavailableGuildCreateEvent]s received by this client. + /// + /// This stream also emits [GuildCreateEvent]s, as they are a subtype of [UnavailableGuildCreateEvent]. + Stream get onGuildCreate => onEvent.whereType(); + + /// A [Stream] of [GuildUpdateEvent]s received by this client. + Stream get onGuildUpdate => onEvent.whereType(); + + /// A [Stream] of [GuildDeleteEvent]s received by this client. + Stream get onGuildDelete => onEvent.whereType(); + + /// A [Stream] of [GuildAuditLogCreateEvent]s received by this client. + Stream get onGuildAuditLogCreate => onEvent.whereType(); + + /// A [Stream] of [GuildBanAddEvent]s received by this client. + Stream get onGuildBanAdd => onEvent.whereType(); + + /// A [Stream] of [GuildBanRemoveEvent]s received by this client. + Stream get onGuildBanRemove => onEvent.whereType(); + + /// A [Stream] of [GuildEmojisUpdateEvent]s received by this client. + Stream get onGuildEmojisUpdate => onEvent.whereType(); + + /// A [Stream] of [GuildStickersUpdateEvent]s received by this client. + Stream get onGuildStickersUpdate => onEvent.whereType(); + + /// A [Stream] of [GuildIntegrationsUpdateEvent]s received by this client. + Stream get onGuildIntegrationsUpdate => onEvent.whereType(); + + /// A [Stream] of [GuildMemberAddEvent]s received by this client. + Stream get onGuildMemberAdd => onEvent.whereType(); + + /// A [Stream] of [GuildMemberRemoveEvent]s received by this client. + Stream get onGuildMemberRemove => onEvent.whereType(); + + /// A [Stream] of [GuildMemberUpdateEvent]s received by this client. + Stream get onGuildMemberUpdate => onEvent.whereType(); + + /// A [Stream] of [GuildMembersChunkEvent]s received by this client. + Stream get onGuildMembersChunk => onEvent.whereType(); + + /// A [Stream] of [GuildRoleCreateEvent]s received by this client. + Stream get onGuildRoleCreate => onEvent.whereType(); + + /// A [Stream] of [GuildRoleUpdateEvent]s received by this client. + Stream get onGuildRoleUpdate => onEvent.whereType(); + + /// A [Stream] of [GuildRoleDeleteEvent]s received by this client. + Stream get onGuildRoleDelete => onEvent.whereType(); + + /// A [Stream] of [GuildScheduledEventCreateEvent]s received by this client. + Stream get onGuildScheduledEventCreate => onEvent.whereType(); + + /// A [Stream] of [GuildScheduledEventUpdateEvent]s received by this client. + Stream get onGuildScheduledEventUpdate => onEvent.whereType(); + + /// A [Stream] of [GuildScheduledEventDeleteEvent]s received by this client. + Stream get onGuildScheduledEventDelete => onEvent.whereType(); + + /// A [Stream] of [GuildScheduledEventUserAddEvent]s received by this client. + Stream get onGuildScheduledEventUserAdd => onEvent.whereType(); + + /// A [Stream] of [GuildScheduledEventUserRemoveEvent]s received by this client. + Stream get onGuildScheduledEventUserRemove => onEvent.whereType(); + + /// A [Stream] of [IntegrationCreateEvent]s received by this client. + Stream get onIntegrationCreate => onEvent.whereType(); + + /// A [Stream] of [IntegrationUpdateEvent]s received by this client. + Stream get onIntegrationUpdate => onEvent.whereType(); + + /// A [Stream] of [IntegrationDeleteEvent]s received by this client. + Stream get onIntegrationDelete => onEvent.whereType(); + + /// A [Stream] of [InviteCreateEvent]s received by this client. + Stream get onInviteCreate => onEvent.whereType(); + + /// A [Stream] of [InviteDeleteEvent]s received by this client. + Stream get onInviteDelete => onEvent.whereType(); + + /// A [Stream] of [MessageCreateEvent]s received by this client. + Stream get onMessageCreate => onEvent.whereType(); + + /// A [Stream] of [MessageUpdateEvent]s received by this client. + Stream get onMessageUpdate => onEvent.whereType(); + + /// A [Stream] of [MessageDeleteEvent]s received by this client. + Stream get onMessageDelete => onEvent.whereType(); + + /// A [Stream] of [MessageBulkDeleteEvent]s received by this client. + Stream get onMessageBulkDelete => onEvent.whereType(); + + /// A [Stream] of [MessageReactionAddEvent]s received by this client. + Stream get onMessageReactionAdd => onEvent.whereType(); + + /// A [Stream] of [MessageReactionRemoveEvent]s received by this client. + Stream get onMessageReactionRemove => onEvent.whereType(); + + /// A [Stream] of [MessageReactionRemoveAllEvent]s received by this client. + Stream get onMessageReactionRemoveAll => onEvent.whereType(); + + /// A [Stream] of [MessageReactionRemoveEmojiEvent]s received by this client. + Stream get onMessageReactionRemoveEmoji => onEvent.whereType(); + + /// A [Stream] of [PresenceUpdateEvent]s received by this client. + Stream get onPresenceUpdate => onEvent.whereType(); + + /// A [Stream] of [TypingStartEvent]s received by this client. + Stream get onTypingStart => onEvent.whereType(); + + /// A [Stream] of [UserUpdateEvent]s received by this client. + Stream get onUserUpdate => onEvent.whereType(); + + /// A [Stream] of [VoiceStateUpdateEvent]s received by this client. + Stream get onVoiceStateUpdate => onEvent.whereType(); + + /// A [Stream] of [VoiceServerUpdateEvent]s received by this client. + Stream get onVoiceServerUpdate => onEvent.whereType(); + + /// A [Stream] of [WebhooksUpdateEvent]s received by this client. + Stream get onWebhooksUpdate => onEvent.whereType(); + + /// A [Stream] of [InteractionCreateEvent]s received by this client. + Stream get onInteractionCreate => onEvent.whereType(); + + /// A [Stream] of [StageInstanceCreateEvent]s received by this client. + Stream get onStageInstanceCreate => onEvent.whereType(); + + /// A [Stream] of [StageInstanceUpdateEvent]s received by this client. + Stream get onStageInstanceUpdate => onEvent.whereType(); + + /// A [Stream] of [StageInstanceDeleteEvent]s received by this client. + Stream get onStageInstanceDelete => onEvent.whereType(); + + // Specializations of [onInteractionCreate] for convenience. + + /// A [Stream] of [PingInteraction]s received by this client. + Stream> get onPingInteraction => onInteractionCreate.whereType>(); + + /// A [Stream] of [ApplicationCommandInteraction]s received by this client. + Stream> get onApplicationCommandInteraction => + onInteractionCreate.whereType>(); + + /// A [Stream] of [MessageComponentInteraction]s received by this client. + Stream> get onMessageComponentInteraction => + onInteractionCreate.whereType>(); + + /// A [Stream] of [ModalSubmitInteraction]s received by this client. + Stream> get onModalSubmitInteraction => + onInteractionCreate.whereType>(); + + /// A [Stream] of [ApplicationCommandAutocompleteInteraction]s received by this client. + Stream> get onApplicationCommandAutocompleteInteraction => + onInteractionCreate.whereType>(); +} diff --git a/lib/src/events/channel_events.dart b/lib/src/events/channel_events.dart deleted file mode 100644 index b24c22274..000000000 --- a/lib/src/events/channel_events.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/channel/guild/voice_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IChannelCreateEvent { - /// The channel that was created, either a [GuildChannel] or [DMChannel] - IChannel get channel; -} - -/// Sent when a channel is created. -class ChannelCreateEvent implements IChannelCreateEvent { - /// The channel that was created, either a [GuildChannel] or [DMChannel] - @override - late final IChannel channel; - - /// Creates an instance of [ChannelCreateEvent] - ChannelCreateEvent(RawApiMap raw, INyxx client) { - channel = Channel.deserialize(client, raw["d"] as RawApiMap); - - if (client.cacheOptions.channelCachePolicyLocation.event && client.cacheOptions.channelCachePolicy.canCache(channel)) { - client.channels[channel.id] = channel; - } - } -} - -/// Sent when a channel is deleted. -abstract class IChannelDeleteEvent { - /// The channel that was deleted. - IChannel get channel; -} - -/// Sent when a channel is deleted. -class ChannelDeleteEvent implements IChannelDeleteEvent { - /// The channel that was deleted. - @override - late final IChannel channel; - - /// Creates an instance of [ChannelDeleteEvent] - ChannelDeleteEvent(RawApiMap raw, INyxx client) { - channel = Channel.deserialize(client, raw["d"] as RawApiMap); - - client.channels.remove(channel.id); - } -} - -abstract class IChannelPinsUpdateEvent { - /// Channel where pins were updated - late final CacheableTextChannel channel; - - /// ID of channel pins were updated - late final Cacheable? guild; - - /// the time at which the most recent pinned message was pinned - late final DateTime? lastPingTimestamp; -} - -/// Fired when channel"s pinned messages are updated -class ChannelPinsUpdateEvent implements IChannelPinsUpdateEvent { - /// Channel where pins were updated - @override - late final CacheableTextChannel channel; - - /// ID of channel pins were updated - @override - late final Cacheable? guild; - - /// the time at which the most recent pinned message was pinned - @override - late final DateTime? lastPingTimestamp; - - /// Creates an instance of [ChannelPinsUpdateEvent] - ChannelPinsUpdateEvent(RawApiMap raw, INyxx client) { - if (raw["d"]["last_pin_timestamp"] != null) { - lastPingTimestamp = DateTime.parse(raw["d"]["last_pin_timestamp"] as String); - } else { - lastPingTimestamp = null; - } - - channel = CacheableTextChannel(client, Snowflake(raw["d"]["channel_id"])); - - if (raw["d"]["guild_id"] != null) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - } else { - guild = null; - } - } -} - -abstract class IChannelUpdateEvent { - /// The channel after the update. - IChannel get updatedChannel; - - /// The channel before the update, if it was cached. - IChannel? get oldChannel; -} - -/// Sent when a channel is updated. -class ChannelUpdateEvent implements IChannelUpdateEvent { - /// The channel after the update. - @override - late final IChannel updatedChannel; - - @override - late final IChannel? oldChannel; - - /// Creates an instance of [ChannelUpdateEvent] - ChannelUpdateEvent(RawApiMap raw, INyxx client) { - updatedChannel = Channel.deserialize(client, raw["d"] as RawApiMap); - - oldChannel = client.channels[updatedChannel.id]; - - // Move messages to new channel - if (updatedChannel is ITextChannel && oldChannel is ITextChannel) { - (updatedChannel as ITextChannel).messageCache.addAll((oldChannel as ITextChannel).messageCache); - } - - client.channels[updatedChannel.id] = updatedChannel; - } -} - -abstract class IStageInstanceEvent { - /// [IStageChannelInstance] related to event - IStageChannelInstance get stageChannelInstance; -} - -/// Event for actions related to stage channels -class StageInstanceEvent implements IStageInstanceEvent { - /// [IStageChannelInstance] related to event - @override - late final IStageChannelInstance stageChannelInstance; - - /// Creates an instance of [StageInstanceEvent] - StageInstanceEvent(INyxx client, RawApiMap raw) { - stageChannelInstance = StageChannelInstance(client, raw); - } -} diff --git a/lib/src/events/disconnect_event.dart b/lib/src/events/disconnect_event.dart deleted file mode 100644 index 7764221ee..000000000 --- a/lib/src/events/disconnect_event.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:nyxx/src/internal/shard/shard.dart'; -import 'package:nyxx/src/utils/enum.dart'; - -abstract class IDisconnectEvent { - /// The shard that got disconnected. - IShard get shard; - - /// Reason of disconnection - DisconnectEventReason get reason; -} - -/// Sent when a shard disconnects from the websocket. -class DisconnectEvent implements IDisconnectEvent { - /// The shard that got disconnected. - @override - final IShard shard; - - /// Reason of disconnection - @override - final DisconnectEventReason reason; - - /// Creates an instance of [DisconnectEvent] - DisconnectEvent(this.shard, this.reason); -} - -/// Reason why shard was disconnected. -class DisconnectEventReason extends IEnum { - /// When shard is disconnected due invalid shard session. - static const DisconnectEventReason invalidSession = DisconnectEventReason(9); - - /// Create an instance of [DisconnectEventReason] - const DisconnectEventReason(int value) : super(value); -} diff --git a/lib/src/events/guild_events.dart b/lib/src/events/guild_events.dart deleted file mode 100644 index 9fdb020cc..000000000 --- a/lib/src/events/guild_events.dart +++ /dev/null @@ -1,672 +0,0 @@ -import 'package:nyxx/src/core/audit_logs/audit_log_entry.dart'; -import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/guild/auto_moderation.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/guild/scheduled_event.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/message/sticker.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IGuildCreateEvent { - /// The guild created. - IGuild get guild; -} - -/// Sent when the bot joins a guild. -class GuildCreateEvent implements IGuildCreateEvent { - /// The guild created. - @override - late final IGuild guild; - - /// Creates an instance of [GuildCreateEvent] - GuildCreateEvent(RawApiMap raw, INyxx client) { - guild = Guild(client, raw["d"] as RawApiMap, true); - client.guilds[guild.id] = guild; - } -} - -abstract class IGuildUpdateEvent { - /// The guild after the update. - IGuild get guild; - - /// The guild before the update, if it was cached. - IGuild? get oldGuild; -} - -/// Sent when a guild is updated. -class GuildUpdateEvent implements IGuildUpdateEvent { - /// The guild after the update. - @override - late final IGuild guild; - - @override - late final IGuild? oldGuild; - - /// Creates an instance of [GuildUpdateEvent] - GuildUpdateEvent(RawApiMap json, INyxx client) { - guild = Guild(client, json["d"] as RawApiMap); - - oldGuild = client.guilds[guild.id]; - if (oldGuild != null) { - guild.members.addAll(oldGuild!.members); - } - - client.guilds[guild.id] = guild; - } -} - -abstract class IGuildDeleteEvent { - /// The guild. - Cacheable get guild; - - /// True if guild is unavailable which means disconnected due discord side problems - /// False if user was kicked from guild - bool get unavailable; -} - -/// Sent when you leave a guild. -class GuildDeleteEvent implements IGuildDeleteEvent { - /// The guild. - @override - late final Cacheable guild; - - /// True if guild is unavailable which means disconnected due discord side problems - /// False if user was kicked from guild - @override - late final bool unavailable; - - /// Creates an instance of [GuildDeleteEvent] - GuildDeleteEvent(RawApiMap raw, INyxx client) { - unavailable = raw["d"]["unavailable"] as bool? ?? false; - guild = GuildCacheable(client, Snowflake(raw["d"]["id"])); - - client.guilds.remove(guild.id); - } -} - -abstract class IGuildMemberRemoveEvent { - /// The guild the user left. - Cacheable get guild; - - ///The user that left. - IUser get user; -} - -/// Sent when a user leaves a guild, can be a leave, kick, or ban. -class GuildMemberRemoveEvent implements IGuildMemberRemoveEvent { - /// The guild the user left. - @override - late final Cacheable guild; - - ///The user that left. - @override - late final IUser user; - - /// Creates an instance of [GuildMemberRemoveEvent] - GuildMemberRemoveEvent(RawApiMap json, INyxx client) { - user = User(client, json["d"]["user"] as RawApiMap); - guild = GuildCacheable(client, Snowflake(json["d"]["guild_id"])); - - final guildInstance = guild.getFromCache(); - if (guildInstance != null) { - guildInstance.members.remove(user.id); - } - } -} - -abstract class IGuildMemberUpdateEvent { - /// The member after the update if member is updated. - Cacheable get member; - - /// The user of the updated member. - IUser get user; - - /// The user of the member before it was updated, if it was cached. - IUser? get oldUser; - - /// Guild in which member is - Cacheable get guild; -} - -/// Sent when a member is updated. -class GuildMemberUpdateEvent implements IGuildMemberUpdateEvent { - /// The member after the update if member is updated. - @override - late final Cacheable member; - - /// User if user is updated. Will be null if member is not null. - @override - late final IUser user; - - @override - late final IUser? oldUser; - - /// Guild in which member is - @override - late final Cacheable guild; - - /// Creates an instance of [GuildMemberUpdateEvent] - GuildMemberUpdateEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - member = MemberCacheable(client, Snowflake(raw["d"]["user"]["id"]), guild); - user = User(client, raw["d"]["user"] as RawApiMap); - - oldUser = client.users[user.id]; - - if (client.cacheOptions.userCachePolicyLocation.event) { - client.users[user.id] = user; - } - - final memberInstance = member.getFromCache(); - if (memberInstance == null) { - return; - } - - final guildInstance = guild.getFromCache(); - if (guildInstance == null) { - return; - } - - final nickname = raw["d"]["nickname"] as String?; - final roles = (raw["d"]["roles"] as RawApiList).map(Snowflake.new).toList(); - final boostingSince = DateTime.tryParse(raw["premium_since"] as String? ?? ""); - - (memberInstance as Member).updateMember(nickname, roles, boostingSince); - } -} - -abstract class IGuildMemberAddEvent { - /// The member that joined. - late final IMember member; - - /// User object of member that joined - late final IUser user; - - /// Guild where used was added - late final Cacheable guild; -} - -/// Sent when a member joins a guild. -class GuildMemberAddEvent implements IGuildMemberAddEvent { - /// The member that joined. - @override - late final IMember member; - - /// User object of member that joined - @override - late final IUser user; - - /// Guild where used was added - @override - late final Cacheable guild; - - /// Creates an instance of [GuildMemberAddEvent] - GuildMemberAddEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - member = Member(client, raw["d"] as RawApiMap, guild.id); - user = User(client, raw["d"]["user"] as RawApiMap); - - if (client.cacheOptions.userCachePolicyLocation.event) { - client.users[user.id] = user; - } - - final guildInstance = guild.getFromCache(); - if (guildInstance == null) { - return; - } - - if (client.cacheOptions.memberCachePolicyLocation.event && client.cacheOptions.memberCachePolicy.canCache(member)) { - guildInstance.members[member.id] = member; - } - } -} - -abstract class IGuildBanAddEvent { - /// The guild that the member was banned from. - Cacheable get guild; - - /// The user that was banned. - IUser get user; -} - -/// Sent when a member is banned. -class GuildBanAddEvent implements IGuildBanAddEvent { - /// The guild that the member was banned from. - @override - late final Cacheable guild; - - /// The user that was banned. - @override - late final IUser user; - - /// Creates an instance of [GuildBanAddEvent] - GuildBanAddEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - user = User(client, raw["d"]["user"] as RawApiMap); - } -} - -abstract class IGuildBanRemoveEvent { - /// The guild that the member was banned from. - Cacheable get guild; - - /// The user that was banned. - IUser get user; -} - -/// Sent when a user is unbanned from a guild. -class GuildBanRemoveEvent implements IGuildBanRemoveEvent { - /// The guild that the member was banned from. - @override - late final Cacheable guild; - - /// The user that was banned. - @override - late final IUser user; - - /// Creates an instance of [GuildBanRemoveEvent] - GuildBanRemoveEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - user = User(client, raw["d"]["user"] as RawApiMap); - } -} - -abstract class IGuildEmojisUpdateEvent { - /// List of modified emojis - List get emojis; - - /// The guild that the member was banned from. - Cacheable get guild; -} - -/// Fired when emojis are updated -class GuildEmojisUpdateEvent implements IGuildEmojisUpdateEvent { - /// List of modified emojis - @override - late final List emojis = []; - - /// The guild that the member was banned from. - @override - late final Cacheable guild; - - /// Creates an instance of [GuildEmojisUpdateEvent] - GuildEmojisUpdateEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - - final guildInstance = guild.getFromCache(); - for (final rawEmoji in raw["d"]["emojis"] as RawApiList) { - final emoji = GuildEmoji(client, rawEmoji as RawApiMap, guild.id); - - emojis.add(emoji); - - if (guildInstance != null) { - guildInstance.emojis[emoji.id] = emoji; - } - } - } -} - -abstract class IRoleCreateEvent { - /// The role that was created. - IRole get role; - - /// The guild that the member was banned from. - Cacheable get guild; -} - -/// Sent when a role is created. -class RoleCreateEvent implements IRoleCreateEvent { - /// The role that was created. - @override - late final IRole role; - - /// The guild that the member was banned from. - @override - late final Cacheable guild; - - /// Creates an instance of [RoleCreateEvent] - RoleCreateEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - - role = Role(client, raw["d"]["role"] as RawApiMap, guild.id); - - final guildInstance = guild.getFromCache(); - if (guildInstance != null) { - guildInstance.roles[role.id] = role; - } - } -} - -abstract class IRoleDeleteEvent { - /// Id of tole that was deleted - Cacheable? get role; - - /// The guild that the member was banned from. - Cacheable get guild; -} - -/// Sent when a role is deleted. -class RoleDeleteEvent implements IRoleDeleteEvent { - /// Id of tole that was deleted - @override - late final Cacheable? role; - - /// The guild that the member was banned from. - @override - late final Cacheable guild; - - /// Creates an instance of [RoleDeleteEvent] - RoleDeleteEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - - final guildInstance = guild.getFromCache(); - if (guildInstance != null) { - role = RoleCacheable(client, Snowflake(raw["d"]["role_id"]), guild); - guildInstance.roles.remove(role!.id); - } else { - role = null; - } - } -} - -abstract class IRoleUpdateEvent { - /// The role after the update. - IRole get role; - - /// The role before it was updated, if it was cached. - IRole? get oldRole; - - /// The guild that the member was banned from. - Cacheable get guild; -} - -/// Sent when a role is updated. -class RoleUpdateEvent implements IRoleUpdateEvent { - /// The role after the update. - @override - late final IRole role; - - @override - late final IRole? oldRole; - - /// The guild that the member was banned from. - @override - late final Cacheable guild; - - /// Creates an instance of [RoleUpdateEvent] - RoleUpdateEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - role = Role(client, raw["d"]["role"] as RawApiMap, guild.id); - - final guildInstance = guild.getFromCache(); - if (guildInstance != null) { - oldRole = guildInstance.roles[role.id]; - guildInstance.roles[role.id] = role; - } else { - oldRole = null; - } - } -} - -abstract class IGuildStickerUpdate { - /// Cacheable of guild where stickers changed - Cacheable get guild; - - /// List of stickers - List get stickers; -} - -/// Sent when a guild's stickers have been updated. -class GuildStickerUpdate implements IGuildStickerUpdate { - /// Cacheable of guild where stickers changed - @override - late final Cacheable guild; - - /// List of stickers - @override - late final List stickers; - - /// Creates an instance of [GuildStickerUpdate] - GuildStickerUpdate(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - stickers = [for (final rawSticker in raw["d"]["stickers"] as RawApiList) GuildSticker(rawSticker as RawApiMap, client)]; - } -} - -abstract class IGuildEventCreateEvent { - IGuildEvent get event; -} - -class GuildEventCreateEvent implements IGuildEventCreateEvent { - @override - late final IGuildEvent event; - - GuildEventCreateEvent(RawApiMap raw, INyxx client) { - event = GuildEvent(raw['d'] as RawApiMap, client); - event.guild.getFromCache()?.scheduledEvents[event.id] = event; - } -} - -abstract class IGuildEventUpdateEvent { - /// The newly edited event. - IGuildEvent get event; - - /// The old event before it's update. - IGuildEvent? get oldEvent; -} - -class GuildEventUpdateEvent implements IGuildEventUpdateEvent { - @override - late final IGuildEvent event; - - @override - late final IGuildEvent? oldEvent; - - GuildEventUpdateEvent(RawApiMap raw, INyxx client) { - event = GuildEvent(raw['d'] as RawApiMap, client); - oldEvent = event.guild.getFromCache()?.scheduledEvents[event.id]; - event.guild.getFromCache()?.scheduledEvents.update(event.id, (_) => event, ifAbsent: () => event); - } -} - -abstract class IGuildEventDeleteEvent { - IGuildEvent get event; -} - -class GuildEventDeleteEvent implements IGuildEventDeleteEvent { - @override - late final IGuildEvent event; - - GuildEventDeleteEvent(RawApiMap raw, INyxx client) { - event = GuildEvent(raw['d'] as RawApiMap, client); - event.guild.getFromCache()?.scheduledEvents.remove(event.id); - } -} - -abstract class IAutoModerationRuleCreateEvent { - /// The created rule. - IAutoModerationRule get rule; -} - -class AutoModerationRuleCreateEvent implements IAutoModerationRuleCreateEvent { - @override - late final IAutoModerationRule rule; - - AutoModerationRuleCreateEvent(RawApiMap raw, INyxx client) { - rule = AutoModerationRule(raw['d'] as RawApiMap, client); - client.guilds[rule.guild.id]?.autoModerationRules[rule.id] = rule; - } -} - -abstract class IAutoModerationRuleUpdateEvent { - /// The updated rule. - IAutoModerationRule get rule; - - /// The old rule before it's update. - IAutoModerationRule? get oldRule; -} - -class AutoModerationRuleUpdateEvent implements IAutoModerationRuleUpdateEvent { - @override - late final IAutoModerationRule rule; - - @override - late final IAutoModerationRule? oldRule; - - AutoModerationRuleUpdateEvent(RawApiMap raw, INyxx client) { - rule = AutoModerationRule(raw['d'] as RawApiMap, client); - final guild = client.guilds[rule.guild.id]; - oldRule = guild?.autoModerationRules[rule.id]; - if (guild == null) { - return; - } - guild.autoModerationRules.update(rule.id, (_) => rule, ifAbsent: () => rule); - } -} - -abstract class IAutoModerationRuleDeleteEvent { - /// The deleted rule. - IAutoModerationRule get rule; -} - -class AutoModerationRuleDeleteEvent implements IAutoModerationRuleDeleteEvent { - @override - late final IAutoModerationRule rule; - - AutoModerationRuleDeleteEvent(RawApiMap raw, INyxx client) { - rule = AutoModerationRule(raw['d'] as RawApiMap, client); - client.guilds[rule.guild.id]?.autoModerationRules.remove(rule.id); - } -} - -/// When a webhook is created, updated or deleted. -abstract class IWebhookUpdateEvent { - /// The channel that points this webhook to. - Cacheable get channel; - - /// The guild this webhook was created/updated/deleted. - Cacheable get guild; -} - -class WebhookUpdateEvent implements IWebhookUpdateEvent { - @override - late final Cacheable channel; - - @override - late final Cacheable guild; - - WebhookUpdateEvent(RawApiMap raw, INyxx client) { - channel = ChannelCacheable(client, Snowflake(raw['d']['channel_id'])); - guild = GuildCacheable(client, Snowflake(raw['d']['guild_id'])); - } -} - -abstract class IAutoModerationActionExecutionEvent implements SnowflakeEntity { - /// The guild where this action was executed. - Cacheable get guild; - - /// The action which was executed. - ActionStructure get action; - - /// The trigger type of rule which was triggered. - TriggerTypes get triggerType; - - /// The member which generated the content which triggered the rule. - Cacheable get member; - - /// The channel in which user content was posted. - Cacheable? get channel; - - /// The message of any user message which content belongs to. - /// - /// This will not be present if the message was blocked by automod or the content was not part of the message. - Cacheable? get message; - - /// The message id of any system auto moderation messages posted as a result of this action. - /// - /// `null` if the [action.actionType] is not [ActionTypes.sendAlertMessage]. - Snowflake? get alertSystemMessage; - - /// The member generated text content. - /// - /// An empty string if you have not the message content privilegied intent. - String get content; - - /// The word or phrase configured in the rule that triggered the rule - String? get matchedKeyword; - - /// The substring in content that triggered the rule. - /// - /// An empty string if you have not the message content privilegied intent. - String get matchedContent; -} - -class AutoModeratioActionExecutionEvent extends SnowflakeEntity implements IAutoModerationActionExecutionEvent { - @override - late final Cacheable guild; - - @override - late final ActionStructure action; - - @override - late final TriggerTypes triggerType; - - @override - late final Cacheable member; - - @override - late final Cacheable? channel; - - @override - late final Cacheable? message; - - @override - late final Snowflake? alertSystemMessage; - - @override - late final String content; - - @override - late final String? matchedKeyword; - - @override - late final String matchedContent; - - AutoModeratioActionExecutionEvent(RawApiMap rawPayload, INyxx client) : super(Snowflake(rawPayload['d']['rule_id'])) { - final raw = rawPayload['d']; - guild = GuildCacheable(client, Snowflake(raw['guild_id'] as String)); - action = ActionStructure(raw['action'] as RawApiMap, client); - triggerType = TriggerTypes.fromValue(raw['rule_trigger_type'] as int); - member = MemberCacheable(client, Snowflake(raw['user_id']), guild); - channel = raw['channel_id'] != null ? ChannelCacheable(client, Snowflake(raw['channel_id'])) : null; - message = raw['message_id'] != null && channel != null ? MessageCacheable(client, Snowflake(raw['message_id']), channel!) : null; - alertSystemMessage = raw['alert_system_message_id'] != null ? Snowflake(raw['alert_system_message_id']) : null; - content = raw['content'] as String; - matchedKeyword = raw['matched_keyword'] != null ? raw['matched_keyword'] as String : null; - matchedContent = raw['matched_content'] as String; - } -} - -/// Sent when a guild audit log entry is created. -/// This event is only sent to bots with the VIEW_AUDIT_LOG permission. -abstract class IAuditLogEntryCreateEvent { - AuditLogEntry get auditLogEntry; -} - -class AuditLogEntryCreateEvent implements IAuditLogEntryCreateEvent { - @override - late final AuditLogEntry auditLogEntry; - - AuditLogEntryCreateEvent(RawApiMap raw, INyxx client) { - auditLogEntry = AuditLogEntry(raw['d'] as RawApiMap, client); - } -} diff --git a/lib/src/events/http_events.dart b/lib/src/events/http_events.dart deleted file mode 100644 index 55eb54c51..000000000 --- a/lib/src/events/http_events.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:nyxx/src/internal/http/http_response.dart'; - -abstract class IHttpErrorEvent { - /// The HTTP response. - HttpResponseError get response; -} - -/// Sent when a failed HTTP response is received. -class HttpErrorEvent implements IHttpErrorEvent { - /// The HTTP response. - @override - final HttpResponseError response; - - /// Creates an instance of [HttpErrorEvent] - HttpErrorEvent(this.response); -} - -abstract class IHttpResponseEvent { - /// The HTTP response. - HttpResponseSuccess get response; -} - -/// Sent when a successful HTTP response is received. -class HttpResponseEvent implements IHttpResponseEvent { - /// The HTTP response. - @override - final HttpResponseSuccess response; - - /// Creates an instance of [HttpResponseEvent] - HttpResponseEvent(this.response); -} diff --git a/lib/src/events/invite_events.dart b/lib/src/events/invite_events.dart deleted file mode 100644 index f42904ba6..000000000 --- a/lib/src/events/invite_events.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/channel/invite.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IInviteCreatedEvent { - /// [IInvite] object of created invite - IInvite get invite; -} - -/// Emitted when invite is creating -class InviteCreatedEvent implements IInviteCreatedEvent { - /// [IInvite] object of created invite - @override - late final IInvite invite; - - /// Creates an instance of [InviteCreatedEvent] - InviteCreatedEvent(RawApiMap raw, INyxx client) { - invite = Invite(raw["d"] as RawApiMap, client); - } -} - -abstract class IInviteDeletedEvent { - /// Channel to which invite was pointing - Cacheable get channel; - - /// Guild where invite was deleted - Cacheable? get guild; - - /// Code of invite - String get code; -} - -/// Emitted when invite is deleted -class InviteDeletedEvent implements IInviteDeletedEvent { - /// Channel to which invite was pointing - @override - late final Cacheable channel; - - /// Guild where invite was deleted - @override - late final Cacheable? guild; - - /// Code of invite - @override - late final String code; - - /// Creates an instance of [InviteDeletedEvent] - InviteDeletedEvent(RawApiMap raw, INyxx client) { - code = raw["d"]["code"] as String; - channel = ChannelCacheable(client, Snowflake(raw["d"]["channel_id"])); - - if (raw["d"]["guild_id"] != null) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - } else { - guild = null; - } - } -} diff --git a/lib/src/events/member_chunk_event.dart b/lib/src/events/member_chunk_event.dart deleted file mode 100644 index 453b2f056..000000000 --- a/lib/src/events/member_chunk_event.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -/// Sent in response to `GUILD_REQUENT_MEMBERS` websocket command. -/// You can use the `chunk_index` and `chunk_count` to calculate how many chunks are left for your request. -abstract class IMemberChunkEvent { - /// Guild members - Iterable get members; - - /// Reference to guild - Cacheable get guild; - - /// Index of current event - int get chunkIndex; - - /// Total number of chunks that will be sent. - int get chunkCount; - - /// Array of snowflakes which were invalid in search - Iterable? get invalidIds; - - /// Nonce is used to identify events. - String? get nonce; - - /// Id of shard where chunk was received - int get shardId; -} - -/// Sent in response to `GUILD_REQUENT_MEMBERS` websocket command. -/// You can use the `chunk_index` and `chunk_count` to calculate how many chunks are left for your request. -class MemberChunkEvent implements IMemberChunkEvent { - /// Guild members - @override - late final Iterable members; - - /// Reference to guild - @override - late final Cacheable guild; - - /// Index of current event - @override - late final int chunkIndex; - - /// Total number of chunks that will be sent. - @override - late final int chunkCount; - - /// Array of snowflakes which were invalid in search - @override - Iterable? invalidIds; - - /// Nonce is used to identify events. - @override - String? nonce; - - /// Id of shard where chunk was received - @override - final int shardId; - - MemberChunkEvent(RawApiMap raw, INyxx client, this.shardId) { - chunkIndex = raw["d"]["chunk_index"] as int; - chunkCount = raw["d"]["chunk_count"] as int; - - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - - if (raw["d"]["not_found"] != null) { - invalidIds = [for (var id in raw["d"]["not_found"] as RawApiList) Snowflake(id)]; - } - - members = [for (var memberRaw in raw["d"]["members"] as RawApiList) Member(client, memberRaw as RawApiMap, guild.id)]; - - if (client.cacheOptions.memberCachePolicyLocation.event) { - final guildInstance = guild.getFromCache(); - for (final member in members) { - if (client.cacheOptions.memberCachePolicy.canCache(member)) { - guildInstance?.members[member.id] = member; - } - } - } - } -} diff --git a/lib/src/events/message_events.dart b/lib/src/events/message_events.dart deleted file mode 100644 index d3bca658d..000000000 --- a/lib/src/events/message_events.dart +++ /dev/null @@ -1,425 +0,0 @@ -import 'package:nyxx/src/core/message/attachment.dart'; -import 'package:nyxx/src/core/message/components/message_component.dart'; -import 'package:nyxx/src/core/message/message_flags.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/embed/embed.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/message/reaction.dart'; -import 'package:nyxx/src/core/message/unicode_emoji.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -/// Sent when a new message is received. -abstract class IMessageReceivedEvent { - /// The new message. - IMessage get message; -} - -/// Sent when a new message is received. -class MessageReceivedEvent implements IMessageReceivedEvent { - /// The new message. - @override - late final IMessage message; - - /// Creates an instance of [MessageReceivedEvent] - MessageReceivedEvent(RawApiMap raw, INyxx client) { - message = Message(client, raw["d"] as RawApiMap); - - if (client.cacheOptions.messageCachePolicyLocation.event && client.cacheOptions.messageCachePolicy.canCache(message)) { - message.channel.getFromCache()?.messageCache[message.id] = message; - } - } -} - -abstract class IMessageDeleteEvent { - /// The message, if cached. - IMessage? get message; - - /// The ID of the message. - Snowflake get messageId; - - /// Channel where message was deleted - CacheableTextChannel get channel; -} - -/// Sent when a message is deleted. -class MessageDeleteEvent implements IMessageDeleteEvent { - /// The message, if cached. - @override - late final IMessage? message; - - /// The ID of the message. - @override - late final Snowflake messageId; - - /// Channel where message was deleted - @override - late final CacheableTextChannel channel; - - /// Creates an instance of [MessageDeleteEvent] - MessageDeleteEvent(RawApiMap raw, INyxx client) { - channel = CacheableTextChannel(client, Snowflake(raw["d"]["channel_id"])); - messageId = Snowflake(raw["d"]["id"]); - - message = channel.getFromCache()?.messageCache[messageId]; - } -} - -abstract class IMessageDeleteBulkEvent { - /// List of deleted messages ids - Iterable get deletedMessagesIds; - - /// Channel on which messages were deleted. - CacheableTextChannel get channel; - - /// Id of guild where event occurred - Cacheable? get guild; - - /// Searches cache for deleted messages and returns those which are present in bots cache. - /// Will return empty collection if cannot obtain channel instance from cache. - /// It is not guaranteed that returned collection will have all deleted messages. - Iterable getDeletedMessages(); -} - -/// Emitted when multiple messages are deleted at once. -class MessageDeleteBulkEvent implements IMessageDeleteBulkEvent { - /// List of deleted messages ids - @override - late final Iterable deletedMessagesIds; - - /// Channel on which messages were deleted. - @override - late final CacheableTextChannel channel; - - /// Id of guild where event occurred - @override - late final Cacheable? guild; - - /// Creates an instance of [MessageDeleteBulkEvent] - MessageDeleteBulkEvent(RawApiMap json, INyxx client) { - channel = CacheableTextChannel(client, Snowflake(json["d"]["channel_id"])); - - if (json["d"]["guild_id"] != null) { - guild = GuildCacheable(client, Snowflake(json["d"]["guild_id"])); - } else { - guild = null; - } - - deletedMessagesIds = (json["d"]["ids"] as RawApiList).map(Snowflake.new); - } - - /// Searches cache for deleted messages and returns those which are present in bots cache. - /// Will return empty collection if cannot obtain channel instance from cache. - /// It is not guaranteed that returned collection will have all deleted messages. - @override - Iterable getDeletedMessages() { - final channelInstance = channel.getFromCache(); - - if (channelInstance == null) { - return []; - } - - return channelInstance.messageCache.values.where((item) => deletedMessagesIds.contains(item.id)); - } -} - -abstract class IMessageReactionEvent { - /// Reference to user who is behind event - Cacheable get user; - - /// Channel on which event was fired - CacheableTextChannel get channel; - - /// Reference to guild if event happened in guild - Cacheable? get guild; - - /// Message reference - IMessage? get message; - - /// Id of message - Snowflake get messageId; - - /// The member who reacted if this happened in a guild - IMember? get member; - - /// Emoji object. - IEmoji get emoji; -} - -/// Emitted when reaction is added or removed from message -abstract class MessageReactionEvent { - /// Reference to user who is behind event - late final Cacheable user; - - /// Channel on which event was fired - late final CacheableTextChannel channel; - - /// Reference to guild if event happened in guild - late final Cacheable? guild; - - /// Message reference - late final IMessage? message; - - /// Id of message - late final Snowflake messageId; - - /// The member who reacted if this happened in a guild - late final IMember? member; - - /// Emoji object. - late final IEmoji emoji; - - /// Creates an instance of [MessageReactionEvent] - MessageReactionEvent(RawApiMap json, INyxx client) { - user = UserCacheable(client, Snowflake(json["d"]["user_id"])); - channel = CacheableTextChannel(client, Snowflake(json["d"]["channel_id"])); - guild = GuildCacheable(client, Snowflake(json["d"]["guild_id"])); - - if (json["d"]["member"] != null) { - member = Member(client, json["d"]["member"] as RawApiMap, guild!.id); - } else { - member = null; - } - - messageId = Snowflake(json["d"]["message_id"]); - - final channelInstance = channel.getFromCache(); - if (channelInstance != null) { - message = channelInstance.messageCache[messageId]; - } else { - message = null; - } - - if (json["d"]["emoji"]["id"] == null) { - emoji = UnicodeEmoji(json["d"]["emoji"]["name"] as String); - } else { - emoji = ResolvableGuildEmojiPartial(json["d"]["emoji"] as RawApiMap, client); - } - } -} - -abstract class IMessageReactionAddedEvent implements IMessageReactionEvent {} - -/// Emitted when reaction is add to message -class MessageReactionAddedEvent extends MessageReactionEvent implements IMessageReactionAddedEvent { - /// Creates an instance of [MessageReactionAddedEvent] - MessageReactionAddedEvent(RawApiMap raw, INyxx client) : super(raw, client) { - if (message == null) { - return; - } - - final r = message!.reactions.indexWhere((r) => r.emoji == emoji); - - if (r == -1) { - message!.reactions.add(Reaction.event(emoji, user == (client as NyxxWebsocket).self)); - } else { - (message!.reactions[r] as Reaction).count++; - } - } -} - -abstract class IMessageReactionRemovedEvent implements IMessageReactionEvent {} - -/// Emitted when reaction is removed from message -class MessageReactionRemovedEvent extends MessageReactionEvent implements IMessageReactionRemovedEvent { - /// Creates an instance of [MessageReactionRemovedEvent] - MessageReactionRemovedEvent(RawApiMap json, INyxx client) : super(json, client) { - if (message == null) { - return; - } - - final r = message!.reactions.indexWhere((r) => r.emoji == emoji); - - if (r != -1) { - if (message!.reactions[r].count == 1) { - message!.reactions.removeAt(r); - } else { - (message!.reactions[r] as Reaction).count--; - } - } - } -} - -abstract class IMessageReactionsRemovedEvent { - /// Channel on which event was fired - CacheableTextChannel get channel; - - /// Message reference - Cacheable get message; - - /// Guild where event occurs - Cacheable? get guild; -} - -/// Emitted when all reaction are removed -class MessageReactionsRemovedEvent implements IMessageReactionsRemovedEvent { - /// Channel on which event was fired - @override - late final CacheableTextChannel channel; - - /// Message reference - @override - late final Cacheable message; - - /// Guild where event occurs - @override - late final Cacheable? guild; - - /// Creates an instance of [MessageReactionsRemovedEvent] - MessageReactionsRemovedEvent(RawApiMap json, INyxx client) { - channel = CacheableTextChannel(client, Snowflake(json["d"]["channel_id"])); - guild = GuildCacheable(client, Snowflake(json["d"]["guild_id"])); - message = MessageCacheable(client, Snowflake(json["d"]["message_id"]), channel); - - final messageInstance = message.getFromCache(); - if (messageInstance != null) { - messageInstance.reactions.clear(); - } - } -} - -abstract class IMessageReactionRemoveEmojiEvent { - /// Channel on which event was fired - CacheableTextChannel get channel; - - /// Message reference - Cacheable get message; - - /// Guild where event occurs - Cacheable? get guild; - - /// Removed emoji - IEmoji get emoji; -} - -/// Emitted when reactions of certain emoji are deleted -class MessageReactionRemoveEmojiEvent implements IMessageReactionRemoveEmojiEvent { - /// Channel on which event was fired - @override - late final CacheableTextChannel channel; - - /// Message reference - @override - late final Cacheable message; - - /// Guild where event occurs - @override - late final Cacheable? guild; - - /// Removed emoji - @override - late final IEmoji emoji; - - /// Creates an instance of [MessageReactionRemoveEmojiEvent] - MessageReactionRemoveEmojiEvent(RawApiMap json, INyxx client) { - channel = CacheableTextChannel(client, Snowflake(json["d"]["channel_id"])); - guild = GuildCacheable(client, Snowflake(json["d"]["guild_id"])); - message = MessageCacheable(client, Snowflake(json["d"]["message_id"]), channel); - - if (json["d"]["emoji"]["id"] == null) { - emoji = UnicodeEmoji(json["d"]["emoji"]["name"] as String); - } else { - emoji = ResolvableGuildEmojiPartial(json["d"]["emoji"] as RawApiMap, client); - } - - final messageInstance = message.getFromCache(); - if (messageInstance != null) { - messageInstance.reactions.removeWhere((element) => element.emoji == emoji); - } - } -} - -abstract class IMessageUpdateEvent { - /// Edited message with updated fields - IMessage? get updatedMessage; - - /// The message before it was updated, if it was cached. - IMessage? get oldMessage; - - /// Id of channel where message was edited - CacheableTextChannel get channel; - - /// Id of edited message - Snowflake get messageId; -} - -/// Sent when a message is updated. -class MessageUpdateEvent implements IMessageUpdateEvent { - /// Edited message with updated fields - @override - late final IMessage? updatedMessage; - - @override - late final IMessage? oldMessage; - - /// Id of channel where message was edited - @override - late final CacheableTextChannel channel; - - /// Id of edited message - @override - late final Snowflake messageId; - - /// Creates an instance of [MessageUpdateEvent] - MessageUpdateEvent(RawApiMap raw, INyxx client) { - channel = CacheableTextChannel(client, Snowflake(raw["d"]["channel_id"])); - messageId = Snowflake(raw["d"]["id"]); - - final channelInstance = channel.getFromCache(); - if (channelInstance == null) { - updatedMessage = null; - oldMessage = null; - return; - } - - oldMessage = channelInstance.messageCache[messageId]; - - if (oldMessage == null) { - updatedMessage = null; - return; - } - - updatedMessage = Message.copy(oldMessage as Message); - - if (raw["d"]["content"] != updatedMessage!.content) { - (updatedMessage! as Message).content = raw["d"]["content"].toString(); - } - - if (raw["d"]["embeds"] != null) { - (updatedMessage! as Message).embeds = (raw["d"]["embeds"] as RawApiList).map((e) => Embed(e as RawApiMap)).toList(); - } - - if (raw['d']['edited_timestamp'] != null) { - (updatedMessage as Message).editedTimestamp = DateTime.parse(raw['d']['edited_timestamp'] as String); - } - - if (raw['d']['attachments'] != null) { - (updatedMessage as Message).attachments = [for (final attachment in raw['d']['attachments'] as RawApiList) Attachment(attachment as RawApiMap)]; - } - - if (raw['d']['pinned'] != null) { - (updatedMessage as Message).pinned = raw['d']['pinned'] as bool; - } - - if (raw['d']['flags'] != null) { - (updatedMessage as Message).flags = MessageFlags(raw['d']['flags'] as int); - } - - if (raw['d']['components'] != null && (raw['d']['components'] as RawApiList).isNotEmpty) { - (updatedMessage as Message).components = [ - for (final rawRow in raw['d']["components"] as RawApiList) - [for (final componentRaw in rawRow["components"] as RawApiList) MessageComponent.deserialize(componentRaw as RawApiMap, client)] - ]; - } - - channelInstance.messageCache[messageId] = updatedMessage!; - } -} diff --git a/lib/src/events/presence_update_event.dart b/lib/src/events/presence_update_event.dart deleted file mode 100644 index a6b51d1eb..000000000 --- a/lib/src/events/presence_update_event.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/guild/status.dart'; -import 'package:nyxx/src/core/user/presence.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IPresenceUpdateEvent { - /// User object - Cacheable get user; - - /// Users current activities - Iterable get presences; - - /// Status of client - IClientStatus get clientStatus; -} - -/// Sent when a member's presence updates. -class PresenceUpdateEvent implements IPresenceUpdateEvent { - /// User object - @override - late final Cacheable user; - - /// Users current activities - @override - late final Iterable presences; - - /// Status of client - @override - late final IClientStatus clientStatus; - - /// Creates an instance of [PresenceUpdateEvent] - PresenceUpdateEvent(RawApiMap raw, INyxx client) { - presences = [for (final rawActivity in raw["d"]["activities"] as RawApiList) Activity(rawActivity as RawApiMap, client)]; - clientStatus = ClientStatus(raw["d"]["client_status"] as RawApiMap); - user = UserCacheable(client, Snowflake(raw["d"]["user"]["id"])); - - final cachedUser = user.getFromCache(); - if (cachedUser != null) { - if (clientStatus != cachedUser.status) { - (cachedUser as User).status = clientStatus; - } - - (cachedUser as User).presence = presences.isNotEmpty ? presences.first : null; - } - } -} diff --git a/lib/src/events/ratelimit_event.dart b/lib/src/events/ratelimit_event.dart deleted file mode 100644 index 0f706a4ee..000000000 --- a/lib/src/events/ratelimit_event.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:http/http.dart' as http; - -import 'package:nyxx/src/internal/http/http_request.dart'; - -abstract class IRatelimitEvent { - /// True if rate limit handler stopped the request - /// False if the client received a 429 - bool get handled; - - /// The request that was rate limited. - HttpRequest get request; - - /// The error response received if the rate limit handler did not stop - /// the request (aka hit 429) - http.BaseResponse? get response; -} - -/// Sent when the client is rate limit -/// ed, either by the rate limit handler itself, -/// or when a 429 is received. -class RatelimitEvent implements IRatelimitEvent { - /// True if rate limit handler stopped the request - /// False if the client received a 429 - @override - final bool handled; - - /// The request that was rate limited. - @override - final HttpRequest request; - - /// The error response received if the rate limit handler did not stop - /// the request (aka hit 429) - @override - final http.BaseResponse? response; - - /// Creates an instance of [RatelimitEvent] - RatelimitEvent(this.request, this.handled, [this.response]); -} diff --git a/lib/src/events/raw_event.dart b/lib/src/events/raw_event.dart deleted file mode 100644 index 1b642ba53..000000000 --- a/lib/src/events/raw_event.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:nyxx/src/internal/shard/shard.dart'; -import 'package:nyxx/src/typedefs.dart'; - -/// Raw gateway event -abstract class IRawEvent { - /// Shard where event was received - IShard get shard; - - /// Raw event data as deserialized json - RawApiMap get rawData; -} - -class RawEvent implements IRawEvent { - @override - final IShard shard; - - @override - final RawApiMap rawData; - - RawEvent(this.shard, this.rawData); -} diff --git a/lib/src/events/ready_event.dart b/lib/src/events/ready_event.dart deleted file mode 100644 index b2f2b0023..000000000 --- a/lib/src/events/ready_event.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; - -abstract class IReadyEvent {} - -/// Sent when the client is ready. -class ReadyEvent implements IReadyEvent { - /// Creates an instance of [ReadyEvent] - ReadyEvent(INyxx client); -} diff --git a/lib/src/events/thread_create_event.dart b/lib/src/events/thread_create_event.dart deleted file mode 100644 index 7086edefb..000000000 --- a/lib/src/events/thread_create_event.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IThreadCreateEvent { - /// The thread that was just created - IThreadChannel get thread; - - /// True if thread is created - bool get newlyCreated; - - /// Set when bot joining private thread - IThreadMember? get member; -} - -/// Fired when a thread is created or when bot joins thread -class ThreadCreateEvent implements IThreadCreateEvent { - /// The thread that was just created - @override - late final IThreadChannel thread; - - @override - late final bool newlyCreated; - - @override - late final IThreadMember? member; - - /// Creates an instance of [ThreadCreateEvent] - ThreadCreateEvent(RawApiMap raw, INyxx client) { - thread = ThreadChannel(client, raw["d"] as RawApiMap); - newlyCreated = raw['d']['newly_created'] as bool? ?? false; - member = raw['d']['member'] != null ? ThreadMember(client, raw['d']['member'] as RawApiMap, thread.guild) : null; - - if (client.cacheOptions.channelCachePolicyLocation.event && client.cacheOptions.channelCachePolicy.canCache(thread)) { - client.channels[thread.id] = thread; - } - } -} - -abstract class IThreadUpdateEvent { - /// The thread that was just updated - IThreadChannel get thread; - - /// The thread as it was before it was updated, if it was cached. - IThreadChannel? get oldThread; -} - -class ThreadUpdateEvent implements IThreadUpdateEvent { - @override - late final IThreadChannel thread; - - @override - late final IThreadChannel? oldThread; - - ThreadUpdateEvent(RawApiMap raw, INyxx client) { - thread = ThreadChannel(client, raw["d"] as RawApiMap); - oldThread = client.channels[thread.id] as IThreadChannel?; - - if (client.cacheOptions.channelCachePolicyLocation.event && client.cacheOptions.channelCachePolicy.canCache(thread)) { - client.channels[thread.id] = thread; - } - } -} diff --git a/lib/src/events/thread_deleted_event.dart b/lib/src/events/thread_deleted_event.dart deleted file mode 100644 index 4a45a0b0d..000000000 --- a/lib/src/events/thread_deleted_event.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IThreadDeletedEvent { - /// Thread that was deleted - CacheableTextChannel get thread; - - /// Channel where thread was located - CacheableTextChannel get parent; - - /// Guild where event was generated - Cacheable get guild; -} - -/// Fired when a thread is created -class ThreadDeletedEvent implements IThreadDeletedEvent { - /// Thread that was deleted - @override - late final CacheableTextChannel thread; - - /// Channel where thread was located - @override - late final CacheableTextChannel parent; - - /// Guild where event was generated - @override - late final Cacheable guild; - - /// Creates an instance of [ThreadDeletedEvent] - ThreadDeletedEvent(RawApiMap raw, INyxx client) { - final data = raw["d"] as RawApiMap; - - thread = CacheableTextChannel(client, Snowflake(data["id"])); - parent = CacheableTextChannel(client, Snowflake(data["parent_id"])); - guild = GuildCacheable(client, Snowflake(data["guild_id"])); - } -} diff --git a/lib/src/events/thread_list_sync_event.dart b/lib/src/events/thread_list_sync_event.dart deleted file mode 100644 index e899df540..000000000 --- a/lib/src/events/thread_list_sync_event.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IThreadListSyncEvent { - /// The guild being synced. - Cacheable get guild; - - /// A list of channels which are the parents of the threads being synced. - List> get channels; - - /// The threads being synced. - List get threads; - - /// The thread members being synced. - List get threadMembers; -} - -class ThreadListSyncEvent implements IThreadListSyncEvent { - @override - late final Cacheable guild; - - @override - late final List> channels; - - @override - late final List threads; - - @override - late final List threadMembers; - - ThreadListSyncEvent(RawApiMap raw, INyxx client) { - guild = GuildCacheable(client, Snowflake(raw['guild_id'])); - channels = [ - for (final channelId in (raw['channel_ids'] ?? []) as RawApiList) ChannelCacheable(client, Snowflake(channelId)), - ]; - - threads = [ - for (final rawThread in raw['threads'] as RawApiList) ThreadChannel(client, rawThread as RawApiMap), - ]; - - for (final thread in threads) { - if (client.cacheOptions.channelCachePolicyLocation.event && client.cacheOptions.channelCachePolicy.canCache(thread)) { - client.channels[thread.id] = thread; - } - } - - threadMembers = [ - for (final rawMember in raw['members'] as RawApiList) ThreadMember(client, rawMember as RawApiMap, guild), - ]; - } -} diff --git a/lib/src/events/thread_members_update_event.dart b/lib/src/events/thread_members_update_event.dart deleted file mode 100644 index 6518adb3f..000000000 --- a/lib/src/events/thread_members_update_event.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IThreadMembersUpdateEvent { - /// The thread that was updated - CacheableTextChannel get thread; - - /// The guild it was updated in - Cacheable get guild; - - /// The members that were added. Note that they are not cached - Iterable> get addedMembers; - - /// The approximate number of members in the thread, capped at 50 - int get approxMemberCount; - - /// Users who were removed from the thread - Iterable> get removedUsers; -} - -/// Fired when a thread has a member added/removed -class ThreadMembersUpdateEvent implements IThreadMembersUpdateEvent { - /// The thread that was updated - @override - late final CacheableTextChannel thread; - - /// The guild it was updated in - @override - late final Cacheable guild; - - /// The members that were added. Note that they are not cached - @override - late final Iterable> addedMembers; - - /// The approximate number of members in the thread, capped at 50 - @override - late final int approxMemberCount; - - /// Users who were removed from the thread - @override - late final Iterable> removedUsers; - - /// Creates an instance of [ThreadMembersUpdateEvent] - ThreadMembersUpdateEvent(RawApiMap raw, INyxx client) { - final data = raw["d"] as RawApiMap; - - thread = CacheableTextChannel(client, Snowflake(data["id"])); - guild = GuildCacheable(client, Snowflake(data["guild_id"])); - approxMemberCount = data["member_count"] as int; - - addedMembers = [ - if (data["added_members"] != null) - for (final memberData in data["added_members"] as RawApiList) MemberCacheable(client, Snowflake(memberData["user_id"]), guild) - ]; - - removedUsers = [ - if (data["removed_member_ids"] != null) - for (final removedUserId in data["removed_member_ids"] as RawApiList) UserCacheable(client, Snowflake(removedUserId)) - ]; - } -} - -abstract class IThreadMemberUpdateEvent { - /// The current user's thread member that was updated. - IThreadMember get member; -} - -class ThreadMemberUpdateEvent implements IThreadMemberUpdateEvent { - @override - late final ThreadMember member; - - ThreadMemberUpdateEvent(RawApiMap raw, INyxx client) { - member = ThreadMember( - client, - raw, - GuildCacheable(client, Snowflake(raw['guild_id'])), - ); - } -} diff --git a/lib/src/events/typing_event.dart b/lib/src/events/typing_event.dart deleted file mode 100644 index 62f7b11ff..000000000 --- a/lib/src/events/typing_event.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class ITypingEvent { - /// The channel that the user is typing in. - CacheableTextChannel get channel; - - /// The user that is typing. - Cacheable get user; - - /// The member who started typing if this happened in a guild - IMember? get member; - - /// Timestamp when the user started typing - DateTime get timestamp; - - /// Reference to guild where typing occurred - Cacheable? get guild; -} - -/// Sent when a user starts typing. -class TypingEvent implements ITypingEvent { - /// The channel that the user is typing in. - @override - late final CacheableTextChannel channel; - - /// The user that is typing. - @override - late final Cacheable user; - - /// The member who started typing if this happened in a guild - @override - late final IMember? member; - - /// Timestamp when the user started typing - @override - late final DateTime timestamp; - - /// Reference to guild where typing occurred - @override - late final Cacheable? guild; - - /// Creates an instance of [TypingEvent] - TypingEvent(RawApiMap raw, INyxx client) { - channel = CacheableTextChannel(client, Snowflake(raw["d"]["channel_id"])); - user = UserCacheable(client, Snowflake(raw["d"]["user_id"])); - timestamp = DateTime.fromMillisecondsSinceEpoch(raw["d"]["timestamp"] as int); - - if (raw["d"]["guild_id"] != null) { - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - } else { - guild = null; - } - - if (raw["d"]["member"] == null) { - member = null; - return; - } - - member = Member(client, raw["d"]["member"] as RawApiMap, guild!.id); - if (client.cacheOptions.memberCachePolicyLocation.event && client.cacheOptions.memberCachePolicy.canCache(member!)) { - member!.guild.getFromCache()?.members[member!.id] = member!; - } - } -} diff --git a/lib/src/events/user_update_event.dart b/lib/src/events/user_update_event.dart deleted file mode 100644 index 3bad4d197..000000000 --- a/lib/src/events/user_update_event.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IUserUpdateEvent { - /// User instance after update - IUser get user; -} - -/// Emitted when user was updated -class UserUpdateEvent implements IUserUpdateEvent { - /// User instance after update - @override - late final User user; - - /// Creates an instance of [UserUpdateEvent] - UserUpdateEvent(RawApiMap json, INyxx client) { - user = User(client, json["d"] as RawApiMap); - } -} diff --git a/lib/src/events/voice_server_update_event.dart b/lib/src/events/voice_server_update_event.dart deleted file mode 100644 index 3a6673f1c..000000000 --- a/lib/src/events/voice_server_update_event.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IVoiceServerUpdateEvent { - /// Raw websocket event payload - RawApiMap get raw; - - /// Voice connection token - String get token; - - /// The voice server host - String get endpoint; - - /// The guild this voice server update is for - Cacheable get guild; -} - -/// Emitted when guild's voice server changes -class VoiceServerUpdateEvent implements IVoiceServerUpdateEvent { - /// Raw websocket event payload - @override - final RawApiMap raw; - - /// Voice connection token - @override - late final String token; - - /// The voice server host - @override - late final String endpoint; - - /// The guild this voice server update is for - @override - late final Cacheable guild; - - /// Creates an instance of [VoiceServerUpdateEvent] - VoiceServerUpdateEvent(this.raw, INyxx client) { - token = raw["d"]["token"] as String; - endpoint = raw["d"]["endpoint"] as String; - guild = GuildCacheable(client, Snowflake(raw["d"]["guild_id"])); - } -} diff --git a/lib/src/events/voice_state_update_event.dart b/lib/src/events/voice_state_update_event.dart deleted file mode 100644 index 47fb63eb1..000000000 --- a/lib/src/events/voice_state_update_event.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/voice/voice_state.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IVoiceStateUpdateEvent { - /// Used to represent a user's voice connection status. - IVoiceState get state; - - /// The previous voice state, if it was cached. - IVoiceState? get oldState; - - /// Raw gateway response - RawApiMap get raw; -} - -/// Emitted when client connects/disconnects/mutes etc to voice channel -class VoiceStateUpdateEvent implements IVoiceStateUpdateEvent { - /// Used to represent a user's voice connection status. - @override - late final IVoiceState state; - - @override - late final IVoiceState? oldState; - - /// Raw gateway response - @override - final RawApiMap raw; - - /// Creates an instance of [VoiceStateUpdateEvent] - VoiceStateUpdateEvent(this.raw, INyxx client) { - state = VoiceState(client, raw["d"] as RawApiMap); - - oldState = state.guild?.getFromCache()?.voiceStates[state.user.id]; - - if (state.channel != null) { - state.guild?.getFromCache()?.voiceStates[state.user.id] = state; - } else { - state.guild?.getFromCache()?.voiceStates.remove(state.user.id); - } - } -} diff --git a/lib/src/gateway/event_parser.dart b/lib/src/gateway/event_parser.dart new file mode 100644 index 000000000..cc07aed1b --- /dev/null +++ b/lib/src/gateway/event_parser.dart @@ -0,0 +1,53 @@ +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/gateway/opcode.dart'; + +/// An internal class which allows the shard runner to parse gateway events +/// without having a reference to the client's [GatewayManager]. +mixin class EventParser { + GatewayEvent parseGatewayEvent(Map raw, {Duration? heartbeatLatency}) { + final mapping = { + Opcode.dispatch.value: parseDispatch, + Opcode.heartbeat.value: parseHeartbeat, + Opcode.reconnect.value: parseReconnect, + Opcode.invalidSession.value: parseInvalidSession, + Opcode.hello.value: parseHello, + Opcode.heartbeatAck.value: (Map raw) => parseHeartbeatAck(raw, heartbeatLatency: heartbeatLatency!), + }; + + return mapping[raw['op'] as int]!(raw); + } + + HeartbeatEvent parseHeartbeat(Map raw) { + return HeartbeatEvent(); + } + + ReconnectEvent parseReconnect(Map raw) { + return ReconnectEvent(); + } + + InvalidSessionEvent parseInvalidSession(Map raw) { + return InvalidSessionEvent( + isResumable: raw['d'] as bool, + ); + } + + HelloEvent parseHello(Map raw) { + return HelloEvent( + heartbeatInterval: Duration( + milliseconds: (raw['d'] as Map)['heartbeat_interval'] as int, + ), + ); + } + + HeartbeatAckEvent parseHeartbeatAck(Map raw, {required Duration heartbeatLatency}) { + return HeartbeatAckEvent(latency: heartbeatLatency); + } + + RawDispatchEvent parseDispatch(Map raw) { + return RawDispatchEvent( + seq: raw['s'] as int, + name: raw['t'] as String, + payload: raw['d'] as Map, + ); + } +} diff --git a/lib/src/gateway/gateway.dart b/lib/src/gateway/gateway.dart new file mode 100644 index 000000000..a0f3b535b --- /dev/null +++ b/lib/src/gateway/gateway.dart @@ -0,0 +1,1040 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/builders/presence.dart'; +import 'package:nyxx/src/builders/voice.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/gateway/event_parser.dart'; +import 'package:nyxx/src/gateway/message.dart'; +import 'package:nyxx/src/gateway/shard.dart'; +import 'package:nyxx/src/http/managers/gateway_manager.dart'; +import 'package:nyxx/src/http/managers/member_manager.dart'; +import 'package:nyxx/src/http/managers/message_manager.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/gateway/gateway.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/gateway/events/application_command.dart'; +import 'package:nyxx/src/models/gateway/events/auto_moderation.dart'; +import 'package:nyxx/src/models/gateway/events/channel.dart'; +import 'package:nyxx/src/models/gateway/events/guild.dart'; +import 'package:nyxx/src/models/gateway/events/integration.dart'; +import 'package:nyxx/src/models/gateway/events/interaction.dart'; +import 'package:nyxx/src/models/gateway/events/invite.dart'; +import 'package:nyxx/src/models/gateway/events/message.dart'; +import 'package:nyxx/src/models/gateway/events/presence.dart'; +import 'package:nyxx/src/models/gateway/events/ready.dart'; +import 'package:nyxx/src/models/gateway/events/stage_instance.dart'; +import 'package:nyxx/src/models/gateway/events/voice.dart'; +import 'package:nyxx/src/models/gateway/events/webhook.dart'; +import 'package:nyxx/src/models/gateway/opcode.dart'; +import 'package:nyxx/src/models/guild/auto_moderation.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/interaction.dart'; +import 'package:nyxx/src/models/presence.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/iterable_extension.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// Handles the connection to Discord's Gateway with shards, manages the client's cache based on Gateway events and provides an interface to the Gateway. +class Gateway extends GatewayManager with EventParser { + @override + final NyxxGateway client; + + /// The [GatewayBot] instance used to configure this [Gateway]. + final GatewayBot gatewayBot; + + /// The total number of shards running in the client's session. + final int totalShards; + + /// The IDs of the shards running in this [Gateway]. + final List shardIds; + + /// The shards running in this [Gateway]. + final List shards; + + /// A stream of messages received from all shards. + Stream get messages => _messagesController.stream; + + final StreamController _messagesController = StreamController.broadcast(); + + /// A stream of dispatch events received from all shards. + Stream get events => messages.map((message) { + if (message is! EventReceived) { + return null; + } + + final event = message.event; + if (event is! RawDispatchEvent) { + return null; + } + + return parseDispatchEvent(event); + }).whereType(); + + bool _closing = false; + + /// The average latency across all shards in this [Gateway]. + /// + /// See [Shard.latency] for details on how the latency is calculated. + Duration get latency => shards.fold(Duration.zero, (previousValue, element) => previousValue + (element.latency ~/ shards.length)); + + /// Create a new [Gateway]. + Gateway(this.client, this.gatewayBot, this.shards, this.totalShards, this.shardIds) : super.create() { + for (final shard in shards) { + shard.listen( + (message) { + if (message is ErrorReceived) { + shard.logger.warning('Received error: ${message.error}', message.error, message.stackTrace); + } + + _messagesController.add(message); + }, + onError: _messagesController.addError, + onDone: () async { + if (_closing) { + return; + } + + await client.close(); + + throw ShardDisconnectedError(shard); + }, + ); + } + + // Handle all events which should update cache. + events.listen((event) => switch (event) { + ReadyEvent(:final user) => client.users.cache[user.id] = user, + ChannelCreateEvent(:final channel) || ChannelUpdateEvent(:final channel) => client.channels.cache[channel.id] = channel, + ChannelDeleteEvent(:final channel) => client.channels.cache.remove(channel.id), + ThreadCreateEvent(:final thread) || ThreadUpdateEvent(:final thread) => client.channels.cache[thread.id] = thread, + ThreadDeleteEvent(:final thread) => client.channels.cache.remove(thread.id), + ThreadListSyncEvent(:final threads) => client.channels.cache..addEntities(threads), + final GuildCreateEvent event => () { + client.guilds.cache[event.guild.id] = event.guild; + + event.guild.members.cache.addEntities(event.members); + client.channels.cache.addEntities(event.channels); + client.channels.cache.addEntities(event.threads); + client.channels.stageInstanceCache.addEntities(event.stageInstances); + event.guild.scheduledEvents.cache.addEntities(event.scheduledEvents); + client.voice.cache.addEntries(event.voiceStates.map((e) => MapEntry(e.cacheKey, e))); + }(), + GuildUpdateEvent(:final guild) => client.guilds.cache[guild.id] = guild, + GuildDeleteEvent(:final guild, isUnavailable: false) => client.guilds.cache.remove(guild.id), + GuildMemberAddEvent(:final guildId, :final member) || + GuildMemberUpdateEvent(:final guildId, :final member) => + client.guilds[guildId].members.cache[member.id] = member, + GuildMemberRemoveEvent(:final guildId, :final user) => client.guilds[guildId].members.cache.remove(user.id), + GuildMembersChunkEvent(:final guildId, :final members) => client.guilds[guildId].members.cache..addEntities(members), + GuildRoleCreateEvent(:final guildId, :final role) || + GuildRoleUpdateEvent(:final guildId, :final role) => + client.guilds[guildId].roles.cache[role.id] = role, + GuildRoleDeleteEvent(:final guildId, :final roleId) => client.guilds[guildId].roles.cache.remove(roleId), + MessageCreateEvent(:final message) => (client.channels[message.channelId] as PartialTextChannel).messages.cache[message.id] = message, + MessageDeleteEvent(id: final messageId, :final channelId) => + MessageManager(client.options.messageCacheConfig, client, channelId: channelId).cache.remove(messageId), + MessageBulkDeleteEvent(ids: final messageIds, :final channelId) => + // ignore: avoid_function_literals_in_foreach_calls + messageIds..forEach((messageId) => MessageManager(client.options.messageCacheConfig, client, channelId: channelId).cache.remove(messageId)), + UserUpdateEvent(:final user) => client.users.cache[user.id] = user, + StageInstanceCreateEvent(:final instance) || StageInstanceUpdateEvent(:final instance) => client.channels.stageInstanceCache[instance.channelId] = + instance, + StageInstanceDeleteEvent(:final instance) => client.channels.stageInstanceCache.remove(instance.channelId), + GuildScheduledEventCreateEvent(:final event) || + GuildScheduledEventUpdateEvent(:final event) => + client.guilds[event.guildId].scheduledEvents.cache[event.id] = event, + GuildScheduledEventDeleteEvent(:final event) => client.guilds[event.guildId].scheduledEvents.cache.remove(event.id), + AutoModerationRuleCreateEvent(:final rule) || + AutoModerationRuleUpdateEvent(:final rule) => + client.guilds[rule.guildId].autoModerationRules.cache[rule.id] = rule, + AutoModerationRuleDeleteEvent(:final rule) => client.guilds[rule.guildId].autoModerationRules.cache.remove(rule.id), + IntegrationCreateEvent(:final guildId, :final integration) || + IntegrationUpdateEvent(:final guildId, :final integration) => + client.guilds[guildId].integrations.cache[integration.id] = integration, + IntegrationDeleteEvent(:final id, :final guildId) => client.guilds[guildId].integrations.cache.remove(id), + GuildAuditLogCreateEvent(:final entry, :final guildId) => client.guilds[guildId].auditLogs.cache[entry.id] = entry, + VoiceStateUpdateEvent(:final state) => client.voice.cache[state.cacheKey] = state, + GuildEmojisUpdateEvent(:final guildId, :final emojis) => client.guilds[guildId].emojis.cache + ..clear() + ..addEntities(emojis), + GuildStickersUpdateEvent(:final guildId, :final stickers) => client.guilds[guildId].stickers.cache.addEntities(stickers), + ApplicationCommandPermissionsUpdateEvent(:final permissions) => client.guilds[permissions.guildId].commands.permissionsCache[permissions.id] = + permissions, + InteractionCreateEvent(interaction: Interaction(:final guildId, data: ApplicationCommandInteractionData(resolved: final data?))) => () { + if (data.users != null) { + client.users.cache.addAll(data.users!); + } + + if (data.members != null && guildId != null) { + client.guilds[guildId].members.cache.addAll(data.members!); + } + + if (data.roles != null && guildId != null) { + client.guilds[guildId].roles.cache.addAll(data.roles!); + } + }(), + _ => null, + }); + } + + /// Connect to the gateway using the provided [client] and [gatewayBot] configuration. + static Future connect(NyxxGateway client, GatewayBot gatewayBot) async { + final logger = Logger('${client.options.loggerName}.Gateway'); + + final totalShards = client.apiOptions.totalShards ?? gatewayBot.shards; + final List shardIds = client.apiOptions.shards ?? List.generate(totalShards, (i) => i); + + logger + ..info('Connecting ${shardIds.length}/$totalShards shards') + ..fine('Shard IDs: $shardIds') + ..fine( + 'Gateway URL: ${gatewayBot.url}, Recommended Shards: ${gatewayBot.shards}, Max Concurrency: ${gatewayBot.sessionStartLimit.maxConcurrency},' + ' Remaining Session Starts: ${gatewayBot.sessionStartLimit.remaining}, Reset After: ${gatewayBot.sessionStartLimit.resetAfter}', + ); + + if (gatewayBot.sessionStartLimit.remaining < 50) { + logger.warning('${gatewayBot.sessionStartLimit.remaining} session starts remaining'); + } + + if (gatewayBot.sessionStartLimit.remaining < client.options.minimumSessionStarts) { + throw OutOfRemainingSessionsError(gatewayBot); + } + + assert( + shardIds.every((element) => element < totalShards), + 'Shard ID exceeds total shard count', + ); + + assert( + shardIds.every((element) => element >= 0), + 'Invalid shard ID', + ); + + assert( + shardIds.toSet().length == shardIds.length, + 'Duplicate shard ID', + ); + + assert( + client.apiOptions.compression != GatewayCompression.payload || client.apiOptions.payloadFormat != GatewayPayloadFormat.etf, + 'Cannot enable payload compression when using the ETF payload format', + ); + + const identifyDelay = Duration(seconds: 5); + + final shards = shardIds.indexed.map(((int, int) info) { + final (index, id) = info; + + return Future.delayed( + identifyDelay * (index ~/ gatewayBot.sessionStartLimit.maxConcurrency), + () => Shard.connect(id, totalShards, client.apiOptions, gatewayBot.url, client), + ); + }); + + return Gateway(client, gatewayBot, await Future.wait(shards), totalShards, shardIds); + } + + /// Close this [Gateway] instance, disconnecting all shards and closing the event streams. + Future close() async { + _closing = true; + await Future.wait(shards.map((shard) => shard.close())); + _messagesController.close(); + } + + /// Compute the ID of the shard that handles events for [guildId]. + int shardIdFor(Snowflake guildId) => (guildId.value >> 22) % totalShards; + + /// Return the shard that handles events for [guildId]. + /// + /// Throws an error if the shard handling events for [guildId] is not in this [Gateway] instance. + Shard shardFor(Snowflake guildId) => shards.singleWhere((shard) => shard.id == shardIdFor(guildId)); + + DispatchEvent parseDispatchEvent(RawDispatchEvent raw) { + final mapping = { + 'READY': parseReady, + 'RESUMED': parseResumed, + 'APPLICATION_COMMAND_PERMISSIONS_UPDATE': parseApplicationCommandPermissionsUpdate, + 'AUTO_MODERATION_RULE_CREATE': parseAutoModerationRuleCreate, + 'AUTO_MODERATION_RULE_UPDATE': parseAutoModerationRuleUpdate, + 'AUTO_MODERATION_RULE_DELETE': parseAutoModerationRuleDelete, + 'AUTO_MODERATION_ACTION_EXECUTION': parseAutoModerationActionExecution, + 'CHANNEL_CREATE': parseChannelCreate, + 'CHANNEL_UPDATE': parseChannelUpdate, + 'CHANNEL_DELETE': parseChannelDelete, + 'THREAD_CREATE': parseThreadCreate, + 'THREAD_UPDATE': parseThreadUpdate, + 'THREAD_DELETE': parseThreadDelete, + 'THREAD_LIST_SYNC': parseThreadListSync, + 'THREAD_MEMBER_UPDATE': parseThreadMemberUpdate, + 'THREAD_MEMBERS_UPDATE': parseThreadMembersUpdate, + 'CHANNEL_PINS_UPDATE': parseChannelPinsUpdate, + 'GUILD_CREATE': parseGuildCreate, + 'GUILD_UPDATE': parseGuildUpdate, + 'GUILD_DELETE': parseGuildDelete, + 'GUILD_AUDIT_LOG_ENTRY_CREATE': parseGuildAuditLogCreate, + 'GUILD_BAN_ADD': parseGuildBanAdd, + 'GUILD_BAN_REMOVE': parseGuildBanRemove, + 'GUILD_EMOJIS_UPDATE': parseGuildEmojisUpdate, + 'GUILD_STICKERS_UPDATE': parseGuildStickersUpdate, + 'GUILD_INTEGRATIONS_UPDATE': parseGuildIntegrationsUpdate, + 'GUILD_MEMBER_ADD': parseGuildMemberAdd, + 'GUILD_MEMBER_REMOVE': parseGuildMemberRemove, + 'GUILD_MEMBER_UPDATE': parseGuildMemberUpdate, + 'GUILD_MEMBERS_CHUNK': parseGuildMembersChunk, + 'GUILD_ROLE_CREATE': parseGuildRoleCreate, + 'GUILD_ROLE_UPDATE': parseGuildRoleUpdate, + 'GUILD_ROLE_DELETE': parseGuildRoleDelete, + 'GUILD_SCHEDULED_EVENT_CREATE': parseGuildScheduledEventCreate, + 'GUILD_SCHEDULED_EVENT_UPDATE': parseGuildScheduledEventUpdate, + 'GUILD_SCHEDULED_EVENT_DELETE': parseGuildScheduledEventDelete, + 'GUILD_SCHEDULED_EVENT_USER_ADD': parseGuildScheduledEventUserAdd, + 'GUILD_SCHEDULED_EVENT_USER_REMOVE': parseGuildScheduledEventUserRemove, + 'INTEGRATION_CREATE': parseIntegrationCreate, + 'INTEGRATION_UPDATE': parseIntegrationUpdate, + 'INTEGRATION_DELETE': parseIntegrationDelete, + 'INVITE_CREATE': parseInviteCreate, + 'INVITE_DELETE': parseInviteDelete, + 'MESSAGE_CREATE': parseMessageCreate, + 'MESSAGE_UPDATE': parseMessageUpdate, + 'MESSAGE_DELETE': parseMessageDelete, + 'MESSAGE_DELETE_BULK': parseMessageBulkDelete, + 'MESSAGE_REACTION_ADD': parseMessageReactionAdd, + 'MESSAGE_REACTION_REMOVE': parseMessageReactionRemove, + 'MESSAGE_REACTION_REMOVE_ALL': parseMessageReactionRemoveAll, + 'MESSAGE_REACTION_REMOVE_EMOJI': parseMessageReactionRemoveEmoji, + 'PRESENCE_UPDATE': parsePresenceUpdate, + 'TYPING_START': parseTypingStart, + 'USER_UPDATE': parseUserUpdate, + 'VOICE_STATE_UPDATE': parseVoiceStateUpdate, + 'VOICE_SERVER_UPDATE': parseVoiceServerUpdate, + 'WEBHOOKS_UPDATE': parseWebhooksUpdate, + 'INTERACTION_CREATE': parseInteractionCreate, + 'STAGE_INSTANCE_CREATE': parseStageInstanceCreate, + 'STAGE_INSTANCE_UPDATE': parseStageInstanceUpdate, + 'STAGE_INSTANCE_DELETE': parseStageInstanceDelete, + }; + + return mapping[raw.name]?.call(raw.payload) ?? UnknownDispatchEvent(gateway: this, raw: raw); + } + + ReadyEvent parseReady(Map raw) { + return ReadyEvent( + gateway: this, + version: raw['v'] as int, + user: client.users.parse(raw['user'] as Map), + guilds: parseMany( + raw['guilds'] as List, + (Map raw) => PartialGuild(id: Snowflake.parse(raw['id']!), manager: client.guilds), + ), + sessionId: raw['session_id'] as String, + gatewayResumeUrl: Uri.parse(raw['resume_gateway_url'] as String), + shardId: (raw['shard'] as List?)?[0] as int?, + totalShards: (raw['shard'] as List?)?[1] as int?, + application: PartialApplication( + id: Snowflake.parse((raw['application'] as Map)['id']!), + manager: client.applications, + ), + ); + } + + ResumedEvent parseResumed(Map raw) { + return ResumedEvent( + gateway: this, + ); + } + + ApplicationCommandPermissionsUpdateEvent parseApplicationCommandPermissionsUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + final permissions = client.guilds[guildId].commands.parseCommandPermissions(raw); + + return ApplicationCommandPermissionsUpdateEvent( + gateway: this, + permissions: permissions, + oldPermissions: client.guilds[guildId].commands.permissionsCache[permissions.id], + ); + } + + AutoModerationRuleCreateEvent parseAutoModerationRuleCreate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return AutoModerationRuleCreateEvent( + gateway: this, + rule: client.guilds[guildId].autoModerationRules.parse(raw), + ); + } + + AutoModerationRuleUpdateEvent parseAutoModerationRuleUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + final rule = client.guilds[guildId].autoModerationRules.parse(raw); + + return AutoModerationRuleUpdateEvent( + gateway: this, + oldRule: client.guilds[guildId].autoModerationRules.cache[rule.id], + rule: rule, + ); + } + + AutoModerationRuleDeleteEvent parseAutoModerationRuleDelete(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return AutoModerationRuleDeleteEvent( + gateway: this, + rule: client.guilds[guildId].autoModerationRules.parse(raw), + ); + } + + AutoModerationActionExecutionEvent parseAutoModerationActionExecution(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return AutoModerationActionExecutionEvent( + gateway: this, + guildId: guildId, + action: client.guilds[guildId].autoModerationRules.parseAutoModerationAction(raw['action'] as Map), + ruleId: Snowflake.parse(raw['rule_id']!), + triggerType: TriggerType.parse(raw['rule_trigger_type'] as int), + userId: Snowflake.parse(raw['user_id']!), + channelId: maybeParse(raw['channel_id'], Snowflake.parse), + messageId: maybeParse(raw['message_id'], Snowflake.parse), + alertSystemMessageId: maybeParse(raw['alert_system_message_id'], Snowflake.parse), + content: raw['content'] as String?, + matchedKeyword: raw['matched_keyword'] as String?, + matchedContent: raw['matched_content'] as String?, + ); + } + + ChannelCreateEvent parseChannelCreate(Map raw) { + return ChannelCreateEvent( + gateway: this, + channel: client.channels.parse(raw), + ); + } + + ChannelUpdateEvent parseChannelUpdate(Map raw) { + final channel = client.channels.parse(raw); + + return ChannelUpdateEvent( + gateway: this, + oldChannel: client.channels.cache[channel.id], + channel: channel, + ); + } + + ChannelDeleteEvent parseChannelDelete(Map raw) { + return ChannelDeleteEvent( + gateway: this, + channel: client.channels.parse(raw), + ); + } + + ThreadCreateEvent parseThreadCreate(Map raw) { + return ThreadCreateEvent( + gateway: this, + thread: client.channels.parse(raw) as Thread, + ); + } + + ThreadUpdateEvent parseThreadUpdate(Map raw) { + final thread = client.channels.parse(raw) as Thread; + + return ThreadUpdateEvent( + gateway: this, + oldThread: client.channels.cache[thread.id] as Thread?, + thread: thread, + ); + } + + ThreadDeleteEvent parseThreadDelete(Map raw) { + return ThreadDeleteEvent( + gateway: this, + thread: PartialChannel( + id: Snowflake.parse(raw['id']!), + manager: client.channels, + ), + ); + } + + ThreadListSyncEvent parseThreadListSync(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return ThreadListSyncEvent( + gateway: this, + guildId: guildId, + channelIds: maybeParseMany(raw['channel_ids'], Snowflake.parse), + threads: parseMany( + raw['threads'] as List, + (Map raw) => client.channels.parse(raw, guildId: guildId) as Thread, + ), + members: parseMany(raw['members'] as List, client.channels.parseThreadMember), + ); + } + + ThreadMemberUpdateEvent parseThreadMemberUpdate(Map raw) { + return ThreadMemberUpdateEvent( + gateway: this, + member: client.channels.parseThreadMember(raw), + ); + } + + ThreadMembersUpdateEvent parseThreadMembersUpdate(Map raw) { + return ThreadMembersUpdateEvent( + gateway: this, + id: Snowflake.parse(raw['id']!), + guildId: Snowflake.parse(raw['guild_id']!), + memberCount: raw['member_count'] as int, + addedMembers: maybeParseMany(raw['added_members'], client.channels.parseThreadMember), + removedMemberIds: maybeParseMany(raw['removed_member_ids'], Snowflake.parse), + ); + } + + ChannelPinsUpdateEvent parseChannelPinsUpdate(Map raw) { + return ChannelPinsUpdateEvent( + gateway: this, + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + channelId: Snowflake.parse(raw['channel_id']!), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + ); + } + + UnavailableGuildCreateEvent parseGuildCreate(Map raw) { + if (raw['unavailable'] == true) { + return UnavailableGuildCreateEvent(gateway: this, guild: PartialGuild(id: Snowflake.parse(raw['id']!), manager: client.guilds)); + } + + final guild = client.guilds.parse(raw); + + return GuildCreateEvent( + gateway: this, + guild: guild, + joinedAt: DateTime.parse(raw['joined_at'] as String), + isLarge: raw['large'] as bool, + memberCount: raw['member_count'] as int, + voiceStates: parseMany(raw['voice_states'] as List, client.voice.parseVoiceState), + members: parseMany(raw['members'] as List, client.guilds[guild.id].members.parse), + channels: parseMany(raw['channels'] as List, (Map raw) => client.channels.parse(raw, guildId: guild.id) as GuildChannel), + threads: parseMany(raw['threads'] as List, (Map raw) => client.channels.parse(raw, guildId: guild.id) as Thread), + presences: parseMany(raw['presences'] as List, parsePresenceUpdate), + stageInstances: parseMany(raw['stage_instances'] as List, client.channels.parseStageInstance), + scheduledEvents: parseMany(raw['guild_scheduled_events'] as List, client.guilds[guild.id].scheduledEvents.parse), + ); + } + + GuildUpdateEvent parseGuildUpdate(Map raw) { + final guild = client.guilds.parse(raw); + + return GuildUpdateEvent( + gateway: this, + oldGuild: client.guilds.cache[guild.id], + guild: guild, + ); + } + + GuildDeleteEvent parseGuildDelete(Map raw) { + return GuildDeleteEvent( + gateway: this, + guild: PartialGuild(id: Snowflake.parse(raw['id']!), manager: client.guilds), + isUnavailable: raw['unavailable'] as bool, + ); + } + + GuildAuditLogCreateEvent parseGuildAuditLogCreate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id'] as String); + + return GuildAuditLogCreateEvent( + gateway: this, + entry: client.guilds[guildId].auditLogs.parse(raw), + guildId: guildId, + ); + } + + GuildBanAddEvent parseGuildBanAdd(Map raw) { + return GuildBanAddEvent( + gateway: this, + guildId: Snowflake.parse(raw['guild_id']!), + user: client.users.parse(raw['user'] as Map), + ); + } + + GuildBanRemoveEvent parseGuildBanRemove(Map raw) { + return GuildBanRemoveEvent( + gateway: this, + guildId: Snowflake.parse(raw['guild_id']!), + user: client.users.parse(raw['user'] as Map), + ); + } + + GuildEmojisUpdateEvent parseGuildEmojisUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return GuildEmojisUpdateEvent( + gateway: this, + guildId: guildId, + emojis: parseMany(raw['emojis'] as List, client.guilds[guildId].emojis.parse), + ); + } + + GuildStickersUpdateEvent parseGuildStickersUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id'] as String); + + return GuildStickersUpdateEvent( + gateway: this, + guildId: guildId, + stickers: parseMany(raw['stickers'] as List, client.guilds[guildId].stickers.parse), + ); + } + + GuildIntegrationsUpdateEvent parseGuildIntegrationsUpdate(Map raw) { + return GuildIntegrationsUpdateEvent( + gateway: this, + guildId: Snowflake.parse(raw['guild_id']!), + ); + } + + GuildMemberAddEvent parseGuildMemberAdd(Map raw) { + final guildId = Snowflake.parse(raw['guild_id'] as String); + + return GuildMemberAddEvent( + gateway: this, + guildId: guildId, + member: client.guilds[guildId].members.parse(raw), + ); + } + + GuildMemberRemoveEvent parseGuildMemberRemove(Map raw) { + return GuildMemberRemoveEvent( + gateway: this, + guildId: Snowflake.parse(raw['guild_id']!), + user: client.users.parse(raw['user'] as Map), + ); + } + + GuildMemberUpdateEvent parseGuildMemberUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + final member = client.guilds[guildId].members.parse(raw); + + return GuildMemberUpdateEvent( + gateway: this, + oldMember: client.guilds[guildId].members.cache[member.id], + member: member, + guildId: guildId, + ); + } + + GuildMembersChunkEvent parseGuildMembersChunk(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return GuildMembersChunkEvent( + gateway: this, + guildId: guildId, + members: parseMany(raw['members'] as List, client.guilds[guildId].members.parse), + chunkIndex: raw['chunk_index'] as int, + chunkCount: raw['chunk_count'] as int, + notFound: maybeParseMany(raw['not_found'], Snowflake.parse), + presences: maybeParseMany(raw['presences'], parsePresenceUpdate), + nonce: raw['nonce'] as String?, + ); + } + + GuildRoleCreateEvent parseGuildRoleCreate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return GuildRoleCreateEvent( + gateway: this, + guildId: guildId, + role: client.guilds[guildId].roles.parse(raw['role'] as Map), + ); + } + + GuildRoleUpdateEvent parseGuildRoleUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + final role = client.guilds[guildId].roles.parse(raw['role'] as Map); + + return GuildRoleUpdateEvent( + gateway: this, + guildId: guildId, + oldRole: client.guilds[guildId].roles.cache[role.id], + role: role, + ); + } + + GuildRoleDeleteEvent parseGuildRoleDelete(Map raw) { + return GuildRoleDeleteEvent( + gateway: this, + roleId: Snowflake.parse(raw['role_id']!), + guildId: Snowflake.parse(raw['guild_id']!), + ); + } + + GuildScheduledEventCreateEvent parseGuildScheduledEventCreate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return GuildScheduledEventCreateEvent( + gateway: this, + event: client.guilds[guildId].scheduledEvents.parse(raw), + ); + } + + GuildScheduledEventUpdateEvent parseGuildScheduledEventUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + final event = client.guilds[guildId].scheduledEvents.parse(raw); + + return GuildScheduledEventUpdateEvent( + gateway: this, + oldEvent: client.guilds[guildId].scheduledEvents.cache[event.id], + event: event, + ); + } + + GuildScheduledEventDeleteEvent parseGuildScheduledEventDelete(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return GuildScheduledEventDeleteEvent( + gateway: this, + event: client.guilds[guildId].scheduledEvents.parse(raw), + ); + } + + GuildScheduledEventUserAddEvent parseGuildScheduledEventUserAdd(Map raw) { + return GuildScheduledEventUserAddEvent( + gateway: this, + scheduledEventId: Snowflake.parse(raw['guild_scheduled_event_id']!), + userId: Snowflake.parse(raw['user_id']!), + guildId: Snowflake.parse(raw['guild_id']!), + ); + } + + GuildScheduledEventUserRemoveEvent parseGuildScheduledEventUserRemove(Map raw) { + return GuildScheduledEventUserRemoveEvent( + gateway: this, + scheduledEventId: Snowflake.parse(raw['guild_scheduled_event_id']!), + userId: Snowflake.parse(raw['user_id']!), + guildId: Snowflake.parse(raw['guild_id']!), + ); + } + + IntegrationCreateEvent parseIntegrationCreate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return IntegrationCreateEvent( + gateway: this, + guildId: guildId, + integration: client.guilds[guildId].integrations.parse(raw), + ); + } + + IntegrationUpdateEvent parseIntegrationUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + final integration = client.guilds[guildId].integrations.parse(raw); + + return IntegrationUpdateEvent( + gateway: this, + guildId: guildId, + oldIntegration: client.guilds[guildId].integrations.cache[integration.id], + integration: integration, + ); + } + + IntegrationDeleteEvent parseIntegrationDelete(Map raw) { + return IntegrationDeleteEvent( + gateway: this, + id: Snowflake.parse(raw['id']!), + guildId: Snowflake.parse(raw['guild_id']!), + applicationId: maybeParse(raw['application_id'], Snowflake.parse), + ); + } + + InviteCreateEvent parseInviteCreate(Map raw) { + return InviteCreateEvent( + gateway: this, + invite: client.invites.parseWithMetadata({ + 'channel': {'id': raw['channel_id']}, + 'guild': {'id': raw['guild_id']}, + ...raw, + }), + ); + } + + InviteDeleteEvent parseInviteDelete(Map raw) { + return InviteDeleteEvent( + gateway: this, + channelId: Snowflake.parse(raw['channel_id']!), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + code: raw['code'] as String, + ); + } + + MessageCreateEvent parseMessageCreate(Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + final message = MessageManager( + client.options.messageCacheConfig, + client, + channelId: Snowflake.parse(raw['channel_id']!), + ).parse(raw); + + return MessageCreateEvent( + gateway: this, + guildId: guildId, + member: maybeParse( + raw['member'], + (Map raw) => PartialMember( + id: message.author.id, + manager: MemberManager(client.options.memberCacheConfig, client, guildId: guildId!), + ), + ), + mentions: parseMany(raw['mentions'] as List, client.users.parse), + message: message, + ); + } + + MessageUpdateEvent parseMessageUpdate(Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + final channelId = Snowflake.parse(raw['channel_id']!); + final id = Snowflake.parse(raw['id']!); + + return MessageUpdateEvent( + gateway: this, + guildId: guildId, + member: maybeParse( + raw['member'], + (Map _) => PartialMember( + id: Snowflake.parse((raw['author'] as Map)['id']!), + manager: client.guilds[guildId ?? Snowflake.zero].members, + ), + ), + mentions: parseMany(raw['mentions'] as List, client.users.parse), + message: (client.channels[channelId] as PartialTextChannel).messages[id], + oldMessage: (client.channels[channelId] as PartialTextChannel).messages.cache[id], + ); + } + + MessageDeleteEvent parseMessageDelete(Map raw) { + return MessageDeleteEvent( + gateway: this, + id: Snowflake.parse(raw['id']!), + channelId: Snowflake.parse(raw['channel_id']!), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + ); + } + + MessageBulkDeleteEvent parseMessageBulkDelete(Map raw) { + return MessageBulkDeleteEvent( + gateway: this, + ids: parseMany(raw['ids'] as List, Snowflake.parse), + channelId: Snowflake.parse(raw['channel_id']!), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + ); + } + + MessageReactionAddEvent parseMessageReactionAdd(Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + + return MessageReactionAddEvent( + gateway: this, + userId: Snowflake.parse(raw['user_id']!), + channelId: Snowflake.parse(raw['channel_id']!), + messageId: Snowflake.parse(raw['message_id']!), + guildId: guildId, + member: maybeParse(raw['member'], client.guilds[guildId ?? Snowflake.zero].members.parse), + emoji: client.guilds[Snowflake.zero].emojis.parse(raw['emoji'] as Map), + ); + } + + MessageReactionRemoveEvent parseMessageReactionRemove(Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + + return MessageReactionRemoveEvent( + gateway: this, + userId: Snowflake.parse(raw['user_id'] as String), + channelId: Snowflake.parse(raw['channel_id'] as String), + messageId: Snowflake.parse(raw['message_id'] as String), + guildId: guildId, + emoji: client.guilds[Snowflake.zero].emojis.parse(raw['emoji'] as Map), + ); + } + + MessageReactionRemoveAllEvent parseMessageReactionRemoveAll(Map raw) { + return MessageReactionRemoveAllEvent( + gateway: this, + channelId: Snowflake.parse(raw['channel_id']!), + messageId: Snowflake.parse(raw['message_id']!), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + ); + } + + MessageReactionRemoveEmojiEvent parseMessageReactionRemoveEmoji(Map raw) { + return MessageReactionRemoveEmojiEvent( + gateway: this, + channelId: Snowflake.parse(raw['channel_id']!), + messageId: Snowflake.parse(raw['message_id']!), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + emoji: client.guilds[Snowflake.zero].emojis.parse(raw['emoji'] as Map), + ); + } + + PresenceUpdateEvent parsePresenceUpdate(Map raw) { + return PresenceUpdateEvent( + gateway: this, + user: maybeParse( + raw['user'], + (Map raw) => PartialUser(id: Snowflake.parse(raw['id']!), manager: client.users), + ), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + status: maybeParse(raw['status'], UserStatus.parse), + activities: maybeParseMany(raw['activities'], parseActivity), + clientStatus: maybeParse(raw['client_status'], parseClientStatus), + ); + } + + TypingStartEvent parseTypingStart(Map raw) { + var guildId = maybeParse(raw['guild_id'], Snowflake.parse); + + return TypingStartEvent( + gateway: this, + channelId: Snowflake.parse(raw['channel_id']!), + guildId: guildId, + userId: Snowflake.parse(raw['user_id']!), + timestamp: DateTime.fromMillisecondsSinceEpoch((raw['timestamp'] as int) * Duration.millisecondsPerSecond), + member: maybeParse(raw['member'], client.guilds[guildId ?? Snowflake.zero].members.parse), + ); + } + + UserUpdateEvent parseUserUpdate(Map raw) { + final user = client.users.parse(raw); + + return UserUpdateEvent( + gateway: this, + oldUser: client.users.cache[user.id], + user: user, + ); + } + + VoiceStateUpdateEvent parseVoiceStateUpdate(Map raw) { + final voiceState = client.voice.parseVoiceState(raw); + + return VoiceStateUpdateEvent( + gateway: this, + oldState: client.voice.cache[voiceState.cacheKey], + state: voiceState, + ); + } + + VoiceServerUpdateEvent parseVoiceServerUpdate(Map raw) { + return VoiceServerUpdateEvent( + gateway: this, + token: raw['token'] as String, + guildId: Snowflake.parse(raw['guild_id']!), + endpoint: raw['endpoint'] as String?, + ); + } + + WebhooksUpdateEvent parseWebhooksUpdate(Map raw) { + return WebhooksUpdateEvent( + gateway: this, + guildId: Snowflake.parse(raw['guild_id']!), + channelId: Snowflake.parse(raw['channel_id']!), + ); + } + + InteractionCreateEvent> parseInteractionCreate(Map raw) { + final interaction = client.interactions.parse(raw); + + // Needed to get proper type promotion. + return switch (interaction.type) { + InteractionType.ping => InteractionCreateEvent(gateway: this, interaction: interaction as PingInteraction), + InteractionType.applicationCommand => + InteractionCreateEvent(gateway: this, interaction: interaction as ApplicationCommandInteraction), + InteractionType.messageComponent => + InteractionCreateEvent(gateway: this, interaction: interaction as MessageComponentInteraction), + InteractionType.modalSubmit => InteractionCreateEvent(gateway: this, interaction: interaction as ModalSubmitInteraction), + InteractionType.applicationCommandAutocomplete => + InteractionCreateEvent(gateway: this, interaction: interaction as ApplicationCommandAutocompleteInteraction), + } as InteractionCreateEvent>; + } + + StageInstanceCreateEvent parseStageInstanceCreate(Map raw) { + return StageInstanceCreateEvent( + gateway: this, + instance: client.channels.parseStageInstance(raw), + ); + } + + StageInstanceUpdateEvent parseStageInstanceUpdate(Map raw) { + final instance = client.channels.parseStageInstance(raw); + + return StageInstanceUpdateEvent( + gateway: this, + oldInstance: client.channels.stageInstanceCache[instance.channelId], + instance: instance, + ); + } + + StageInstanceDeleteEvent parseStageInstanceDelete(Map raw) { + return StageInstanceDeleteEvent( + gateway: this, + instance: client.channels.parseStageInstance(raw), + ); + } + + /// Stream all members in a guild that match [query] or [userIds]. + /// + /// If neither is provided, all members in the guild are returned. + Stream listGuildMembers( + Snowflake guildId, { + String? query, + int? limit, + List? userIds, + bool? includePresences, + String? nonce, + }) async* { + if (userIds == null) { + query ??= ''; + } + + limit ??= 0; + nonce ??= '${Snowflake.now().value.toRadixString(36)}${guildId.value.toRadixString(36)}'; + + final shard = shardFor(guildId); + shard.add(Send(opcode: Opcode.requestGuildMembers, data: { + 'guild_id': guildId.toString(), + if (query != null) 'query': query, + 'limit': limit, + if (includePresences != null) 'presences': includePresences, + if (userIds != null) 'user_ids': userIds.map((e) => e.toString()).toList(), + 'nonce': nonce, + })); + + int chunksReceived = 0; + + await for (final event in events) { + if (event is! GuildMembersChunkEvent || event.nonce != nonce) { + continue; + } + + yield* Stream.fromIterable(event.members); + + chunksReceived++; + if (chunksReceived == event.chunkCount) { + break; + } + } + } + + /// Update the client's voice state in the guild with ID [guildId]. + void updateVoiceState(Snowflake guildId, GatewayVoiceStateBuilder builder) => shardFor(guildId).updateVoiceState(guildId, builder); + + /// Update the client's presence on all shards. + void updatePresence(PresenceBuilder builder) { + for (final shard in shards) { + shard.add(Send(opcode: Opcode.presenceUpdate, data: builder.build())); + } + } +} diff --git a/lib/src/gateway/message.dart b/lib/src/gateway/message.dart new file mode 100644 index 000000000..1da7da02e --- /dev/null +++ b/lib/src/gateway/message.dart @@ -0,0 +1,82 @@ +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/gateway/opcode.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template shard_data} +/// Information a shard needs to run itself. +/// {@endtemplate} +class ShardData with ToStringHelper { + /// The total number of shards in the current session. + final int totalShards; + + /// The ID of the current shard. + final int id; + + /// The API options the client is using. + final GatewayApiOptions apiOptions; + + /// The original connection URI from [GatewayManager.fetchGatewayBot]. + final Uri originalConnectionUri; + + /// {@macro shard_data} + const ShardData({ + required this.totalShards, + required this.id, + required this.apiOptions, + required this.originalConnectionUri, + }); +} + +/// The base class for all control messages sent from the shard to the client. +abstract class ShardMessage with ToStringHelper {} + +/// A shard message sent when an event is received on the Gateway. +class EventReceived extends ShardMessage { + /// The event that was received. + final GatewayEvent event; + + /// Create a new [EventReceived]. + EventReceived({required this.event}); +} + +/// A shard message sent when the shard encounters an error. +class ErrorReceived extends ShardMessage { + /// The error encountered. + final Object error; + + /// The stack trace where the error occurred. + final StackTrace stackTrace; + + /// Create a new [ErrorReceived]. + ErrorReceived({required this.error, required this.stackTrace}); +} + +/// A shard message sent when the shard is going to disconnect permanently. +class Disconnecting extends ShardMessage { + /// The reason why the shard is disconnecting. + final String reason; + + /// Create a new [Disconnecting]. + Disconnecting({required this.reason}); +} + +/// The base class for all control messages sent from the client to the shard. +abstract class GatewayMessage with ToStringHelper {} + +/// A gateway message sent to instruct the shard to send data on its connection. +class Send extends GatewayMessage { + /// The opcode of the event to send. + final Opcode opcode; + + /// The data of the event to send. + final dynamic data; + + /// Create a new [Send]. + Send({required this.opcode, required this.data}); +} + +/// A gateway message sent to instruct the shard to disconnect & stop handling any further messages. +/// +/// The shard can no longer be used after this is sent. +class Dispose extends GatewayMessage {} diff --git a/lib/src/gateway/shard.dart b/lib/src/gateway/shard.dart new file mode 100644 index 000000000..f70697f8a --- /dev/null +++ b/lib/src/gateway/shard.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:logging/logging.dart'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/builders/voice.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/gateway/message.dart'; +import 'package:nyxx/src/gateway/shard_runner.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/gateway/opcode.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// {@template shard} +/// A single connection to Discord's Gateway. +/// {@endtemplate} +class Shard extends Stream implements StreamSink { + /// The ID of this shard. + final int id; + + /// The isolate this shard's handler is running in. + final Isolate isolate; + + /// The stream on which events from the runner are received. + final Stream receiveStream; + + /// The port on which events are sent to the runner. + final SendPort sendPort; + + /// The client this [Shard] is for. + final NyxxGateway client; + + /// The logger used by this shard. + Logger get logger => Logger('${client.options.loggerName}.Shards[$id]'); + + final Completer _doneCompleter = Completer(); + + Duration _latency = Duration.zero; + + /// The latency on this shard's connection. + /// + /// This is updated for each [HeartbeatAckEvent] received. If no [HeartbeatAckEvent] has been received, this will be [Duration.zero]. + Duration get latency => _latency; + + /// Create a new [Shard]. + Shard(this.id, this.isolate, this.receiveStream, this.sendPort, this.client) { + final subscription = listen((message) { + if (message is ErrorReceived) { + logger.warning('Error: ${message.error}', message.error, message.stackTrace); + } else if (message is Disconnecting) { + logger.info('Disconnecting: ${message.reason}'); + } else if (message is EventReceived) { + final event = message.event; + + if (event is! RawDispatchEvent) { + logger.finer('Receive: ${event.opcode.name}'); + + switch (event) { + case InvalidSessionEvent(:final isResumable): + logger.finest('Resumable: $isResumable'); + if (isResumable) { + logger.info('Reconnecting: invalid session'); + } else { + logger.severe('Unresumable invalid session, disconnecting'); + } + case HelloEvent(:final heartbeatInterval): + logger.finest('Heartbeat Interval: $heartbeatInterval'); + case ReconnectEvent(): + logger.info('Reconnecting: reconnect requested'); + case HeartbeatAckEvent(:final latency): + _latency = latency; + default: + break; + } + } else { + logger + ..fine('Receive event: ${event.name}') + ..finer('Seq: ${event.seq}, Data: ${event.payload}'); + + if (event.name == 'READY') { + logger.info('Connected to Gateway'); + } else if (event.name == 'RESUMED') { + logger.info('Reconnected to Gateway'); + } + } + } + }); + + subscription.asFuture().then((value) { + // Can happen if the shard closes unexpectedly. + // Prevents further calls to close() from attempting to add events. + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(value); + } + }); + } + + /// Connect to the Gateway using the provided parameters. + static Future connect(int id, int totalShards, GatewayApiOptions apiOptions, Uri connectionUri, NyxxGateway client) async { + final logger = Logger('${client.options.loggerName}.Shards[$id]'); + + logger.info('Connecting to Gateway'); + + final receivePort = ReceivePort('Shard #$id message stream (main)'); + final receiveStream = receivePort.asBroadcastStream(); + + final isolate = await Isolate.spawn( + _isolateMain, + _IsolateSpawnData( + totalShards: totalShards, + id: id, + apiOptions: apiOptions, + originalConnectionUri: connectionUri, + sendPort: receivePort.sendPort, + ), + ); + + final exitPort = ReceivePort('Shard #$id exit listener'); + isolate.addOnExitListener(exitPort.sendPort); + exitPort.listen((_) { + logger.info('Shard exited'); + + receivePort.close(); + exitPort.close(); + }); + + final sendPort = await receiveStream.first as SendPort; + + return Shard(id, isolate, receiveStream, sendPort, client); + } + + /// Update the client's voice state on this shard. + void updateVoiceState(Snowflake guildId, GatewayVoiceStateBuilder builder) { + add(Send(opcode: Opcode.voiceStateUpdate, data: { + 'guild_id': guildId.toString(), + ...builder.build(), + })); + } + + @override + void add(GatewayMessage event) { + if (event is Send) { + logger + ..fine('Send: ${event.opcode.name}') + ..finer('Opcode: ${event.opcode.value}, Data: ${event.data}'); + } else if (event is Dispose) { + logger.info('Disposing'); + } + sendPort.send(event); + } + + @override + StreamSubscription listen( + void Function(ShardMessage event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return receiveStream.cast().listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } + + @override + Future close() { + if (_doneCompleter.isCompleted) { + return _doneCompleter.future; + } + + Future doClose() async { + add(Dispose()); + + // Wait for disconnection confirmation + await firstWhere((message) => message is Disconnecting); + + // Give the isolate time to shut down cleanly, but kill it if it takes too long. + try { + await drain().timeout(const Duration(seconds: 5)); + } on TimeoutException { + logger.warning('Isolate took too long to shut down, killing it'); + isolate.kill(priority: Isolate.immediate); + } + } + + _doneCompleter.complete(doClose()); + return _doneCompleter.future; + } + + @override + Future get done => _doneCompleter.future; + + @override + void addError(Object error, [StackTrace? stackTrace]) => throw UnimplementedError(); + + @override + Future addStream(Stream stream) => stream.forEach(add); +} + +class _IsolateSpawnData extends ShardData { + final SendPort sendPort; + + _IsolateSpawnData({ + required super.totalShards, + required super.id, + required super.apiOptions, + required super.originalConnectionUri, + required this.sendPort, + }); +} + +void _isolateMain(_IsolateSpawnData data) async { + final receivePort = ReceivePort('Shard #${data.id} message stream (isolate)'); + data.sendPort.send(receivePort.sendPort); + + final runner = ShardRunner(data); + + runner.run(receivePort.cast()).listen( + (message) { + try { + data.sendPort.send(message); + } on ArgumentError { + // The only message with anything custom should be ErrorReceived + assert(message is ErrorReceived); + message = message as ErrorReceived; + data.sendPort.send(ErrorReceived(error: message.error.toString(), stackTrace: message.stackTrace)); + } + }, + onDone: () => receivePort.close(), + ); +} diff --git a/lib/src/gateway/shard_runner.dart b/lib/src/gateway/shard_runner.dart new file mode 100644 index 000000000..80655bdab --- /dev/null +++ b/lib/src/gateway/shard_runner.dart @@ -0,0 +1,340 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:eterl/eterl.dart'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/gateway/event_parser.dart'; +import 'package:nyxx/src/gateway/message.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/gateway/opcode.dart'; + +/// An internal class that contains the logic for running a shard. +/// +/// This class handles opening the connection, heartbeating and any connection lifecycle events. +class ShardRunner { + /// The data needed for the shard to operate. + final ShardData data; + + /// The current heartbeat timer. + Timer? heartbeatTimer; + + /// The last seq number received. + int? seq; + + /// The session ID from the latest READY event. + String? sessionId; + + /// The current active connection. + ShardConnection? connection; + + /// Whether the last heartbeat was ACKed. + bool lastHeartbeatAcked = true; + + /// The stopwatch timing the interval between a heartbeat being sent and a heartbeat ACK being received. + Stopwatch? heartbeatStopwatch; + + /// Whether the current connection can be resumed. + bool canResume = false; + + /// Whether the shard is currently disposing and should not reconnect. + bool disposing = false; + + /// The URI to use when connecting to the Gateway. + late Uri gatewayUri = originalGatewayUri; + + /// The original URI to use when connecting to the Gateway for the first time or after an invalid session. + late final Uri originalGatewayUri = data.originalConnectionUri.replace(queryParameters: { + ...data.originalConnectionUri.queryParameters, + ...data.apiOptions.gatewayConnectionOptions, + }); + + ShardRunner(this.data); + + /// Run the shard runner. + Stream run(Stream messages) { + final controller = StreamController(); + + // The subscription to the control messages stream. + // This subscription is paused whenever the shard is not successfully connected,. + final controlSubscription = messages.listen((message) { + if (message is Send) { + connection!.add(message); + } + + if (message is Dispose) { + disposing = true; + connection!.close(); + } + }) + ..pause(); + + /// The main connection loop. + /// + /// Maintains an active connection until a dispose request is received or the websocket closes with an invalid code. + Future asyncRun() async { + while (true) { + try { + // Initialize lastHeartbeatAcked to `true` so we don't immediately disconnect in heartbeat(). + lastHeartbeatAcked = true; + + // Pause the control subscription until we are connected. + if (!controlSubscription.isPaused) { + controlSubscription.pause(); + } + + // Open the websocket connection. + connection = await ShardConnection.connect(gatewayUri.toString(), this); + + // Obtain the heartbeat interval from the HELLO event and start heartbeating. + final hello = await connection!.first; + if (hello is! HelloEvent) { + throw InvalidEventException('Expected HELLO on connection.'); + } + controller.add(EventReceived(event: hello)); + + startHeartbeat(hello.heartbeatInterval); + + // If we can resume (the connection loop was restarted) and we have the information needed, try to resume. + // Otherwise, identify. + if (canResume && seq != null && sessionId != null) { + sendResume(); + } else { + sendIdentify(); + } + + canResume = false; + + // We are connected, start handling control messages. + controlSubscription.resume(); + + // Handle events from the connection & forward them to the result controller. + final subscription = connection!.listen((event) { + if (event is RawDispatchEvent) { + seq = event.seq; + + if (event.name == 'READY') { + final resumeUri = Uri.parse(event.payload['resume_gateway_url'] as String); + + gatewayUri = resumeUri.replace(queryParameters: { + ...resumeUri.queryParameters, + ...data.apiOptions.gatewayConnectionOptions, + }); + + sessionId = event.payload['session_id'] as String; + } + } else if (event is ReconnectEvent) { + canResume = true; + connection!.close(); + } else if (event is InvalidSessionEvent) { + if (event.isResumable) { + canResume = true; + } else { + canResume = false; + gatewayUri = originalGatewayUri; + } + + connection!.close(); + } else if (event is HeartbeatAckEvent) { + lastHeartbeatAcked = true; + heartbeatStopwatch = null; + } else if (event is HeartbeatEvent) { + connection!.add(Send(opcode: Opcode.heartbeat, data: seq)); + } + + controller.add(EventReceived(event: event)); + }); + + // Wait for the current connection to end, either due to a remote close or due to us disconnecting. + await subscription.asFuture(); + + // If the disconnect was triggered by a dispose, don't try to reconnect. Exit the loop. + if (disposing) { + controller.add(Disconnecting(reason: 'Dispose requested')); + return; + } + + // Check if we can resume based on close code. + // A manual close where we set closeCode earlier would have a close code of 1000, so this + // doesn't change closeCode if we set it manually. + // 1001 is the close code used for a ping failure, so include it in the resumable codes. + const resumableCodes = [null, 1001, 4000, 4001, 4002, 4003, 4007, 4008, 4009]; + final closeCode = connection!.websocket.closeCode; + canResume = canResume || resumableCodes.contains(closeCode); + + // If we encounter a fatal error, exit the shard. + if (!canResume && (closeCode ?? 0) >= 4000) { + controller.add(Disconnecting(reason: 'Received error close code: $closeCode')); + return; + } + } catch (error, stackTrace) { + controller.add(ErrorReceived(error: error, stackTrace: stackTrace)); + } finally { + // Reset connection properties. + connection?.close(); + connection = null; + heartbeatTimer?.cancel(); + heartbeatTimer = null; + heartbeatStopwatch = null; + } + } + } + + asyncRun().then((_) { + controller.close(); + controlSubscription.cancel(); + }); + + return controller.stream; + } + + void heartbeat() { + if (!lastHeartbeatAcked) { + connection!.close(4000); + return; + } + + connection!.add(Send(opcode: Opcode.heartbeat, data: seq)); + lastHeartbeatAcked = false; + heartbeatStopwatch = Stopwatch()..start(); + } + + void startHeartbeat(Duration heartbeatInterval) { + heartbeatTimer = Timer(heartbeatInterval * Random().nextDouble(), () { + heartbeat(); + + heartbeatTimer = Timer.periodic(heartbeatInterval, (_) => heartbeat()); + }); + } + + void sendIdentify() { + connection!.add(Send( + opcode: Opcode.identify, + data: { + 'token': data.apiOptions.token, + 'properties': { + 'os': Platform.operatingSystem, + 'browser': 'nyxx', + 'device': 'nyxx', + }, + if (data.apiOptions.compression == GatewayCompression.payload) 'compress': true, + if (data.apiOptions.largeThreshold != null) 'large_threshold': data.apiOptions.largeThreshold, + 'shard': [data.id, data.totalShards], + if (data.apiOptions.initialPresence != null) 'presence': data.apiOptions.initialPresence!.build(), + 'intents': data.apiOptions.intents.value, + }, + )); + } + + void sendResume() { + assert(sessionId != null && seq != null); + connection!.add(Send( + opcode: Opcode.resume, + data: { + 'token': data.apiOptions.token, + 'session_id': sessionId, + 'seq': seq, + }, + )); + } +} + +class ShardConnection extends Stream implements StreamSink { + final WebSocket websocket; + final Stream events; + final ShardRunner runner; + + ShardConnection(this.websocket, this.events, this.runner); + + static Future connect(String gatewayUri, ShardRunner runner) async { + final connection = await WebSocket.connect(gatewayUri); + connection.pingInterval = const Duration(seconds: 20); + + final uncompressedStream = switch (runner.data.apiOptions.compression) { + GatewayCompression.transport => decompressTransport(connection.cast>()), + GatewayCompression.payload => decompressPayloads(connection), + GatewayCompression.none => connection, + }; + + final dataStream = switch (runner.data.apiOptions.payloadFormat) { + GatewayPayloadFormat.json => parseJson(uncompressedStream), + GatewayPayloadFormat.etf => parseEtf(uncompressedStream.cast>()), + }; + + final parser = EventParser(); + final eventStream = + dataStream.cast>().map((event) => parser.parseGatewayEvent(event, heartbeatLatency: runner.heartbeatStopwatch?.elapsed)); + + return ShardConnection(connection, eventStream.asBroadcastStream(), runner); + } + + @override + StreamSubscription listen( + void Function(GatewayEvent event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return events.listen(onData, cancelOnError: cancelOnError, onDone: onDone, onError: onError); + } + + @override + void add(Send event) { + final payload = { + 'op': event.opcode.value, + 'd': event.data, + }; + + final encoded = switch (runner.data.apiOptions.payloadFormat) { + GatewayPayloadFormat.json => jsonEncode(payload), + GatewayPayloadFormat.etf => eterl.pack(payload), + }; + + websocket.add(encoded); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) => websocket.addError(error, stackTrace); + + @override + Future addStream(Stream stream) => stream.forEach(add); + + @override + Future close([int? code]) => websocket.close(code ?? 1000); + + @override + Future get done => websocket.done; +} + +Stream decompressTransport(Stream> raw) { + final filter = RawZLibFilter.inflateFilter(); + + return raw.map((chunk) { + filter.process(chunk, 0, chunk.length); + + final buffer = []; + for (List? decoded = []; decoded != null; decoded = filter.processed()) { + buffer.addAll(decoded); + } + + return buffer; + }); +} + +Stream decompressPayloads(Stream raw) => raw.map((message) { + if (message is String) { + return message; + } else { + return zlib.decode(message as List); + } + }); + +Stream parseJson(Stream raw) => raw.map((message) { + final source = message is String ? message : utf8.decode(message as List); + + return jsonDecode(source); + }); + +Stream parseEtf(Stream> raw) => raw.transform(eterl.unpacker()); diff --git a/lib/src/http/bucket.dart b/lib/src/http/bucket.dart new file mode 100644 index 000000000..b580f817a --- /dev/null +++ b/lib/src/http/bucket.dart @@ -0,0 +1,142 @@ +import 'package:http/http.dart'; +import 'package:nyxx/src/http/handler.dart'; +import 'package:nyxx/src/http/request.dart'; + +/// A rate limit bucket tracking requests. +/// +/// {@template http_bucket} +/// Every response from Discord's API contains headers to handle rate limiting. This class keeps +/// track of these headers in a single rate limit bucket (identified by the [xRateLimitBucket] +/// header) and allows the client to anticipate rate limits. +/// +/// Every [HttpHandler] stores a map of [HttpRoute.rateLimitId] to [HttpBucket] and implicitly +/// checks each request before sending it, waiting if a rate limit would be exceeded. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/topics/rate-limits#rate-limits +/// {@endtemplate} +class HttpBucket { + /// The name of the header containing the rate limit bucket id. + static const xRateLimitBucket = "x-ratelimit-bucket"; + + /// The name of the header containing the rate limit per reset. + static const xRateLimitLimit = "x-ratelimit-limit"; + + /// The name of the header containing the remaining request count in the current reset. + static const xRateLimitRemaining = "x-ratelimit-remaining"; + + /// The name of the header containing the time at which the rate limit for this bucket will reset. + /// + /// This is not used due to issues with server-client clock drift. Instead, [xRateLimitResetAfter] + /// is used in combination with [DateTime.now] to determine [resetAt]. + static const xRateLimitReset = "x-ratelimit-reset"; + + /// The name of the header containing the amount of time until the rate limit resets, in seconds. + /// + /// The value of this header can be a floating-point number. + static const xRateLimitResetAfter = "x-ratelimit-reset-after"; + + /// The [HttpHandler] to which this bucket belongs. + final HttpHandler handler; + + /// The id of this bucket. + /// + /// This is the value of the [xRateLimitBucket] header on requests in this bucket. + final String id; + + final Set _inflightRequests = {}; + + /// The number of in-flight requests in this bucket. + /// + /// {@macro in_flight_requests} + int get inflightRequests => _inflightRequests.length; + + int _remaining; + + /// The remaining number of requests that can be made in this reset period. + /// + /// This value accounts for in-flight requests, see [addInflightRequest] and + /// [removeInflightRequest] for more information. + int get remaining => _remaining - inflightRequests; + + DateTime _resetAt; + + /// The time at which this bucket resets. + DateTime get resetAt => _resetAt; + + /// The duration after which this bucket resets. + Duration get resetAfter => resetAt.difference(DateTime.now()); + + /// Create a new [HttpBucket]. + /// + /// {@macro http_bucket} + HttpBucket( + this.handler, { + required this.id, + required int remaining, + required DateTime resetAt, + }) : _remaining = remaining, + _resetAt = resetAt; + + /// Create a [HttpBucket] from a response from the API. + /// + /// If the [response] does not have rate limit headers, this method returns `null`. + /// + /// {@macro http_bucket} + static HttpBucket? fromResponse(HttpHandler handler, BaseResponse response) { + final limit = response.headers[xRateLimitLimit]; + final remaining = response.headers[xRateLimitRemaining]; + final resetAfter = response.headers[xRateLimitResetAfter]; + final id = response.headers[xRateLimitBucket]; + + if (limit == null || remaining == null || resetAfter == null || id == null) { + return null; + } + + final resetAfterDuration = Duration(milliseconds: (double.parse(resetAfter) * 1000).ceil()); + final resetAtTime = DateTime.now().add(resetAfterDuration); + + return HttpBucket( + handler, + id: id, + remaining: int.parse(remaining), + resetAt: resetAtTime, + ); + } + + /// Update this bucket with the values from [response]. + /// + /// Call this method for every response in this bucket. + void updateWith(BaseResponse response) { + assert(contains(response), 'Response was not in bucket'); + + final remaining = response.headers[xRateLimitRemaining]; + final resetAfter = response.headers[xRateLimitResetAfter]; + + if (remaining != null) { + _remaining = int.parse(remaining); + } + + if (resetAfter != null) { + final resetAfterDuration = Duration(milliseconds: (double.parse(resetAfter) * 1000).ceil()); + _resetAt = DateTime.now().add(resetAfterDuration); + } + } + + /// Return whether the [response]'s [xRateLimitBucket] header matches this bucket's. + bool contains(BaseResponse response) => id == response.headers[xRateLimitBucket]; + + /// Add [request] to this bucket's in-flight requests. + /// + /// {@template in_flight_requests} + /// In flight requests are requests that have been sent by the client but have not yet received a + /// response from the API. These requests count towards the [remaining] count to avoid sending too + /// many requests at once. + /// {@endtemplate} + void addInflightRequest(HttpRequest request) => _inflightRequests.add(request); + + /// Remove [request] from this bucket's in-flight requests. + /// + /// {@macro in_flight_requests} + void removeInflightRequest(HttpRequest request) => _inflightRequests.remove(request); +} diff --git a/lib/src/http/cdn/cdn_asset.dart b/lib/src/http/cdn/cdn_asset.dart new file mode 100644 index 000000000..8678c4b6c --- /dev/null +++ b/lib/src/http/cdn/cdn_asset.dart @@ -0,0 +1,111 @@ +import 'dart:typed_data'; + +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/cdn/cdn_request.dart'; +import 'package:nyxx/src/http/response.dart'; +import 'package:nyxx/src/http/route.dart'; + +/// Available formats for CDN endpoints. +enum CdnFormat { + /// The PNG format. + png._('png'), + + /// The JPEG/JPG format. + jpeg._('jpeg'), + + /// The webp format. + webp._('webp'), + + /// The GIF format. + /// + /// This is only available to endpoints where [CdnAsset.isAnimated] is true. + gif._('gif'), + + /// The Lottie format. + /// + /// This is only available to sticker endpoints where [Sticker.formatType] is [StickerFormatType.lottie]. + lottie._('lottie'); + + /// The extension to use on the CDN endpoint for this format. + final String extension; + + const CdnFormat._(this.extension); + + @override + String toString() => 'CdnFormat($extension)'; +} + +/// {@template cdn_asset} +/// An asset, most commonly an image, on Discord's CDN. +/// {@endtemplate} +class CdnAsset { + /// The client this asset is associated with. + final Nyxx client; + + /// The hash of the asset. + final String hash; + + /// The base URL of the asset. + /// + /// This is combined with [hash] and [defaultFormat] to obtain [url]. + final HttpRoute base; + + /// The default format for this asset if none is specified. + final CdnFormat defaultFormat; + + /// Whether this asset is an animated image. + final bool isAnimated; + + /// The URL at which this asset can be fetched from. + Uri get url => _getRequest(defaultFormat, null).prepare(client).url; + + /// {@macro cdn_asset} + CdnAsset({ + required this.client, + required this.base, + required this.hash, + CdnFormat? defaultFormat, + bool? isAnimated, + }) : isAnimated = isAnimated ?? hash.startsWith('a_'), + defaultFormat = defaultFormat ?? ((isAnimated ?? hash.startsWith('a_')) ? CdnFormat.gif : CdnFormat.png); + + CdnRequest _getRequest(CdnFormat format, int? size) { + final route = HttpRoute(); + + for (final part in base.parts) { + route.add(part); + } + route.add(HttpRoutePart('$hash.${format.extension}')); + + return CdnRequest(route, queryParameters: {if (size != null) 'size': size.toString()}); + } + + /// Fetch this asset and return its binary data. + Future fetch({CdnFormat? format, int? size}) async { + assert(format != CdnFormat.gif || isAnimated, 'Asset must be animated to fetch as GIF'); + + final request = _getRequest(format ?? defaultFormat, size); + + final response = await client.httpHandler.executeSafe(request); + return response.body; + } + + /// Fetch this asset and return a stream of its binary data. + Stream> fetchStreamed({CdnFormat? format, int? size}) async* { + assert(format != CdnFormat.gif || isAnimated, 'Asset must be animated to fetch as GIF'); + + final request = _getRequest(format ?? defaultFormat, size); + final rawRequest = request.prepare(client); + + final rawResponse = await client.httpHandler.httpClient.send(rawRequest); + + if (rawResponse.statusCode < 200 || rawResponse.statusCode >= 300) { + throw HttpResponseError.fromResponse(request, rawResponse); + } + + yield* rawResponse.stream; + } + + @override + String toString() => 'CdnAsset($url)'; +} diff --git a/lib/src/http/cdn/cdn_request.dart b/lib/src/http/cdn/cdn_request.dart new file mode 100644 index 000000000..15ee02180 --- /dev/null +++ b/lib/src/http/cdn/cdn_request.dart @@ -0,0 +1,15 @@ +import 'package:http/http.dart'; + +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/request.dart'; + +/// A request to Discord's CDN. +class CdnRequest extends HttpRequest { + /// Create a new [CdnRequest]. + CdnRequest(super.route, {super.queryParameters}) : super(method: 'GET', authenticated: false, applyGlobalRateLimit: false); + + @override + BaseRequest prepare(Nyxx client) { + return Request(method, Uri.https(client.apiOptions.cdnHost, route.path)); + } +} diff --git a/lib/src/http/handler.dart b/lib/src/http/handler.dart new file mode 100644 index 000000000..ae64923e3 --- /dev/null +++ b/lib/src/http/handler.dart @@ -0,0 +1,289 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:http/http.dart' hide MultipartRequest; +import 'package:logging/logging.dart'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/bucket.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/response.dart'; +import 'package:nyxx/src/utils/iterable_extension.dart'; + +extension on HttpRequest { + String get loggingId => '$method $route'; +} + +/// Information about a [delay] applied to [request] because of a rate limit. +/// +/// [isGlobal] indicates if the delay applied is due to the global rate limit. +/// [isAnticipated] will be true if the request was delayed before it was sent. If [isAnticipated] is `false`, +/// a response with the status code 429 was received from the API. +typedef RateLimitInfo = ({HttpRequest request, Duration delay, bool isGlobal, bool isAnticipated}); + +/// A handler for making HTTP requests to the Discord API. +/// +/// {@template http_handler} +/// HTTP requests can be made using the [execute] method. Rate limiting is anticipated and requests +/// will not be sent if their bucket is out of remaining requests or if the global rate limit was +/// exceeded. +/// {@endtemplate} +class HttpHandler { + /// The client this handler is attached to. + final Nyxx client; + + /// The HTTP client used to make requests. + final Client httpClient = Client(); + + final Map _buckets = {}; + + /// A mapping of [HttpRequest.rateLimitId] to [HttpBucket] for rate limiting. + Map get buckets => UnmodifiableMapView(_buckets); + + DateTime? _globalReset; + + /// The time at which the global rate limit resets. + /// + /// Will be `null` if no global rate limit has been encountered. + DateTime? get globalReset => _globalReset; + + Logger get logger => Logger('${client.options.loggerName}.Http'); + + final StreamController _onRequestController = StreamController.broadcast(); + final StreamController _onResponseController = StreamController.broadcast(); + final StreamController _onRateLimitController = StreamController.broadcast(); + + /// A stream of requests executed by this handler. + /// + /// Requests are emitted before they are sent. + Stream get onRequest => _onRequestController.stream; + + /// A stream of responses received by this handler. + /// + /// This includes error & rate limit responses. Since rate limit responses trigger the request + /// to be retried, this means you may receive multiple responses for a single request on this + /// stream. + Stream get onResponse => _onResponseController.stream; + + /// A stream that emits an event when a request is delayed because of a rate limit. + Stream get onRateLimit => _onRateLimitController.stream; + + final Queue _realLatencies = DoubleLinkedQueue(); + final Queue _latencies = DoubleLinkedQueue(); + + final Expando _latencyStopwatches = Expando(); + + static const _latencyRequestCount = 10; + + /// The average time taken by the last 10 requests to get a response. + /// + /// This time includes the time requests are held or retried due to rate limits. It is an indicator of how long the [Future] returned by [execute] is likely + /// to take to complete. + /// + /// If no requests have been completed, this getter returns [Duration.zero]. + /// + /// To get the network latency for this [HttpHandler], see [realLatency]. + Duration get latency => _latencies.isEmpty ? Duration.zero : (_latencies.reduce((a, b) => a + b) ~/ _latencies.length); + + /// The average network and API latency of the last 10 requests. + /// + /// This time measures how long each request takes to get a response from Discord's API, regardless of holding or retries due to rate limiting. This is not an + /// indicator of how long each call to [execute] takes to complete. + /// + /// If no requests have been completed, this getter returns [Duration.zero]. + Duration get realLatency => _realLatencies.isEmpty ? Duration.zero : (_realLatencies.reduce((a, b) => a + b) ~/ _realLatencies.length); + + /// Create a new [HttpHandler]. + /// + /// {@macro http_handler} + HttpHandler(this.client); + + /// Send [request] to the API and return the response. + /// + /// The request will not be sent immediately if its corresponding bucket is out of remaining + /// requests or if the global rate limit has been hit. Instead, this method will wait until the + /// rate limit has passed to send the request. + /// + /// If the response has a status code of 2XX, a [HttpResponseSuccess] is returned. + /// + /// If the response has a status code of 429, rate limit information is extracted from the + /// response and the request is sent again after the rate limit passes. The response returned is + /// that of the second request. + /// + /// Otherwise, this method returns a [HttpResponseError]. + Future execute(HttpRequest request) async { + logger + ..fine(request.loggingId) + ..finer( + 'Rate Limit ID: ${request.rateLimitId}, Headers: ${request.headers}, Audit Log Reason: ${request.auditLogReason},' + ' Authenticated: ${request.authenticated}, Apply Global Rate Limit: ${request.applyGlobalRateLimit}', + ); + + if (request is BasicRequest) { + logger.finer('Query Parameters: ${request.queryParameters}, Body: ${request.body}'); + } else if (request is FormDataRequest) { + logger.finer('Query parameters: ${request.queryParameters}, Payload: ${request.formParams}, Files: ${request.files.map((e) => e.filename).join(', ')}'); + } else { + logger.finer('Query parameters: ${request.queryParameters}'); + } + + _onRequestController.add(request); + + // Use ??= instead of = as the request might already exist if it was retried + // due to a rate limit. + _latencyStopwatches[request] ??= Stopwatch()..start(); + + Duration waitTime; + HttpBucket? bucket; + + do { + bucket = _buckets[request.rateLimitId]; + + final now = DateTime.now(); + + final globalWaitTime = (request.applyGlobalRateLimit ? globalReset?.difference(now) : null) ?? Duration.zero; + + Duration bucketWaitTime = Duration.zero; + if (bucket != null && bucket.remaining <= 0) { + if (bucket.resetAt.isAfter(now)) { + bucketWaitTime = bucket.resetAt.difference(now); + } else if (bucket.inflightRequests > 0) { + // This occurs when the bucket has many in flight requests + // (which take all the remaining request slots) but has not + // yet received a response to update its reset after time. + // + // This would mean the bucket reset time would be negative, + // when we actually want to wait for an updated reset time. + // + // We just wait for one of those requests to complete. + bucketWaitTime = const Duration(seconds: 1); + } + } + + final isGlobal = globalWaitTime > bucketWaitTime && request.applyGlobalRateLimit; + waitTime = isGlobal ? globalWaitTime : bucketWaitTime; + + if (waitTime > Duration.zero) { + logger.finer('Holding ${request.loggingId} for $waitTime'); + _onRateLimitController.add((request: request, delay: waitTime, isGlobal: isGlobal, isAnticipated: true)); + await Future.delayed(waitTime); + } + } while (waitTime > Duration.zero); + + try { + logger.finer('Sending ${request.loggingId}'); + + final realLatencyStopwatch = Stopwatch()..start(); + + bucket?.addInflightRequest(request); + final response = await httpClient.send(request.prepare(client)); + + final realLatency = (realLatencyStopwatch..stop()).elapsed; + + _realLatencies.addLast(realLatency); + if (_realLatencies.length > _latencyRequestCount) { + _realLatencies.removeFirst(); + } + + return _handle(request, response); + } finally { + bucket?.removeInflightRequest(request); + } + } + + /// Execute [request] and throw the response if it is not a [HttpResponseSuccess]. + Future executeSafe(HttpRequest request) async { + final response = await execute(request); + + if (response is! HttpResponseSuccess) { + throw response; + } + + return response; + } + + void _updateRatelimitBucket(HttpRequest request, BaseResponse response) { + HttpBucket? bucket = _buckets.values.firstWhereSafe( + (bucket) => bucket.contains(response), + orElse: () => HttpBucket.fromResponse(this, response), + ); + + if (bucket == null) { + return; + } + + bucket.updateWith(response); + _buckets[request.rateLimitId] = bucket; + } + + Future _handle(HttpRequest request, StreamedResponse response) async { + _updateRatelimitBucket(request, response); + + final HttpResponse parsedResponse; + if (response.statusCode >= 200 && response.statusCode < 300) { + parsedResponse = await HttpResponseSuccess.fromResponse(request, response); + } else { + parsedResponse = await HttpResponseError.fromResponse(request, response); + } + + logger + ..fine('${response.statusCode} ${request.loggingId}') + ..finer('Headers: ${parsedResponse.headers}, Body: ${parsedResponse.textBody ?? parsedResponse.body.map((e) => e.toRadixString(16)).join(' ')}'); + + _onResponseController.add(parsedResponse); + + if (parsedResponse.statusCode == 429) { + try { + final responseBody = parsedResponse.jsonBody; + final retryAfter = Duration(milliseconds: ((responseBody["retry_after"] as double) * 1000).ceil()); + final isGlobal = responseBody["global"] as bool; + + if (isGlobal) { + _globalReset = DateTime.now().add(retryAfter); + } + + _onRateLimitController.add((request: request, delay: retryAfter, isGlobal: isGlobal, isAnticipated: false)); + return Future.delayed(retryAfter, () => execute(request)); + } on TypeError { + logger.shout('Invalid rate limit body for ${request.loggingId}! Your client is probably cloudflare banned!'); + } + } + + final latency = (_latencyStopwatches[request]?..stop())?.elapsed; + _latencyStopwatches[request] = null; + + if (latency != null) { + _latencies.addLast(latency); + + if (_latencies.length > _latencyRequestCount) { + _latencies.removeFirst(); + } + } + + return parsedResponse; + } + + void close() { + httpClient.close(); + _onRequestController.close(); + _onResponseController.close(); + } +} + +/// An [HttpHandler] that refreshes the OAuth2 access token if needed. +class Oauth2HttpHandler extends HttpHandler { + /// The options containing the credentials that may be refreshed. + final OAuth2ApiOptions apiOptions; + + /// Create a new [Oauth2HttpHandler]. + Oauth2HttpHandler(NyxxOAuth2 super.client) : apiOptions = client.apiOptions; + + @override + Future execute(HttpRequest request) async { + if (apiOptions.credentials.isExpired && request.authenticated) { + apiOptions.credentials = await apiOptions.credentials.refresh(); + } + + return await super.execute(request); + } +} diff --git a/lib/src/http/managers/application_command_manager.dart b/lib/src/http/managers/application_command_manager.dart new file mode 100644 index 000000000..d0a589c6d --- /dev/null +++ b/lib/src/http/managers/application_command_manager.dart @@ -0,0 +1,279 @@ +import 'dart:convert'; + +import 'package:nyxx/src/builders/application_command.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/commands/application_command.dart'; +import 'package:nyxx/src/models/commands/application_command_option.dart'; +import 'package:nyxx/src/models/commands/application_command_permissions.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A [Manager] for [ApplicationCommand]s. +/// +/// [GlobalApplicationCommandManager] or [GuildApplicationCommandManager] will be used as concrete classes instead of this one depending on the circumstances. +abstract class ApplicationCommandManager extends Manager { + Snowflake? get _guildId; + + /// The ID of the application this manager is for. + final Snowflake applicationId; + + /// Create a new [ApplicationCommandManager]. + ApplicationCommandManager(super.config, super.client, {required this.applicationId, required super.identifier}); + + @override + PartialApplicationCommand operator [](Snowflake id) => PartialApplicationCommand(id: id, manager: this); + + @override + ApplicationCommand parse(Map raw) { + return ApplicationCommand( + id: Snowflake.parse(raw['id']!), + manager: this, + type: ApplicationCommandType.parse(raw['type'] as int? ?? 1), + applicationId: Snowflake.parse(raw['application_id']!), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + name: raw['name'] as String, + nameLocalizations: maybeParse( + raw['name_localizations'], + (Map raw) => raw.map( + (key, value) => MapEntry(Locale.parse(key), value as String), + ), + ), + description: raw['description'] as String, + descriptionLocalizations: maybeParse( + raw['description_localizations'], + (Map raw) => raw.map( + (key, value) => MapEntry(Locale.parse(key), value as String), + ), + ), + options: maybeParseMany(raw['options'], parseApplicationCommandOption), + defaultMemberPermissions: maybeParse(raw['default_member_permissions'], (String raw) => Permissions(int.parse(raw))), + hasDmPermission: raw['dm_permission'] as bool?, + isNsfw: raw['nsfw'] as bool?, + version: Snowflake.parse(raw['version']!), + ); + } + + /// Parse a [CommandOption] from [raw]. + CommandOption parseApplicationCommandOption(Map raw) { + return CommandOption( + type: CommandOptionType.parse(raw['type'] as int), + name: raw['name'] as String, + nameLocalizations: maybeParse( + raw['name_localizations'], + (Map raw) => raw.map( + (key, value) => MapEntry(Locale.parse(key), value as String), + ), + ), + description: raw['description'] as String, + descriptionLocalizations: maybeParse( + raw['description_localizations'], + (Map raw) => raw.map( + (key, value) => MapEntry(Locale.parse(key), value as String), + ), + ), + isRequired: raw['required'] as bool?, + choices: maybeParseMany(raw['choices'], parseOptionChoice), + options: maybeParseMany(raw['options'], parseApplicationCommandOption), + channelTypes: maybeParseMany(raw['channel_types'], ChannelType.parse), + minValue: raw['min_value'] as num?, + maxValue: raw['max_value'] as num?, + minLength: raw['min_length'] as int?, + maxLength: raw['max_length'] as int?, + hasAutocomplete: raw['autocomplete'] as bool?, + ); + } + + /// Parse a [CommandOptionChoice] from [raw]. + CommandOptionChoice parseOptionChoice(Map raw) { + return CommandOptionChoice( + name: raw['name'] as String, + nameLocalizations: maybeParse( + raw['name_localizations'], + (Map raw) => raw.map( + (key, value) => MapEntry(Locale.parse(key), value as String), + ), + ), + value: raw['value'], + ); + } + + /// List the commands belonging to the application. + Future> list({bool? withLocalizations}) async { + final route = HttpRoute()..applications(id: applicationId.toString()); + if (_guildId != null) route.guilds(id: _guildId!.toString()); + route.commands(); + + final request = BasicRequest(route, queryParameters: {if (withLocalizations != null) 'with_localizations': withLocalizations.toString()}); + + final response = await client.httpHandler.executeSafe(request); + final commands = parseMany(response.jsonBody as List, parse); + + cache.addEntities(commands); + return commands; + } + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute()..applications(id: applicationId.toString()); + if (_guildId != null) route.guilds(id: _guildId!.toString()); + route.commands(id: id.toString()); + + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final command = parse(response.jsonBody as Map); + + cache[command.id] = command; + return command; + } + + @override + Future create(ApplicationCommandBuilder builder) async { + final route = HttpRoute()..applications(id: applicationId.toString()); + if (_guildId != null) route.guilds(id: _guildId!.toString()); + route.commands(); + + final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final command = parse(response.jsonBody as Map); + + cache[command.id] = command; + return command; + } + + @override + Future update(Snowflake id, ApplicationCommandUpdateBuilder builder) async { + final route = HttpRoute()..applications(id: applicationId.toString()); + if (_guildId != null) route.guilds(id: _guildId!.toString()); + route.commands(id: id.toString()); + + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final command = parse(response.jsonBody as Map); + + cache[command.id] = command; + return command; + } + + @override + Future delete(Snowflake id) async { + final route = HttpRoute()..applications(id: applicationId.toString()); + if (_guildId != null) route.guilds(id: _guildId!.toString()); + route.commands(id: id.toString()); + + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + cache.remove(id); + } + + /// Remove all existing commands and replace them with the commands defined in [builders]. + Future> bulkOverride(List builders) async { + final route = HttpRoute()..applications(id: applicationId.toString()); + if (_guildId != null) route.guilds(id: _guildId!.toString()); + route.commands(); + + final request = BasicRequest(route, method: 'PUT', body: jsonEncode([for (final builder in builders) builder.build()])); + + final response = await client.httpHandler.executeSafe(request); + final commands = parseMany(response.jsonBody as List, parse); + + cache + ..clear() + ..addEntities(commands); + return commands; + } +} + +/// An [ApplicationCommandManager] for the commands in a guild. +class GuildApplicationCommandManager extends ApplicationCommandManager { + /// The ID of the guild this manager is for. + final Snowflake guildId; + + /// A cache for the command permissions in this guild. + final Cache permissionsCache; + + @override + Snowflake get _guildId => guildId; + + /// Create a new [GuildApplicationCommandManager]. + GuildApplicationCommandManager( + super.config, + super.client, { + required super.applicationId, + required this.guildId, + required CacheConfig permissionsConfig, + }) : permissionsCache = Cache(client, '$guildId.commandPermissions', permissionsConfig), + super(identifier: '$guildId.commands'); + + /// Parse a [CommandPermissions] from [raw]. + CommandPermissions parseCommandPermissions(Map raw) { + return CommandPermissions( + manager: this, + id: Snowflake.parse(raw['id']!), + applicationId: Snowflake.parse(raw['application_id']!), + guildId: Snowflake.parse(raw['guild_id']!), + permissions: parseMany(raw['permissions'] as List, parseCommandPermission), + ); + } + + /// Parse a [CommandPermission] from [raw]. + CommandPermission parseCommandPermission(Map raw) { + return CommandPermission( + id: Snowflake.parse(raw['id']!), + type: CommandPermissionType.parse(raw['type'] as int), + hasPermission: raw['permission'] as bool, + ); + } + + /// List all the [CommandPermissions] in this guild. + Future> listPermissions() async { + final route = HttpRoute() + ..applications(id: applicationId.toString()) + ..guilds(id: guildId.toString()) + ..commands() + ..permissions(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final permissions = parseMany(response.jsonBody as List, parseCommandPermissions); + + permissionsCache.addEntities(permissions); + return permissions; + } + + /// Fetch the permissions for a command. + Future fetchPermissions(Snowflake id) async { + final route = HttpRoute() + ..applications(id: applicationId.toString()) + ..guilds(id: guildId.toString()) + ..commands(id: id.toString()) + ..permissions(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final permissions = parseCommandPermissions(response.jsonBody as Map); + + permissionsCache[permissions.id] = permissions; + return permissions; + } + + // TODO: Do we add the command permission endpoints? +} + +/// An [ApplicationCommandManager] for an application's global commands. +class GlobalApplicationCommandManager extends ApplicationCommandManager { + @override + Null get _guildId => null; + + /// Create a new [GlobalApplicationCommandManager]. + GlobalApplicationCommandManager(super.config, super.client, {required super.applicationId}) : super(identifier: 'commands'); +} diff --git a/lib/src/http/managers/application_manager.dart b/lib/src/http/managers/application_manager.dart new file mode 100644 index 000000000..1ea6402b3 --- /dev/null +++ b/lib/src/http/managers/application_manager.dart @@ -0,0 +1,134 @@ +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/team.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [Application]s. +// See the comment on PartialApplication for why we do not implement Manager. +class ApplicationManager { + /// The client this manager belongs to. + final NyxxRest client; + + /// Create a new [ApplicationManager]. + ApplicationManager(this.client); + + /// Return a partial application with the given [id]. + PartialApplication operator [](Snowflake id) => PartialApplication(id: id, manager: this); + + /// Parse an [Application] from [raw]. + Application parse(Map raw) { + return Application( + id: Snowflake.parse(raw['id']!), + manager: this, + name: raw['name'] as String, + iconHash: raw['icon'] as String?, + description: raw['description'] as String, + rpcOrigins: maybeParseMany(raw['rpc_origins']), + isBotPublic: raw['bot_public'] as bool, + botRequiresCodeGrant: raw['bot_require_code_grant'] as bool, + termsOfServiceUrl: maybeParse(raw['terms_of_service_url'], Uri.parse), + privacyPolicyUrl: maybeParse(raw['privacy_policy_url'], Uri.parse), + owner: maybeParse( + raw['owner'], + (Map raw) => PartialUser( + id: Snowflake.parse(raw['id']!), + manager: client.users, + ), + ), + verifyKey: raw['verify_key'] as String, + team: maybeParse(raw['team'], parseTeam), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + primarySkuId: maybeParse(raw['primary_sku_id'], Snowflake.parse), + slug: raw['slug'] as String?, + coverImageHash: raw['cover_image'] as String?, + flags: ApplicationFlags(raw['flags'] as int? ?? 0), + tags: maybeParseMany(raw['tags']), + installationParameters: maybeParse(raw['install_params'], parseInstallationParameters), + customInstallUrl: maybeParse(raw['custom_install_url'], Uri.parse), + roleConnectionsVerificationUrl: maybeParse(raw['role_connections_verification_url'], Uri.parse), + ); + } + + Team parseTeam(Map raw) { + return Team( + manager: this, + iconHash: raw['icon'] as String?, + id: Snowflake.parse(raw['id']!), + members: parseMany(raw['members'] as List, parseTeamMember), + name: raw['name'] as String, + ownerId: Snowflake.parse(raw['owner_user_id']!), + ); + } + + TeamMember parseTeamMember(Map raw) { + return TeamMember( + membershipState: TeamMembershipState.parse(raw['membership_state'] as int), + teamId: Snowflake.parse(raw['team_id']!), + user: PartialUser(id: Snowflake.parse((raw['user'] as Map)['id']!), manager: client.users), + ); + } + + InstallationParameters parseInstallationParameters(Map raw) { + return InstallationParameters( + scopes: parseMany(raw['scopes'] as List), + permissions: Permissions(int.parse(raw['permissions'] as String)), + ); + } + + ApplicationRoleConnectionMetadata parseApplicationRoleConnectionMetadata(Map raw) { + return ApplicationRoleConnectionMetadata( + type: ConnectionMetadataType.parse(raw['type'] as int), + key: raw['key'] as String, + name: raw['name'] as String, + localizedNames: maybeParse( + raw['name_localizations'], + (Map raw) => raw.map((key, value) => MapEntry(Locale.parse(key), value as String)), + ), + description: raw['description'] as String, + localizedDescriptions: maybeParse( + raw['description_localizations'], + (Map raw) => raw.map((key, value) => MapEntry(Locale.parse(key), value as String)), + ), + ); + } + + /// Fetch an application's role connection metadata. + Future> fetchApplicationRoleConnectionMetadata(Snowflake id) async { + final route = HttpRoute() + ..applications(id: id.toString()) + ..roleConnections() + ..metadata(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, parseApplicationRoleConnectionMetadata); + } + + /// Update and fetch an application's role connection metadata. + Future> updateApplicationRoleConnectionMetadata(Snowflake id) async { + final route = HttpRoute() + ..applications(id: id.toString()) + ..roleConnections() + ..metadata(); + final request = BasicRequest(route, method: 'PUT'); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, parseApplicationRoleConnectionMetadata); + } + + Future fetchCurrentApplication() async { + final route = HttpRoute() + ..oauth2() + ..applications(id: '@me'); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parse(response.jsonBody as Map); + } +} diff --git a/lib/src/http/managers/audit_log_manager.dart b/lib/src/http/managers/audit_log_manager.dart new file mode 100644 index 000000000..7a4327c89 --- /dev/null +++ b/lib/src/http/managers/audit_log_manager.dart @@ -0,0 +1,120 @@ +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/guild/audit_log.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +class AuditLogManager extends ReadOnlyManager { + final Snowflake guildId; + + AuditLogManager(super.config, super.client, {required this.guildId}) : super(identifier: '$guildId.auditLogEntries'); + + @override + PartialAuditLogEntry operator [](Snowflake id) => PartialAuditLogEntry(id: id, manager: this); + + @override + AuditLogEntry parse(Map raw) { + return AuditLogEntry( + id: Snowflake.parse(raw['id'] as String), + manager: this, + targetId: maybeParse(raw['target_id'], Snowflake.parse), + changes: maybeParseMany(raw['changes'], parseAuditLogChange), + userId: maybeParse(raw['user_id'], Snowflake.parse), + actionType: AuditLogEvent.parse(raw['action_type'] as int), + options: maybeParse(raw['options'], parseAuditLogEntryInfo), + reason: raw['reason'] as String?, + ); + } + + AuditLogChange parseAuditLogChange(Map raw) { + return AuditLogChange( + oldValue: raw['old_value'], + newValue: raw['new_value'], + key: raw['key'] as String, + ); + } + + AuditLogEntryInfo parseAuditLogEntryInfo(Map raw) { + return AuditLogEntryInfo( + manager: this, + applicationId: maybeParse(raw['application_id'], Snowflake.parse), + autoModerationRuleName: raw['auto_moderation_rule_name'] as String?, + autoModerationTriggerType: raw['auto_moderation_rule_trigger_type'] as String?, + channelId: maybeParse(raw['channel_id'], Snowflake.parse), + count: raw['count'] as String?, + deleteMemberDays: raw['delete_member_days'] as String?, + id: maybeParse(raw['id'], Snowflake.parse), + membersRemoved: raw['members_removed'] as String?, + messageId: maybeParse(raw['message_id'], Snowflake.parse), + roleName: raw['role_name'] as String?, + overwriteType: maybeParse(raw['type'], (String raw) => PermissionOverwriteType.parse(int.parse(raw))), + ); + } + + @override + Future fetch(Snowflake id) async { + // Add one because before and after are exclusive. + final entries = await list(before: Snowflake(id.value + 1)); + + return entries.firstWhere( + (entry) => entry.id == id, + orElse: () => throw AuditLogEntryNotFoundException(guildId, id), + ); + } + + Future> list({Snowflake? userId, AuditLogEvent? type, Snowflake? before, Snowflake? after, int? limit}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..auditLogs(); + final request = BasicRequest(route, queryParameters: { + if (userId != null) 'user_id': userId.toString(), + if (type != null) 'action_type': type.value.toString(), + if (before != null) 'before': before.toString(), + if (after != null) 'after': after.toString(), + if (limit != null) 'limit': limit.toString(), + }); + + final response = await client.httpHandler.executeSafe(request); + final responseBody = response.jsonBody as Map; + final entries = parseMany(responseBody['audit_log_entries'] as List, parse); + + final applicationCommands = parseMany(responseBody['application_commands'] as List, (Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + + if (guildId == null) { + return client.commands.parse(raw); + } + + return client.guilds[guildId].commands.parse(raw); + }); + for (final command in applicationCommands) { + if (command.guild == null) { + client.commands.cache[command.id] = command; + } else { + client.guilds[command.guildId!].commands.cache[command.id] = command; + } + } + + final autoModerationRules = parseMany(responseBody['auto_moderation_rules'] as List, client.guilds[guildId].autoModerationRules.parse); + client.guilds[guildId].autoModerationRules.cache.addEntities(autoModerationRules); + + final scheduledEvents = parseMany(responseBody['guild_scheduled_events'] as List, client.guilds[guildId].scheduledEvents.parse); + client.guilds[guildId].scheduledEvents.cache.addEntities(scheduledEvents); + + final threads = parseMany(responseBody['threads'] as List, client.channels.parse); + client.channels.cache.addEntities(threads); + + final users = parseMany(responseBody['users'] as List, client.users.parse); + client.users.cache.addEntities(users); + + final webhooks = parseMany(responseBody['webhooks'] as List, client.webhooks.parse); + client.webhooks.cache.addEntities(webhooks); + + cache.addEntities(entries); + return entries; + } +} diff --git a/lib/src/http/managers/auto_moderation_manager.dart b/lib/src/http/managers/auto_moderation_manager.dart new file mode 100644 index 000000000..48fc56c19 --- /dev/null +++ b/lib/src/http/managers/auto_moderation_manager.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:nyxx/src/builders/guild/auto_moderation.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/guild/auto_moderation.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +class AutoModerationManager extends Manager { + final Snowflake guildId; + + AutoModerationManager(super.config, super.client, {required this.guildId}) : super(identifier: '$guildId.autoModerationRules'); + + @override + PartialAutoModerationRule operator [](Snowflake id) => PartialAutoModerationRule(id: id, manager: this); + + @override + AutoModerationRule parse(Map raw) { + return AutoModerationRule( + id: Snowflake.parse(raw['id']!), + manager: this, + guildId: Snowflake.parse(raw['guild_id']!), + name: raw['name'] as String, + creatorId: Snowflake.parse(raw['creator_id']!), + eventType: AutoModerationEventType.parse(raw['event_type'] as int), + triggerType: TriggerType.parse(raw['trigger_type'] as int), + metadata: parseTriggerMetadata(raw['trigger_metadata'] as Map), + actions: parseMany(raw['actions'] as List, parseAutoModerationAction), + isEnabled: raw['enabled'] as bool, + exemptRoleIds: parseMany(raw['exempt_roles'] as List, Snowflake.parse), + exemptChannelIds: parseMany(raw['exempt_channels'] as List, Snowflake.parse), + ); + } + + TriggerMetadata parseTriggerMetadata(Map raw) { + return TriggerMetadata( + keywordFilter: maybeParseMany(raw['keyword_filter']), + regexPatterns: maybeParseMany(raw['regex_patterns']), + presets: maybeParseMany(raw['presets'], KeywordPresetType.parse), + allowList: maybeParseMany(raw['allow_list']), + mentionTotalLimit: raw['mention_total_limit'] as int?, + isMentionRaidProtectionEnabled: raw['mention_raid_protection_enabled'] as bool?, + ); + } + + AutoModerationAction parseAutoModerationAction(Map raw) { + return AutoModerationAction( + type: ActionType.parse(raw['type'] as int), + metadata: maybeParse(raw['metadata'], parseActionMetadata), + ); + } + + ActionMetadata parseActionMetadata(Map raw) { + return ActionMetadata( + manager: this, + channelId: maybeParse(raw['channel_id'], Snowflake.parse), + duration: maybeParse(raw['duration_seconds'], (int seconds) => Duration(seconds: seconds)), + customMessage: raw['custom_message'] as String?, + ); + } + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(id: id.toString()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final rule = parse(response.jsonBody as Map); + + cache[rule.id] = rule; + return rule; + } + + Future> list() async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final rules = parseMany(response.jsonBody as List, parse); + + cache.addEntities(rules); + return rules; + } + + @override + Future create(AutoModerationRuleBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build()), auditLogReason: auditLogReason); + + final response = await client.httpHandler.executeSafe(request); + final rule = parse(response.jsonBody as Map); + + cache[rule.id] = rule; + return rule; + } + + @override + Future update(Snowflake id, AutoModerationRuleUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(id: id.toString()); + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build()), auditLogReason: auditLogReason); + + final response = await client.httpHandler.executeSafe(request); + final rule = parse(response.jsonBody as Map); + + cache[rule.id] = rule; + return rule; + } + + @override + Future delete(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + + cache.remove(id); + } +} diff --git a/lib/src/http/managers/channel_manager.dart b/lib/src/http/managers/channel_manager.dart new file mode 100644 index 000000000..9d273328d --- /dev/null +++ b/lib/src/http/managers/channel_manager.dart @@ -0,0 +1,771 @@ +import 'dart:convert'; + +import 'package:http/http.dart' show MultipartFile; +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/channel/stage_instance.dart'; +import 'package:nyxx/src/builders/channel/thread.dart'; +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/followed_channel.dart'; +import 'package:nyxx/src/models/channel/stage_instance.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/channel/thread_list.dart'; +import 'package:nyxx/src/models/channel/types/announcement_thread.dart'; +import 'package:nyxx/src/models/channel/types/directory.dart'; +import 'package:nyxx/src/models/channel/types/dm.dart'; +import 'package:nyxx/src/models/channel/types/forum.dart'; +import 'package:nyxx/src/models/channel/types/group_dm.dart'; +import 'package:nyxx/src/models/channel/types/guild_announcement.dart'; +import 'package:nyxx/src/models/channel/types/guild_category.dart'; +import 'package:nyxx/src/models/channel/types/guild_stage.dart'; +import 'package:nyxx/src/models/channel/types/guild_text.dart'; +import 'package:nyxx/src/models/channel/types/guild_voice.dart'; +import 'package:nyxx/src/models/channel/types/private_thread.dart'; +import 'package:nyxx/src/models/channel/types/public_thread.dart'; +import 'package:nyxx/src/models/channel/voice_channel.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/flags.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [Channel]s. +class ChannelManager extends ReadOnlyManager { + final Cache stageInstanceCache; + + /// Create a new [ChannelManager]. + ChannelManager(super.config, super.client, {required CacheConfig stageInstanceConfig}) + : stageInstanceCache = Cache(client, 'channels.stageInstances', stageInstanceConfig), + super(identifier: 'channels'); + + /// Return a partial instance of the entity with ID [id] containing no data. + /// + /// This allows performing API operations without fetching an instance from the API. + /// + /// Because this method doesn't perform any API checks, there might be no real entity with the + /// correct [id]. In this case, the object returned may not work with the API correctly. + /// + /// While this method's return type is [PartialChannel], a [PartialTextChannel] is always returned. + /// If you are sure the channel you are requesting is a text channel, the returned value can safely + /// be cast to a [PartialTextChannel] to access the channel's messages. + @override + PartialChannel operator [](Snowflake id) => PartialTextChannel(id: id, manager: this); + + @override + Channel parse(Map raw, {Snowflake? guildId}) { + final type = ChannelType.parse(raw['type'] as int); + + final parsers = { + ChannelType.guildText: parseGuildTextChannel, + ChannelType.dm: parseDmChannel, + ChannelType.guildVoice: parseGuildVoiceChannel, + ChannelType.groupDm: parseGroupDmChannel, + ChannelType.guildCategory: parseGuildCategory, + ChannelType.guildAnnouncement: parseGuildAnnouncementChannel, + ChannelType.announcementThread: parseAnnouncementThread, + ChannelType.publicThread: parsePublicThread, + ChannelType.privateThread: parsePrivateThread, + ChannelType.guildStageVoice: parseGuildStageChannel, + ChannelType.guildDirectory: parseDirectoryChannel, + ChannelType.guildForum: parseForumChannel, + }; + + return parsers[type]!(raw, guildId: guildId); + } + + GuildTextChannel parseGuildTextChannel(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.guildText.value, 'Invalid type for GuildTextChannel'); + + return GuildTextChannel( + id: Snowflake.parse(raw['id']!), + manager: this, + topic: raw['topic'] as String?, + // Discord doesn't seem to include this field if the default 3 day expiration is used (3 days = 4320 minutes) + defaultAutoArchiveDuration: Duration(minutes: raw['default_auto_archive_duration'] as int? ?? 4320), + defaultThreadRateLimitPerUser: + maybeParse(raw['default_thread_rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isNsfw: raw['nsfw'] as bool? ?? false, + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + name: raw['name'] as String, + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: raw['position'] as int, + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + ); + } + + DmChannel parseDmChannel(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.dm.value, 'Invalid type for DmChannel'); + + return DmChannel( + id: Snowflake.parse(raw['id']!), + manager: this, + recipient: client.users.parse((raw['recipients'] as List).single as Map), + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + ); + } + + GuildVoiceChannel parseGuildVoiceChannel(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.guildVoice.value, 'Invalid type for GuildVoiceChannel'); + + return GuildVoiceChannel( + id: Snowflake.parse(raw['id']!), + manager: this, + bitrate: raw['bitrate'] as int, + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isNsfw: raw['nsfw'] as bool? ?? false, + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + name: raw['name'] as String, + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: raw['position'] as int, + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + rtcRegion: raw['rtc_region'] as String?, + userLimit: raw['user_limit'] == 0 ? null : raw['user_limit'] as int?, + videoQualityMode: maybeParse(raw['video_quality_mode'], VideoQualityMode.parse) ?? VideoQualityMode.auto, + ); + } + + GroupDmChannel parseGroupDmChannel(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.groupDm.value, 'Invalid type for GroupDmChannel'); + + return GroupDmChannel( + id: Snowflake.parse(raw['id']!), + manager: this, + name: raw['name'] as String, + recipients: parseMany(raw['recipients'] as List, client.users.parse), + iconHash: raw['icon'] as String?, + ownerId: Snowflake.parse(raw['owner_id']!), + applicationId: maybeParse(raw['application_id'], Snowflake.parse), + isManaged: raw['managed'] as bool? ?? false, + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + ); + } + + GuildCategory parseGuildCategory(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.guildCategory.value, 'Invalid type for GuildCategory'); + + return GuildCategory( + id: Snowflake.parse(raw['id']!), + manager: this, + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isNsfw: raw['nsfw'] as bool? ?? false, + name: raw['name'] as String, + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: raw['position'] as int, + ); + } + + GuildAnnouncementChannel parseGuildAnnouncementChannel(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.guildAnnouncement.value, 'Invalid type for GuildAnnouncementChannel'); + + return GuildAnnouncementChannel( + id: Snowflake.parse(raw['id']!), + manager: this, + topic: raw['topic'] as String?, + // Discord doesn't seem to include this field if the default 3 day expiration is used (3 days = 4320 minutes) + defaultAutoArchiveDuration: Duration(minutes: raw['default_auto_archive_duration'] as int? ?? 4320), + defaultThreadRateLimitPerUser: + maybeParse(raw['default_thread_rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isNsfw: raw['nsfw'] as bool? ?? false, + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + name: raw['name'] as String, + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: raw['position'] as int, + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + ); + } + + AnnouncementThread parseAnnouncementThread(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.announcementThread.value, 'Invalid type for AnnouncementThread'); + + return AnnouncementThread( + id: Snowflake.parse(raw['id']!), + manager: this, + appliedTags: maybeParse, List>(raw['applied_tags'], (tags) => parseMany(tags, Snowflake.parse)), + approximateMemberCount: raw['member_count'] as int, + archiveTimestamp: DateTime.parse((raw['thread_metadata'] as Map)['archive_timestamp'] as String), + autoArchiveDuration: Duration(minutes: (raw['thread_metadata'] as Map)['auto_archive_duration'] as int), + createdAt: maybeParse((raw['thread_metadata'] as Map)['create_timestamp'], DateTime.parse) ?? DateTime(2022, 1, 9), + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isArchived: (raw['thread_metadata'] as Map)['archived'] as bool, + isLocked: (raw['thread_metadata'] as Map)['locked'] as bool, + isNsfw: raw['nsfw'] as bool? ?? false, + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + messageCount: raw['message_count'] as int, + name: raw['name'] as String, + ownerId: Snowflake.parse(raw['owner_id']!), + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: -1, + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + totalMessagesSent: raw['total_message_sent'] as int, + flags: maybeParse(raw['flags'], ChannelFlags.new), + ); + } + + PublicThread parsePublicThread(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.publicThread.value, 'Invalid type for PublicThread'); + + return PublicThread( + id: Snowflake.parse(raw['id']!), + manager: this, + appliedTags: maybeParse, List>(raw['applied_tags'], (tags) => parseMany(tags, Snowflake.parse)), + approximateMemberCount: raw['member_count'] as int, + archiveTimestamp: DateTime.parse((raw['thread_metadata'] as Map)['archive_timestamp'] as String), + autoArchiveDuration: Duration(minutes: (raw['thread_metadata'] as Map)['auto_archive_duration'] as int), + createdAt: maybeParse((raw['thread_metadata'] as Map)['create_timestamp'], DateTime.parse) ?? DateTime(2022, 1, 9), + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isArchived: (raw['thread_metadata'] as Map)['archived'] as bool, + isLocked: (raw['thread_metadata'] as Map)['locked'] as bool, + isNsfw: raw['nsfw'] as bool? ?? false, + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + messageCount: raw['message_count'] as int, + name: raw['name'] as String, + ownerId: Snowflake.parse(raw['owner_id']!), + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: -1, + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + totalMessagesSent: raw['total_message_sent'] as int, + flags: maybeParse(raw['flags'], ChannelFlags.new), + ); + } + + PrivateThread parsePrivateThread(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.privateThread.value, 'Invalid type for PrivateThread'); + + return PrivateThread( + id: Snowflake.parse(raw['id']!), + manager: this, + isInvitable: (raw['thread_metadata'] as Map)['invitable'] as bool, + appliedTags: maybeParse, List>(raw['applied_tags'], (tags) => parseMany(tags, Snowflake.parse)), + approximateMemberCount: raw['member_count'] as int, + archiveTimestamp: DateTime.parse((raw['thread_metadata'] as Map)['archive_timestamp'] as String), + autoArchiveDuration: Duration(minutes: (raw['thread_metadata'] as Map)['auto_archive_duration'] as int), + createdAt: maybeParse((raw['thread_metadata'] as Map)['create_timestamp'], DateTime.parse) ?? DateTime(2022, 1, 9), + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isArchived: (raw['thread_metadata'] as Map)['archived'] as bool, + isLocked: (raw['thread_metadata'] as Map)['locked'] as bool, + isNsfw: raw['nsfw'] as bool? ?? false, + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + messageCount: raw['message_count'] as int, + name: raw['name'] as String, + ownerId: Snowflake.parse(raw['owner_id']!), + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: -1, + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + totalMessagesSent: raw['total_message_sent'] as int, + flags: maybeParse(raw['flags'], ChannelFlags.new), + ); + } + + GuildStageChannel parseGuildStageChannel(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.guildStageVoice.value, 'Invalid type for GuildStageChannel'); + + return GuildStageChannel( + id: Snowflake.parse(raw['id']!), + manager: this, + bitrate: raw['bitrate'] as int, + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isNsfw: raw['nsfw'] as bool? ?? false, + lastMessageId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + name: raw['name'] as String, + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: raw['position'] as int, + rateLimitPerUser: maybeParse(raw['rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + rtcRegion: raw['rtc_region'] as String?, + userLimit: raw['user_limit'] == 0 ? null : raw['user_limit'] as int?, + videoQualityMode: maybeParse(raw['video_quality_mode'], VideoQualityMode.parse) ?? VideoQualityMode.auto, + ); + } + + DirectoryChannel parseDirectoryChannel(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.guildDirectory.value, 'Invalid type for DirectoryChannel'); + + return DirectoryChannel( + id: Snowflake.parse(raw['id']!), + manager: this, + ); + } + + ForumChannel parseForumChannel(Map raw, {Snowflake? guildId}) { + assert(raw['type'] == ChannelType.guildForum.value, 'Invalid type for ForumChannel'); + + return ForumChannel( + id: Snowflake.parse(raw['id']!), + manager: this, + topic: raw['topic'] as String?, + lastThreadId: maybeParse(raw['last_message_id'], Snowflake.parse), + lastPinTimestamp: maybeParse(raw['last_pin_timestamp'], DateTime.parse), + flags: ChannelFlags(raw['flags'] as int), + availableTags: parseMany(raw['available_tags'] as List, parseForumTag), + defaultReaction: maybeParse(raw['default_reaction_emoji'], parseDefaultReaction), + // Discord doesn't seem to include this field if the default 3 day expiration is used (3 days = 4320 minutes) + defaultAutoArchiveDuration: Duration(minutes: raw['default_auto_archive_duration'] as int? ?? 4320), + defaultThreadRateLimitPerUser: + maybeParse(raw['default_thread_rate_limit_per_user'], (value) => value == 0 ? null : Duration(seconds: value)), + guildId: guildId ?? Snowflake.parse(raw['guild_id']!), + isNsfw: raw['nsfw'] as bool? ?? false, + name: raw['name'] as String, + parentId: maybeParse(raw['parent_id'], Snowflake.parse), + permissionOverwrites: maybeParseMany(raw['permission_overwrites'], parsePermissionOverwrite) ?? [], + position: raw['position'] as int, + ); + } + + PermissionOverwrite parsePermissionOverwrite(Map raw) { + return PermissionOverwrite( + id: Snowflake.parse(raw['id']!), + type: PermissionOverwriteType.parse(raw['type'] as int), + allow: Permissions(int.parse(raw['allow'] as String)), + deny: Permissions(int.parse(raw['deny'] as String)), + ); + } + + ForumTag parseForumTag(Map raw) { + return ForumTag( + id: Snowflake.parse(raw['id']!), + name: raw['name'] as String, + isModerated: raw['moderated'] as bool, + emojiId: maybeParse(raw['emoji_id'], Snowflake.parse), + emojiName: raw['emoji_name'] as String?, + ); + } + + DefaultReaction parseDefaultReaction(Map raw) { + return DefaultReaction( + emojiId: maybeParse(raw['emoji_id'], Snowflake.parse), + emojiName: raw['emoji_name'] as String?, + ); + } + + FollowedChannel parseFollowedChannel(Map raw) { + return FollowedChannel( + manager: this, + channelId: Snowflake.parse(raw['channel_id']!), + webhookId: Snowflake.parse(raw['webhook_id']!), + ); + } + + ThreadMember parseThreadMember(Map raw) { + return ThreadMember( + manager: this, + joinTimestamp: DateTime.parse(raw['join_timestamp'] as String), + flags: Flags(raw['flags'] as int), + threadId: Snowflake.parse(raw['id']!), + userId: Snowflake.parse(raw['user_id']!), + member: maybeParse(raw['member'], client.guilds[Snowflake.zero].members.parse), + ); + } + + ThreadList parseThreadList(Map raw) { + return ThreadList( + threads: parseMany(raw['threads'] as List, parse).cast(), + members: parseMany(raw['members'] as List, parseThreadMember), + hasMore: raw['has_more'] as bool? ?? false, + ); + } + + StageInstance parseStageInstance(Map raw) { + return StageInstance( + id: Snowflake.parse(raw['id']!), + manager: this, + guildId: Snowflake.parse(raw['guild_id']!), + channelId: Snowflake.parse(raw['channel_id']!), + topic: raw['topic'] as String, + privacyLevel: PrivacyLevel.parse(raw['privacy_level'] as int), + scheduledEventId: maybeParse(raw['guild_scheduled_event_id'], Snowflake.parse), + ); + } + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute()..channels(id: id.toString()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final channel = parse(response.jsonBody as Map); + + cache[channel.id] = channel; + return channel; + } + + /// Update a channel using the provided [builder]. + Future update(Snowflake id, UpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute()..channels(id: id.toString()); + final request = BasicRequest( + route, + method: 'PATCH', + body: jsonEncode(builder.build()), + auditLogReason: auditLogReason, + ); + + final response = await client.httpHandler.executeSafe(request); + final channel = parse(response.jsonBody as Map); + + cache[channel.id] = channel; + return channel; + } + + /// Delete a guild channel or close a DM channel. + Future delete(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute()..channels(id: id.toString()); + final request = BasicRequest( + route, + method: 'DELETE', + auditLogReason: auditLogReason, + ); + + final response = await client.httpHandler.executeSafe(request); + final channel = parse(response.jsonBody as Map); + + cache.remove(channel.id); + return channel; + } + + /// Update a permission overwrite in a channel. + Future updatePermissionOverwrite(Snowflake id, PermissionOverwriteBuilder builder) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..permissions(id: builder.id.toString()); + final request = BasicRequest(route, method: 'PUT', body: jsonEncode(builder.build())); + + await client.httpHandler.executeSafe(request); + } + + /// Delete a permission overwrite in a channel. + Future deletePermissionOverwrite(Snowflake id, Snowflake permissionId) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..permissions(id: permissionId.toString()); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + } + + /// List the invites in a guild channel. + Future> listInvites(Snowflake id) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..invites(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, client.invites.parseWithMetadata); + } + + /// Create an invite in a guild channel. + Future createInvite(Snowflake id, InviteBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..invites(); + final request = BasicRequest(route, method: 'POST', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + return client.invites.parse(response.jsonBody as Map); + } + + /// Add a channel to another channel's followers. + Future followChannel(Snowflake id, Snowflake toFollow) async { + final route = HttpRoute() + ..channels(id: toFollow.toString()) + ..followers(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode({'webhook_channel_id': id.toString()})); + + final response = await client.httpHandler.executeSafe(request); + + return parseFollowedChannel(response.jsonBody as Map); + } + + /// Trigger the typing indicator for the current user in a channel. + Future triggerTyping(Snowflake channelId) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..typing(); + final request = BasicRequest(route, method: 'POST'); + + await client.httpHandler.executeSafe(request); + } + + /// Create a thread from a message in a channel. + Future createThreadFromMessage(Snowflake id, Snowflake messageId, ThreadFromMessageBuilder builder) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..messages(id: messageId.toString()) + ..threads(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final thread = parse(response.jsonBody as Map) as Thread; + + cache[thread.id] = thread; + return thread; + } + + /// Create a thread in a channel. + Future createThread(Snowflake id, ThreadBuilder builder) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threads(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final thread = parse(response.jsonBody as Map) as Thread; + + cache[thread.id] = thread; + return thread; + } + + /// Create a thread in a forum channel. + Future createForumThread(Snowflake id, ForumThreadBuilder builder) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threads(); + + final HttpRequest request; + if (builder.message.attachments?.isNotEmpty == true) { + final attachments = builder.message.attachments!; + final payload = builder.build(); + + final files = []; + for (int i = 0; i < attachments.length; i++) { + files.add(MultipartFile.fromBytes( + 'files[$i]', + attachments[i].data, + filename: attachments[i].fileName, + )); + + (((payload['message'] as Map)['attachments'] as List)[i] as Map)['id'] = i.toString(); + } + + request = MultipartRequest( + route, + method: 'PATCH', + jsonPayload: jsonEncode(payload), + files: files, + ); + } else { + request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + } + + final response = await client.httpHandler.executeSafe(request); + final thread = parse(response.jsonBody as Map) as Thread; + + cache[thread.id] = thread; + return thread; + } + + /// Add the current user to a thread. + Future joinThread(Snowflake id) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threadMembers(id: '@me'); + final request = BasicRequest(route, method: 'PUT'); + + await client.httpHandler.executeSafe(request); + } + + /// Add a member to a thread. + Future addThreadMember(Snowflake id, Snowflake memberId) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threadMembers(id: memberId.toString()); + final request = BasicRequest(route, method: 'PUT'); + + await client.httpHandler.executeSafe(request); + } + + /// Remove the current user from a thread. + Future leaveThread(Snowflake id) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threadMembers(id: '@me'); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + } + + /// Remove a member from a thread. + Future removeThreadMember(Snowflake id, Snowflake memberId) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threadMembers(id: memberId.toString()); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + } + + /// Fetch information about a member in a thread. + Future fetchThreadMember(Snowflake id, Snowflake memberId, {bool? withMember}) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threadMembers(id: memberId.toString()); + final request = BasicRequest( + route, + queryParameters: { + if (withMember != null) 'with_member': withMember.toString(), + }, + ); + + final response = await client.httpHandler.executeSafe(request); + return parseThreadMember(response.jsonBody as Map); + } + + /// List the members of a thread. + Future> listThreadMembers(Snowflake id, {bool? withMembers, Snowflake? after, int? limit}) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threadMembers(); + final request = BasicRequest( + route, + queryParameters: { + if (withMembers != null) 'with_member': withMembers.toString(), + if (after != null) 'after': after.toString(), + if (limit != null) 'limit': limit.toString(), + }, + ); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, parseThreadMember); + } + + /// List the public archived threads in a channel. + Future listPublicArchivedThreads(Snowflake id, {DateTime? before, int? limit}) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threads() + ..archived() + ..public(); + final request = BasicRequest( + route, + queryParameters: { + if (before != null) 'before': before.toIso8601String(), + if (limit != null) 'limit': limit.toString(), + }, + ); + + final response = await client.httpHandler.executeSafe(request); + return parseThreadList(response.jsonBody as Map); + } + + /// List the private archived threads in a channel. + Future listPrivateArchivedThreads(Snowflake id, {DateTime? before, int? limit}) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..threads() + ..archived() + ..private(); + final request = BasicRequest( + route, + queryParameters: { + if (before != null) 'before': before.toIso8601String(), + if (limit != null) 'limit': limit.toString(), + }, + ); + + final response = await client.httpHandler.executeSafe(request); + return parseThreadList(response.jsonBody as Map); + } + + /// List the private archived threads the current user has joined in a channel. + Future listJoinedPrivateArchivedThreads(Snowflake id, {DateTime? before, int? limit}) async { + final route = HttpRoute() + ..channels(id: id.toString()) + ..users(id: '@me') + ..threads() + ..archived() + ..private(); + final request = BasicRequest( + route, + queryParameters: { + if (before != null) 'before': before.toIso8601String(), + if (limit != null) 'limit': limit.toString(), + }, + ); + + final response = await client.httpHandler.executeSafe(request); + return parseThreadList(response.jsonBody as Map); + } + + /// Start a stage instance in a channel. + Future createStageInstance(Snowflake channelId, StageInstanceBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute()..stageInstances(); + final request = BasicRequest( + route, + method: 'POST', + body: jsonEncode({'channel_id': channelId.toString(), ...builder.build()}), + auditLogReason: auditLogReason, + ); + + final response = await client.httpHandler.executeSafe(request); + final stageInstance = parseStageInstance(response.jsonBody as Map); + + stageInstanceCache[stageInstance.channelId] = stageInstance; + return stageInstance; + } + + /// Fetch the current stage instance for a channel. + Future fetchStageInstance(Snowflake channelId) async { + final route = HttpRoute()..stageInstances(id: channelId.toString()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final stageInstance = parseStageInstance(response.jsonBody as Map); + + stageInstanceCache[stageInstance.channelId] = stageInstance; + return stageInstance; + } + + /// Update the stage instance for a channel. + Future updateStageInstance(Snowflake channelId, StageInstanceUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute()..stageInstances(id: channelId.toString()); + final request = BasicRequest( + route, + method: 'PATCH', + body: jsonEncode(builder.build()), + auditLogReason: auditLogReason, + ); + + final response = await client.httpHandler.executeSafe(request); + final stageInstance = parseStageInstance(response.jsonBody as Map); + + stageInstanceCache[stageInstance.channelId] = stageInstance; + return stageInstance; + } + + /// Delete the stage instance for a channel. + Future deleteStageInstance(Snowflake channelId, {String? auditLogReason}) async { + final route = HttpRoute()..stageInstances(id: channelId.toString()); + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + + stageInstanceCache.remove(channelId); + } +} diff --git a/lib/src/http/managers/emoji_manager.dart b/lib/src/http/managers/emoji_manager.dart new file mode 100644 index 000000000..f2f00db24 --- /dev/null +++ b/lib/src/http/managers/emoji_manager.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:nyxx/src/builders/emoji/emoji.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +import 'manager.dart'; + +class EmojiManager extends Manager { + final Snowflake guildId; + + EmojiManager(super.config, super.client, {required this.guildId}) : super(identifier: '$guildId.emojis'); + + @override + PartialEmoji operator [](Snowflake id) => PartialEmoji(id: id, manager: this); + + @override + Emoji parse(Map raw) { + final isUnicode = raw['id'] == null; + + if (isUnicode) { + return TextEmoji( + name: raw['name'] as String, + manager: this, + id: Snowflake.zero, + ); + } + + return GuildEmoji( + id: Snowflake.parse(raw['id'] as String), + manager: this, + user: maybeParse(raw['user'], client.users.parse), + isAnimated: raw['animated'] as bool?, + isAvailable: raw['available'] as bool?, + isManaged: raw['managed'] as bool?, + requiresColons: raw['require_colons'] as bool?, + name: raw['name'] as String?, + roleIds: maybeParseMany(raw['roles'], Snowflake.parse), + ); + } + + void _checkIsConcrete([Snowflake? id]) { + if (guildId == Snowflake.zero) { + throw UnsupportedError('Cannot fetch, create, update or delete emoji received without a guild'); + } + + if (id == Snowflake.zero) { + throw UnsupportedError('Cannot fetch, create, update or delete a text emoji by ID'); + } + } + + @override + Future get(Snowflake id) async => await super.get(id) as GuildEmoji; + + @override + Future fetch(Snowflake id) async { + _checkIsConcrete(id); + + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..emojis(id: id.toString()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final emoji = parse(response.jsonBody as Map) as GuildEmoji; + + cache[emoji.id] = emoji; + return emoji; + } + + Future> list() async { + _checkIsConcrete(); + + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..emojis(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final emojis = parseMany(response.jsonBody as List, (Map raw) => parse(raw) as GuildEmoji); + + cache.addEntities(emojis); + return emojis; + } + + @override + Future create(EmojiBuilder builder, {String? audiReason}) async { + _checkIsConcrete(); + + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..emojis(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final emoji = parse(response.jsonBody as Map) as GuildEmoji; + + cache[emoji.id] = emoji; + return emoji; + } + + @override + Future update(Snowflake id, EmojiUpdateBuilder builder, {String? auditReason}) async { + _checkIsConcrete(id); + + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..emojis(id: id.toString()); + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final emoji = parse(response.jsonBody as Map) as GuildEmoji; + + cache[emoji.id] = emoji; + return emoji; + } + + @override + Future delete(Snowflake id, {String? auditReason}) async { + _checkIsConcrete(id); + + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..emojis(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + cache.remove(id); + } +} diff --git a/lib/src/http/managers/gateway_manager.dart b/lib/src/http/managers/gateway_manager.dart new file mode 100644 index 000000000..2ee4ad8a1 --- /dev/null +++ b/lib/src/http/managers/gateway_manager.dart @@ -0,0 +1,138 @@ +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/gateway/gateway.dart'; +import 'package:nyxx/src/models/presence.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A [Manager] for gateway information. +// Use an abstract class so the client getter can be abstract, +// allowing us to override it in Gateway to have a more specific type. +abstract class GatewayManager { + /// The client this manager is for. + NyxxRest get client; + + /// @nodoc + // We need a constructor to be allowed to use this class as a superclass. + GatewayManager.create(); + + /// Create a new [GatewayManager]. + factory GatewayManager(NyxxRest client) = _GatewayManagerImpl; + + GatewayConfiguration parseGatewayConfiguration(Map raw) { + return GatewayConfiguration(url: Uri.parse(raw['url'] as String)); + } + + GatewayBot parseGatewayBot(Map raw) { + return GatewayBot( + url: Uri.parse(raw['url'] as String), + shards: raw['shards'] as int, + sessionStartLimit: parseSessionStartLimit(raw['session_start_limit'] as Map), + ); + } + + SessionStartLimit parseSessionStartLimit(Map raw) { + return SessionStartLimit( + total: raw['total'] as int, + remaining: raw['remaining'] as int, + resetAfter: Duration(milliseconds: raw['reset_after'] as int), + maxConcurrency: raw['max_concurrency'] as int, + ); + } + + Activity parseActivity(Map raw) { + // No fields are validated server-side. Expect errors. + return Activity( + name: raw['name'] as String, + type: ActivityType.parse(raw['type'] as int), + url: tryParse(raw['url'], Uri.parse), + createdAt: tryParse(raw['created_at'], DateTime.fromMillisecondsSinceEpoch), + timestamps: tryParse(raw['timestamps'], parseActivityTimestamps), + applicationId: tryParse(raw['application_id'], Snowflake.parse), + details: tryParse(raw['details']), + state: tryParse(raw['state']), + emoji: tryParse(raw['emoji'], client.guilds[Snowflake.zero].emojis.parse), + party: tryParse(raw['party'], parseActivityParty), + assets: tryParse(raw['assets'], parseActivityAssets), + secrets: tryParse(raw['secrets'], parseActivitySecrets), + isInstance: tryParse(raw['instance']), + flags: tryParse(raw['flags'], ActivityFlags.new), + buttons: tryParseMany(raw['buttons'], parseActivityButton), + ); + } + + ActivityTimestamps parseActivityTimestamps(Map raw) { + return ActivityTimestamps( + start: maybeParse(raw['start'], (int milliseconds) => DateTime.fromMillisecondsSinceEpoch(milliseconds)), + end: maybeParse(raw['end'], (int milliseconds) => DateTime.fromMillisecondsSinceEpoch(milliseconds)), + ); + } + + ActivityParty parseActivityParty(Map raw) { + return ActivityParty( + id: raw['id'] as String?, + currentSize: (raw['size'] as List?)?[0] as int?, + maxSize: (raw['size'] as List?)?[1] as int?, + ); + } + + ActivityAssets parseActivityAssets(Map raw) { + return ActivityAssets( + largeImage: raw['large_image'] as String?, + largeText: raw['large_text'] as String?, + smallImage: raw['small_image'] as String?, + smallText: raw['small_text'] as String?, + ); + } + + ActivitySecrets parseActivitySecrets(Map raw) { + return ActivitySecrets( + join: raw['join'] as String?, + spectate: raw['spectate'] as String?, + match: raw['match'] as String?, + ); + } + + ActivityButton parseActivityButton(Map raw) { + return ActivityButton( + label: raw['label'] as String, + url: Uri.parse(raw['url'] as String), + ); + } + + ClientStatus parseClientStatus(Map raw) { + return ClientStatus( + desktop: maybeParse(raw['desktop'], UserStatus.parse), + mobile: maybeParse(raw['mobile'], UserStatus.parse), + web: maybeParse(raw['web'], UserStatus.parse), + ); + } + + /// Fetch the current gateway configuration. + Future fetchGatewayConfiguration() async { + final route = HttpRoute()..gateway(); + final request = BasicRequest(route, authenticated: false); + + final response = await client.httpHandler.executeSafe(request); + return parseGatewayConfiguration(response.jsonBody as Map); + } + + /// Fetch the current gateway configuration for the client. + Future fetchGatewayBot() async { + final route = HttpRoute() + ..gateway() + ..bot(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseGatewayBot(response.jsonBody as Map); + } +} + +class _GatewayManagerImpl extends GatewayManager { + @override + final NyxxRest client; + + _GatewayManagerImpl(this.client) : super.create(); +} diff --git a/lib/src/http/managers/guild_manager.dart b/lib/src/http/managers/guild_manager.dart new file mode 100644 index 000000000..9729b56d2 --- /dev/null +++ b/lib/src/http/managers/guild_manager.dart @@ -0,0 +1,735 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:nyxx/src/builders/channel/channel_position.dart'; +import 'package:nyxx/src/builders/channel/guild_channel.dart'; +import 'package:nyxx/src/builders/guild/guild.dart'; +import 'package:nyxx/src/builders/guild/template.dart'; +import 'package:nyxx/src/builders/guild/welcome_screen.dart'; +import 'package:nyxx/src/builders/guild/widget.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/builders/voice.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/thread_list.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/guild/ban.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/guild_preview.dart'; +import 'package:nyxx/src/models/guild/guild_widget.dart'; +import 'package:nyxx/src/models/guild/onboarding.dart'; +import 'package:nyxx/src/models/guild/template.dart'; +import 'package:nyxx/src/models/guild/welcome_screen.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/models/voice/voice_region.dart'; +import 'package:nyxx/src/utils/flags.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [Guild]s. +class GuildManager extends Manager { + /// Create a new [GuildManager]. + GuildManager(super.config, super.client) : super(identifier: 'guilds'); + + @override + PartialGuild operator [](Snowflake id) => PartialGuild(id: id, manager: this); + + @override + Guild parse(Map raw) { + final id = Snowflake.parse(raw['id'] as String); + + return Guild( + id: id, + manager: this, + name: raw['name'] as String, + iconHash: (raw['icon'] ?? raw['icon_hash']) as String?, + splashHash: raw['splash'] as String?, + discoverySplashHash: raw['discovery_splash'] as String?, + isOwnedByCurrentUser: raw['owner'] as bool?, + ownerId: Snowflake.parse(raw['owner_id']!), + currentUserPermissions: maybeParse(raw['permissions'], (String permissions) => Permissions(int.parse(permissions))), + afkChannelId: maybeParse(raw['afk_channel_id'], Snowflake.parse), + afkTimeout: Duration(seconds: raw['afk_timeout'] as int), + isWidgetEnabled: raw['widget_enabled'] as bool? ?? false, + widgetChannelId: maybeParse(raw['widget_channel_id'], Snowflake.parse), + verificationLevel: VerificationLevel.parse(raw['verification_level'] as int), + defaultMessageNotificationLevel: MessageNotificationLevel.parse(raw['default_message_notifications'] as int), + explicitContentFilterLevel: ExplicitContentFilterLevel.parse(raw['explicit_content_filter'] as int), + roleList: parseMany(raw['roles'] as List, this[id].roles.parse), + features: parseGuildFeatures(raw['features'] as List), + mfaLevel: MfaLevel.parse(raw['mfa_level'] as int), + applicationId: maybeParse(raw['application_id'], Snowflake.parse), + systemChannelId: maybeParse(raw['system_channel_id'], Snowflake.parse), + systemChannelFlags: SystemChannelFlags(raw['system_channel_flags'] as int), + rulesChannelId: maybeParse(raw['rules_channel_id'], Snowflake.parse), + maxPresences: raw['max_presences'] as int?, + maxMembers: raw['max_members'] as int?, + vanityUrlCode: raw['vanity_url_code'] as String?, + description: raw['description'] as String?, + bannerHash: raw['banner'] as String?, + premiumTier: PremiumTier.parse(raw['premium_tier'] as int), + premiumSubscriptionCount: raw['premium_subscription_count'] as int?, + preferredLocale: Locale.parse(raw['preferred_locale'] as String), + publicUpdatesChannelId: maybeParse(raw['public_updates_channel_id'], Snowflake.parse), + maxVideoChannelUsers: raw['max_video_channel_users'] as int?, + maxStageChannelUsers: raw['max_stage_video_channel_users'] as int?, + approximateMemberCount: raw['approximate_member_count'] as int?, + approximatePresenceCount: raw['approximate_presence_count'] as int?, + welcomeScreen: maybeParse(raw['welcome_screen'], parseWelcomeScreen), + nsfwLevel: NsfwLevel.parse(raw['nsfw_level'] as int), + hasPremiumProgressBarEnabled: raw['premium_progress_bar_enabled'] as bool, + emojiList: parseMany(raw['emojis'] as List, this[id].emojis.parse), + stickerList: parseMany(raw['stickers'] as List? ?? [], this[id].stickers.parse), + ); + } + + static final Map> _nameToGuildFeature = { + 'ANIMATED_BANNER': GuildFeatures.animatedBanner, + 'ANIMATED_ICON': GuildFeatures.animatedIcon, + 'APPLICATION_COMMAND_PERMISSIONS_V2': GuildFeatures.applicationCommandPermissionsV2, + 'AUTO_MODERATION': GuildFeatures.autoModeration, + 'BANNER': GuildFeatures.banner, + 'COMMUNITY': GuildFeatures.community, + 'CREATOR_MONETIZABLE_PROVISIONAL': GuildFeatures.creatorMonetizableProvisional, + 'CREATOR_STORE_PAGE': GuildFeatures.creatorStorePage, + 'DEVELOPER_SUPPORT_SERVER': GuildFeatures.developerSupportServer, + 'DISCOVERABLE': GuildFeatures.discoverable, + 'FEATURABLE': GuildFeatures.featurable, + 'INVITES_DISABLED': GuildFeatures.invitesDisabled, + 'INVITE_SPLASH': GuildFeatures.inviteSplash, + 'MEMBER_VERIFICATION_GATE_ENABLED': GuildFeatures.memberVerificationGateEnabled, + 'MORE_STICKERS': GuildFeatures.moreStickers, + 'NEWS': GuildFeatures.news, + 'PARTNERED': GuildFeatures.partnered, + 'PREVIEW_ENABLED': GuildFeatures.previewEnabled, + 'RAID_ALERTS_DISABLED': GuildFeatures.raidAlertsDisabled, + 'ROLE_ICONS': GuildFeatures.roleIcons, + 'ROLE_SUBSCRIPTIONS_AVAILABLE_FOR_PURCHASE': GuildFeatures.roleSubscriptionsAvailableForPurchase, + 'ROLE_SUBSCRIPTIONS_ENABLED': GuildFeatures.roleSubscriptionsEnabled, + 'TICKETED_EVENTS_ENABLED': GuildFeatures.ticketedEventsEnabled, + 'VANITY_URL': GuildFeatures.vanityUrl, + 'VERIFIED': GuildFeatures.verified, + 'VIP_REGIONS': GuildFeatures.vipRegions, + 'WELCOME_SCREEN_ENABLED': GuildFeatures.welcomeScreenEnabled, + }; + + static final Map, String> _guildFeatureToName = { + for (final entry in _nameToGuildFeature.entries) entry.value: entry.key, + }; + + /// Parse an [GuildFeatures] from [raw]./// Parse [GuildFeatures] from [raw]. + GuildFeatures parseGuildFeatures(List raw) { + final featureFlags = parseMany(raw, parseGuildFeature); + + return GuildFeatures(featureFlags.fold(0, (value, element) => value | element.value)); + } + + /// Parse a [Flag] from [raw]. + Flag parseGuildFeature(String raw) { + return _nameToGuildFeature[raw] ?? Flag(0); + } + + /// Serialize [source] to a [List]. + static List serializeGuildFeatures(Flags source) { + return source.map(serializeGuildFeature).toList(); + } + + /// Serialize [source] to a [String]. + static String serializeGuildFeature(Flag source) { + return _guildFeatureToName[source]!; + } + + /// Parse a [WelcomeScreen] from [raw]. + WelcomeScreen parseWelcomeScreen(Map raw) { + return WelcomeScreen( + description: raw['description'] as String?, + channels: parseMany(raw['welcome_channels'] as List, parseWelcomeScreenChannel), + ); + } + + /// Parse a [WelcomeScreenChannel] from [raw]. + WelcomeScreenChannel parseWelcomeScreenChannel(Map raw) { + return WelcomeScreenChannel( + manager: this, + channelId: Snowflake.parse(raw['channel_id']!), + description: raw['description'] as String, + emojiId: maybeParse(raw['emoji_id'], Snowflake.parse), + emojiName: raw['emoji_name'] as String?, + ); + } + + /// Parse a [GuildPreview] from [raw]. + GuildPreview parseGuildPreview(Map raw) { + final id = Snowflake.parse(raw['id']!); + + return GuildPreview( + id: id, + manager: this, + name: raw['name'] as String, + iconHash: raw['icon'] as String?, + splashHash: raw['splash'] as String?, + discoverySplashHash: raw['discovery_splash'] as String?, + emojiList: parseMany(raw['emojis'] as List, this[id].emojis.parse), + features: parseGuildFeatures(raw['features'] as List), + description: raw['description'] as String?, + approximateMemberCount: raw['approximate_member_count'] as int, + approximatePresenceCount: raw['approximate_presence_count'] as int, + stickerList: parseMany(raw['stickers'] as List? ?? [], this[id].stickers.parse), + ); + } + + /// Parse a [Ban] from [raw]. + Ban parseBan(Map raw) { + return Ban( + reason: raw['reason'] as String, + user: client.users.parse(raw['user'] as Map), + ); + } + + /// Parse a [WidgetSettings] from [raw]. + WidgetSettings parseWidgetSettings(Map raw) { + return WidgetSettings( + manager: this, + isEnabled: raw['enabled'] as bool, + channelId: maybeParse(raw['channel_id'], Snowflake.parse), + ); + } + + /// Parse a [GuildWidget] from [raw]. + GuildWidget parseGuildWidget(Map raw) { + return GuildWidget( + manager: this, + guildId: Snowflake.parse(raw['id']!), + name: raw['name'] as String, + invite: raw['instant_invite'] as String?, + channels: parseMany( + raw['channels'] as List, + (Map raw) => PartialChannel(id: Snowflake.parse(raw['id']!), manager: client.channels), + ), + users: parseMany( + raw['members'] as List, + (Map raw) => PartialUser(id: Snowflake.parse(raw['id']!), manager: client.users), + ), + presenceCount: raw['presence_count'] as int, + ); + } + + /// Parse an [Onboarding] from [raw]. + Onboarding parseOnboarding(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return Onboarding( + manager: this, + guildId: guildId, + prompts: parseMany(raw['prompts'] as List, (Map raw) => parseOnboardingPrompt(raw, guildId: guildId)), + defaultChannelIds: parseMany(raw['default_channel_ids'] as List, Snowflake.parse), + isEnabled: raw['enabled'] as bool, + ); + } + + /// Parse an [OnboardingPrompt] from [raw]. + OnboardingPrompt parseOnboardingPrompt(Map raw, {Snowflake? guildId}) { + return OnboardingPrompt( + id: Snowflake.parse(raw['id']!), + type: OnboardingPromptType.parse(raw['type'] as int), + options: parseMany(raw['options'] as List, (Map raw) => parseOnboardingPromptOption(raw, guildId: guildId)), + title: raw['title'] as String, + isSingleSelect: raw['single_select'] as bool, + isRequired: raw['required'] as bool, + isInOnboarding: raw['in_onboarding'] as bool, + ); + } + + /// Parse an [OnboardingPromptOption] from [raw]. + OnboardingPromptOption parseOnboardingPromptOption(Map raw, {Snowflake? guildId}) { + Emoji? emoji; + final rawEmoji = raw['emoji'] as Map; + + // Discord passes an "empty" emoji object when unset instead of null + if (rawEmoji['id'] != null || rawEmoji['name'] != null) { + emoji = this[guildId ?? Snowflake.zero].emojis.parse(raw['emoji'] as Map); + } + + return OnboardingPromptOption( + manager: this, + id: Snowflake.parse(raw['id']!), + channelIds: parseMany(raw['channel_ids'] as List, Snowflake.parse), + roleIds: parseMany(raw['role_ids'] as List, Snowflake.parse), + emoji: emoji, + title: raw['title'] as String, + description: raw['description'] as String?, + ); + } + + GuildTemplate parseGuildTemplate(Map raw) { + final sourceGuildId = Snowflake.parse(raw['source_guild_id']!); + + return GuildTemplate( + code: raw['code'] as String, + manager: this, + name: raw['name'] as String, + description: raw['description'] as String?, + usageCount: raw['usage_count'] as int, + creatorId: Snowflake.parse(raw['creator_id']!), + creator: client.users.parse(raw['creator'] as Map), + createdAt: DateTime.parse(raw['created_at'] as String), + updatedAt: DateTime.parse(raw['updated_at'] as String), + sourceGuildId: sourceGuildId, + // Add synthetic fields so we can parse the (mostly complete) partial guild as a full guild + serializedSourceGuild: parse({ + 'id': sourceGuildId.toString(), + 'owner_id': Snowflake.zero.toString(), + 'features': [], + 'mfa_level': MfaLevel.none.value, + 'premium_tier': PremiumTier.none.value, + 'nsfw_level': NsfwLevel.unset.value, + 'premium_progress_bar_enabled': false, + 'emojis': [], + ...(raw['serialized_source_guild'] as Map), + 'roles': [ + for (final role in ((raw['serialized_source_guild'] as Map)['roles'] as List).cast>()) + { + 'position': 0, + ...role, + }, + ], + }), + isDirty: raw['is_dirty'] as bool?, + ); + } + + @override + Future fetch(Snowflake id, {bool? withCounts}) async { + final route = HttpRoute()..guilds(id: id.toString()); + final request = BasicRequest(route, queryParameters: {if (withCounts != null) 'with_counts': withCounts.toString()}); + + final response = await client.httpHandler.executeSafe(request); + final guild = parse(response.jsonBody as Map); + + cache[guild.id] = guild; + return guild; + } + + @override + Future create(GuildBuilder builder) async { + final route = HttpRoute()..guilds(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final guild = parse(response.jsonBody as Map); + + cache[guild.id] = guild; + return guild; + } + + @override + Future update(Snowflake id, GuildUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute()..guilds(id: id.toString()); + final request = BasicRequest(route, method: 'PATCH', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final guild = parse(response.jsonBody as Map); + + cache[guild.id] = guild; + return guild; + } + + @override + Future delete(Snowflake id) async { + final route = HttpRoute()..guilds(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + cache.remove(id); + } + + /// Fetch a guild's preview. + Future fetchGuildPreview(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..preview(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseGuildPreview(response.jsonBody as Map); + } + + /// Fetch the channels in a guild. + Future> fetchGuildChannels(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..channels(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final channels = parseMany(response.jsonBody as List, client.channels.parse).cast(); + + client.channels.cache.addEntities(channels); + return channels; + } + + /// Create a channel in a guild. + Future createGuildChannel(Snowflake id, GuildChannelBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..channels(); + final request = BasicRequest(route, method: 'POST', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final channel = client.channels.parse(response.jsonBody as Map) as T; + + client.channels.cache[channel.id] = channel; + return channel; + } + + ///Update the positions of channels in a guild. + Future updateChannelPositions(Snowflake id, List positions) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..channels(); + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(positions.map((e) => e.build()).toList())); + + await client.httpHandler.executeSafe(request); + } + + /// List the active threads in a guild. + Future listActiveThreads(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..threads() + ..active(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final list = client.channels.parseThreadList(response.jsonBody as Map); + + client.channels.cache.addEntities(list.threads); + return list; + } + + /// List the bans in a guild. + Future> listBans(Snowflake id, {int? limit, Snowflake? after, Snowflake? before}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..bans(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, parseBan); + } + + /// Fetch a ban in a guild. + Future fetchBan(Snowflake id, Snowflake userId) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..bans(id: userId.toString()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseBan(response.jsonBody as Map); + } + + /// Create a ban in a guild. + Future createBan(Snowflake id, Snowflake userId, {Duration? deleteMessages, String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..bans(id: userId.toString()); + final request = BasicRequest( + route, + method: 'PUT', + auditLogReason: auditLogReason, + body: jsonEncode({ + if (deleteMessages != null) 'delete_message_seconds': deleteMessages.inSeconds, + }), + ); + + await client.httpHandler.executeSafe(request); + } + + /// Delete a ban in a guild. + Future deleteBan(Snowflake id, Snowflake userId, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..bans(id: userId.toString()); + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + } + + /// Update a guild's MFA level. + Future updateMfaLevel(Snowflake id, MfaLevel level, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..mfa(); + final request = BasicRequest( + route, + method: 'POST', + auditLogReason: auditLogReason, + body: jsonEncode({'level': level.value}), + ); + + final response = await client.httpHandler.executeSafe(request); + return MfaLevel.parse(response.jsonBody as int); + } + + /// Fetch the prune count in a guild. + Future fetchPruneCount(Snowflake id, {int? days, List? roleIds}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..prune(); + final request = BasicRequest(route, queryParameters: { + if (days != null) 'days': days.toString(), + if (roleIds != null) 'include_roles': roleIds.map((e) => e.toString()).join(','), + }); + + final response = await client.httpHandler.executeSafe(request); + return (response.jsonBody as Map)['pruned'] as int; + } + + /// Start a prune in a guild. + Future startGuildPrune( + Snowflake id, { + int? days, + bool? computeCount, + List? roleIds, + String? auditLogReason, + }) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..prune(); + final request = BasicRequest( + route, + method: 'POST', + auditLogReason: auditLogReason, + body: jsonEncode({ + if (days != null) 'days': days, + if (computeCount != null) 'compute_prune_count': computeCount, + if (roleIds != null) 'include_roles': roleIds.map((e) => e.toString()).toList(), + }), + ); + + final response = await client.httpHandler.executeSafe(request); + return (response.jsonBody as Map)['pruned'] as int?; + } + + /// List the voice regions in a guild. + Future> listVoiceRegions(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..regions(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, client.voice.parseVoiceRegion); + } + + /// List the invites in a guild. + Future> listInvites(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..invites(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, client.invites.parse); + } + + /// Fetch a guild's widget settings. + Future fetchWidgetSettings(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..widget(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseWidgetSettings(response.jsonBody as Map); + } + + /// Update a guild's widget settings. + Future updateWidgetSettings(Snowflake id, WidgetSettingsUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..widget(); + final request = BasicRequest(route, method: 'PATCH', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + return parseWidgetSettings(response.jsonBody as Map); + } + + /// Fetch a guild's widget. + Future fetchGuildWidget(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..widgetJson(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseGuildWidget(response.jsonBody as Map); + } + + /// Fetch a guild's vanity invite code. + Future fetchVanityCode(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..vanityUrl(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return (response.jsonBody as Map)['code'] as String?; + } + + /// Fetch the image for a guild's widget. + Future fetchGuildWidgetImage(Snowflake id, {WidgetImageStyle? style}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..widgetPng(); + final request = BasicRequest( + route, + authenticated: false, + queryParameters: {if (style != null) 'style': style.value}, + ); + + final response = await client.httpHandler.executeSafe(request); + return response.body; + } + + /// Fetch a guild's welcome screen. + Future fetchWelcomeScreen(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..welcomeScreen(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseWelcomeScreen(response.jsonBody as Map); + } + + /// Update a guild's welcome screen. + Future updateWelcomeScreen(Snowflake id, WelcomeScreenUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..welcomeScreen(); + final request = BasicRequest(route, method: 'PATCH', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + return parseWelcomeScreen(response.jsonBody as Map); + } + + /// Fetch a guild's onboarding. + Future fetchOnboarding(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..onboarding(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseOnboarding(response.jsonBody as Map); + } + + /// Update the current user's voice state in a guild. + Future updateCurrentUserVoiceState(Snowflake id, CurrentUserVoiceStateUpdateBuilder builder) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..voiceStates(id: '@me'); + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build())); + + await client.httpHandler.executeSafe(request); + } + + /// Update a member's voice state in a guild. + Future updateVoiceState(Snowflake id, Snowflake userId, VoiceStateUpdateBuilder builder) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..voiceStates(id: userId.toString()); + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build())); + + await client.httpHandler.executeSafe(request); + } + + /// Fetch a guild template by [code]. + Future fetchGuildTemplate(String code) async { + final route = HttpRoute() + ..guilds() + ..templates(code: code); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseGuildTemplate(response.jsonBody as Map); + } + + /// Create a guild from a guild template. + Future createGuildFromTemplate(String code, {required String name, ImageBuilder? icon}) async { + final route = HttpRoute() + ..guilds() + ..templates(code: code); + final request = BasicRequest(route, method: 'POST', body: jsonEncode({'name': name, if (icon != null) 'icon': icon.buildDataString()})); + + final response = await client.httpHandler.executeSafe(request); + final guild = parse(response.jsonBody as Map); + + cache[guild.id] = guild; + return guild; + } + + /// List the templates in a guild. + Future> listGuildTemplates(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..templates(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, parseGuildTemplate); + } + + /// Create a guild template from a guild. + Future createGuildTemplate(Snowflake id, GuildTemplateBuilder builder) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..templates(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + return parseGuildTemplate(response.jsonBody as Map); + } + + /// Sync a guild template to the source guild. + Future syncGuildTemplate(Snowflake id, String code) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..templates(code: code); + final request = BasicRequest(route, method: 'PUT'); + + final response = await client.httpHandler.executeSafe(request); + return parseGuildTemplate(response.jsonBody as Map); + } + + /// Update a guild template. + Future updateGuildTemplate(Snowflake id, String code, GuildTemplateUpdateBuilder builder) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..templates(code: code); + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + return parseGuildTemplate(response.jsonBody as Map); + } + + /// Delete a guild template. + Future deleteGuildTemplate(Snowflake id, String code) async { + final route = HttpRoute() + ..guilds(id: id.toString()) + ..templates(code: code); + final request = BasicRequest(route, method: 'DELETE'); + + final response = await client.httpHandler.executeSafe(request); + return parseGuildTemplate(response.jsonBody as Map); + } +} diff --git a/lib/src/http/managers/integration_manager.dart b/lib/src/http/managers/integration_manager.dart new file mode 100644 index 000000000..149ca6164 --- /dev/null +++ b/lib/src/http/managers/integration_manager.dart @@ -0,0 +1,98 @@ +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/guild/integration.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A [Manager] for [Integration]s. +class IntegrationManager extends ReadOnlyManager { + /// The ID of the guild this manager is for. + final Snowflake guildId; + + /// Create a new [IntegrationManager]. + IntegrationManager(super.config, super.client, {required this.guildId}) : super(identifier: '$guildId.integrations'); + + @override + PartialIntegration operator [](Snowflake id) => PartialIntegration(id: id, manager: this); + + @override + Integration parse(Map raw) { + return Integration( + id: Snowflake.parse(raw['id']!), + manager: this, + name: raw['name'] as String, + type: raw['type'] as String, + isEnabled: raw['enabled'] as bool, + isSyncing: raw['syncing'] as bool?, + roleId: maybeParse(raw['role_id'], Snowflake.parse), + enableEmoticons: raw['enable_emoticons'] as bool?, + expireBehavior: maybeParse(raw['expire_behavior'], IntegrationExpireBehavior.parse), + expireGracePeriod: maybeParse(raw['expire_grace_period'], (int value) => Duration(days: value)), + user: maybeParse(raw['user'], client.users.parse), + account: parseIntegrationAccount(raw['account'] as Map), + syncedAt: maybeParse(raw['synced_at'], DateTime.parse), + subscriberCount: raw['subscriber_count'] as int?, + isRevoked: raw['revoked'] as bool?, + application: maybeParse(raw['application'], parseIntegrationApplication), + scopes: maybeParseMany(raw['scopes']), + ); + } + + /// Parse an [IntegrationAccount] from [raw]. + IntegrationAccount parseIntegrationAccount(Map raw) { + return IntegrationAccount( + id: Snowflake.parse(raw['id']!), + name: raw['name'] as String, + ); + } + + /// Parse an [IntegrationApplication] from [raw]. + IntegrationApplication parseIntegrationApplication(Map raw) { + return IntegrationApplication( + id: Snowflake.parse(raw['id']!), + name: raw['name'] as String, + iconHash: raw['icon'] as String?, + description: raw['description'] as String, + bot: maybeParse(raw['bot'], client.users.parse), + ); + } + + @override + Future fetch(Snowflake id) async { + final integrations = await list(); + + return integrations.firstWhere( + (integration) => integration.id == id, + orElse: () => throw IntegrationNotFoundException(guildId, id), + ); + } + + /// List the integrations in the guild. + Future> list() async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..integrations(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final integrations = parseMany(response.jsonBody as List, parse); + + cache.addEntities(integrations); + return integrations; + } + + /// Delete an integration from the guild. + Future delete(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..integrations(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + + cache.remove(id); + } +} diff --git a/lib/src/http/managers/interaction_manager.dart b/lib/src/http/managers/interaction_manager.dart new file mode 100644 index 000000000..18d4fb2e0 --- /dev/null +++ b/lib/src/http/managers/interaction_manager.dart @@ -0,0 +1,400 @@ +import 'dart:convert'; + +import 'package:http/http.dart' hide MultipartRequest; +import 'package:nyxx/src/builders/interaction_response.dart'; +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/commands/application_command.dart'; +import 'package:nyxx/src/models/commands/application_command_option.dart'; +import 'package:nyxx/src/models/interaction.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/message/component.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A [Manager] for [Interaction]s. +class InteractionManager { + /// The client for this [InteractionManager]. + final NyxxRest client; + + /// The ID of the application for this [InteractionManager]. + final Snowflake applicationId; + + /// Create a new [InteractionManager]. + InteractionManager(this.client, {required this.applicationId}); + + Interaction parse(Map raw) { + final type = InteractionType.parse(raw['type'] as int); + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + final channelId = maybeParse(raw['channel_id'], Snowflake.parse); + final id = Snowflake.parse(raw['id']!); + final applicationId = Snowflake.parse(raw['application_id']!); + final channel = maybeParse(raw['channel'], (Map raw) => client.channels[Snowflake.parse(raw['id']!)]); + final member = maybeParse(raw['member'], client.guilds[guildId ?? Snowflake.zero].members.parse); + final user = maybeParse(raw['user'], client.users.parse); + final token = raw['token'] as String; + final version = raw['version'] as int; + final message = maybeParse(raw['message'], (client.channels[channelId ?? Snowflake.zero] as PartialTextChannel).messages.parse); + final appPermissions = maybeParse(raw['app_permissions'], (String raw) => Permissions(int.parse(raw))); + final locale = maybeParse(raw['locale'], Locale.parse); + final guildLocale = maybeParse(raw['guild_locale'], Locale.parse); + + return switch (type) { + InteractionType.ping => PingInteraction( + manager: this, + id: id, + applicationId: applicationId, + type: type, + guildId: guildId, + channel: channel, + channelId: channelId, + member: member, + user: user, + token: token, + version: version, + message: message, + appPermissions: appPermissions, + locale: locale, + guildLocale: guildLocale, + ), + InteractionType.applicationCommand => ApplicationCommandInteraction( + manager: this, + id: id, + applicationId: applicationId, + type: type, + data: parseApplicationCommandInteractionData(raw['data'] as Map, guildId: guildId, channelId: channelId), + guildId: guildId, + channel: channel, + channelId: channelId, + member: member, + user: user, + token: token, + version: version, + message: message, + appPermissions: appPermissions, + locale: locale, + guildLocale: guildLocale, + ), + InteractionType.messageComponent => MessageComponentInteraction( + manager: this, + id: id, + applicationId: applicationId, + type: type, + data: parseMessageComponentInteractionData(raw['data'] as Map), + guildId: guildId, + channel: channel, + channelId: channelId, + member: member, + user: user, + token: token, + version: version, + message: message, + appPermissions: appPermissions, + locale: locale, + guildLocale: guildLocale, + ), + InteractionType.modalSubmit => ModalSubmitInteraction( + manager: this, + id: id, + applicationId: applicationId, + type: type, + data: parseModalSubmitInteractionData(raw['data'] as Map), + guildId: guildId, + channel: channel, + channelId: channelId, + member: member, + user: user, + token: token, + version: version, + message: message, + appPermissions: appPermissions, + locale: locale, + guildLocale: guildLocale, + ), + InteractionType.applicationCommandAutocomplete => ApplicationCommandAutocompleteInteraction( + manager: this, + id: id, + applicationId: applicationId, + type: type, + data: parseApplicationCommandInteractionData(raw['data'] as Map, guildId: guildId, channelId: channelId), + guildId: guildId, + channel: channel, + channelId: channelId, + member: member, + user: user, + token: token, + version: version, + message: message, + appPermissions: appPermissions, + locale: locale, + guildLocale: guildLocale, + ), + } as Interaction; + } + + ApplicationCommandInteractionData parseApplicationCommandInteractionData(Map raw, {Snowflake? guildId, Snowflake? channelId}) { + return ApplicationCommandInteractionData( + id: Snowflake.parse(raw['id']!), + name: raw['name'] as String, + type: ApplicationCommandType.parse(raw['type'] as int), + resolved: maybeParse(raw['resolved'], (Map raw) => parseResolvedData(raw, guildId: guildId, channelId: channelId)), + options: maybeParseMany(raw['options'], parseInteractionOption), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + targetId: maybeParse(raw['target_id'], Snowflake.parse), + ); + } + + ResolvedData parseResolvedData(Map raw, {Snowflake? guildId, Snowflake? channelId}) { + return ResolvedData( + users: maybeParse( + raw['users'], + (Map raw) => raw.map((key, value) => MapEntry(Snowflake.parse(key), client.users.parse(value as Map))), + ), + members: maybeParse( + raw['members'], + (Map raw) => raw.map( + (key, value) => MapEntry( + Snowflake.parse(key), + client.guilds[guildId ?? Snowflake.zero].members.parse(value as Map, userId: Snowflake.parse(key)), + ), + ), + ), + roles: maybeParse( + raw['roles'], + (Map raw) => + raw.map((key, value) => MapEntry(Snowflake.parse(key), client.guilds[guildId ?? Snowflake.zero].roles.parse(value as Map))), + ), + channels: maybeParse( + raw['channels'], + (Map raw) => raw.map( + (key, value) => MapEntry( + Snowflake.parse(key), + PartialChannel(id: Snowflake.parse((value as Map)['id']!), manager: client.channels), + ), + ), + ), + messages: maybeParse( + raw['messages'], + (Map raw) => raw.map( + (key, value) => MapEntry( + Snowflake.parse(key), + PartialMessage( + id: Snowflake.parse((value as Map)['id']!), + manager: (client.channels[channelId ?? Snowflake.zero] as PartialTextChannel).messages), + ), + ), + ), + attachments: maybeParse( + raw['attachments'], + (Map raw) => raw.map( + (key, value) => MapEntry( + Snowflake.parse(key), + (client.channels[channelId ?? Snowflake.zero] as PartialTextChannel).messages.parseAttachment(value as Map), + ), + ), + ), + ); + } + + InteractionOption parseInteractionOption(Map raw) { + return InteractionOption( + name: raw['name'] as String, + type: CommandOptionType.parse(raw['type'] as int), + value: raw['value'], + options: maybeParseMany(raw['options'], parseInteractionOption), + isFocused: raw['focused'] as bool?, + ); + } + + MessageComponentInteractionData parseMessageComponentInteractionData(Map raw) { + return MessageComponentInteractionData( + customId: raw['custom_id'] as String, + type: MessageComponentType.parse(raw['component_type'] as int), + values: maybeParseMany(raw['values']), + ); + } + + ModalSubmitInteractionData parseModalSubmitInteractionData(Map raw) { + return ModalSubmitInteractionData( + customId: raw['custom_id'] as String, + components: parseMany(raw['components'] as List, (client.channels[Snowflake.zero] as PartialTextChannel).messages.parseMessageComponent), + ); + } + + /// Create a response to an interaction. + Future createResponse(Snowflake id, String token, InteractionResponseBuilder builder) async { + final route = HttpRoute() + ..interactions(id: id.toString(), token: token) + ..callback(); + + final HttpRequest request; + if (builder.data case MessageBuilder(:final attachments?) || MessageUpdateBuilder(:final attachments?) + when !identical(attachments, sentinelList) && attachments.isNotEmpty) { + final payload = builder.build(); + + final files = []; + for (int i = 0; i < attachments.length; i++) { + files.add(MultipartFile.fromBytes( + 'files[$i]', + attachments[i].data, + filename: attachments[i].fileName, + )); + + (((payload['data'] as Map)['attachments'] as List)[i] as Map)['id'] = i.toString(); + } + + request = MultipartRequest( + route, + method: 'POST', + jsonPayload: jsonEncode(payload), + files: files, + applyGlobalRateLimit: false, + ); + } else { + request = BasicRequest( + route, + method: 'POST', + body: jsonEncode(builder.build()), + applyGlobalRateLimit: false, + ); + } + + await client.httpHandler.executeSafe(request); + } + + /// Fetch an interaction's original response. + Future fetchOriginalResponse(String token) => _fetchResponse(token, '@original'); + + /// Update an interaction's original response. + Future updateOriginalResponse(String token, MessageUpdateBuilder builder) => _updateResponse(token, '@original', builder); + + /// Delete an interaction's original response. + Future deleteOriginalResponse(String token) => _deleteResponse(token, '@original'); + + /// Create a followup to an interaction. + Future createFollowup(String token, MessageBuilder builder, {bool? isEphemeral}) async { + final route = HttpRoute() + ..webhooks(id: applicationId.toString()) + ..add(HttpRoutePart(token)); + + final builtMessagePayload = builder.build(); + if (isEphemeral != null) { + builtMessagePayload['flags'] = (builtMessagePayload['flags'] as int? ?? 0) | (isEphemeral ? MessageFlags.ephemeral.value : 0); + } + + final HttpRequest request; + if (!identical(builder.attachments, sentinelList) && builder.attachments?.isNotEmpty == true) { + final attachments = builder.attachments!; + + final files = []; + for (int i = 0; i < attachments.length; i++) { + files.add(MultipartFile.fromBytes( + 'files[$i]', + attachments[i].data, + filename: attachments[i].fileName, + )); + + ((builtMessagePayload['attachments'] as List)[i] as Map)['id'] = i.toString(); + } + + request = MultipartRequest( + route, + method: 'POST', + jsonPayload: jsonEncode(builtMessagePayload), + files: files, + applyGlobalRateLimit: false, + ); + } else { + request = BasicRequest( + route, + method: 'POST', + body: jsonEncode(builtMessagePayload), + applyGlobalRateLimit: false, + ); + } + + final response = await client.httpHandler.executeSafe(request); + + final channelId = Snowflake.parse((response.jsonBody as Map)['channel_id']!); + return (client.channels[channelId] as PartialTextChannel).messages.parse(response.jsonBody as Map); + } + + /// Fetch a followup to an interaction. + Future fetchFollowup(String token, Snowflake messageId) => _fetchResponse(token, messageId.toString()); + + /// Update a followup to an interaction. + Future updateFollowup(String token, Snowflake messageId, MessageUpdateBuilder builder) => _updateResponse(token, messageId.toString(), builder); + + /// Delete a followup to an interaction. + Future deleteFollowup(String token, Snowflake messageId) => _deleteResponse(token, messageId.toString()); + + Future _fetchResponse(String token, String messageId) async { + final route = HttpRoute() + ..webhooks(id: applicationId.toString(), token: token) + ..messages(id: messageId); + final request = BasicRequest(route, applyGlobalRateLimit: false); + + final response = await client.httpHandler.executeSafe(request); + + final channelId = Snowflake.parse((response.jsonBody as Map)['channel_id']!); + return (client.channels[channelId] as PartialTextChannel).messages.parse(response.jsonBody as Map); + } + + Future _updateResponse(String token, String messageId, MessageUpdateBuilder builder) async { + final route = HttpRoute() + ..webhooks(id: applicationId.toString(), token: token) + ..messages(id: messageId.toString()); + + final HttpRequest request; + if (!identical(builder.attachments, sentinelList) && builder.attachments?.isNotEmpty == true) { + final attachments = builder.attachments!; + final payload = builder.build(); + + final files = []; + for (int i = 0; i < attachments.length; i++) { + files.add(MultipartFile.fromBytes( + 'files[$i]', + attachments[i].data, + filename: attachments[i].fileName, + )); + + ((payload['attachments'] as List)[i] as Map)['id'] = i.toString(); + } + + request = MultipartRequest( + route, + method: 'PATCH', + jsonPayload: jsonEncode(payload), + files: files, + applyGlobalRateLimit: false, + ); + } else { + request = BasicRequest( + route, + method: 'PATCH', + body: jsonEncode(builder.build()), + applyGlobalRateLimit: false, + ); + } + + final response = await client.httpHandler.executeSafe(request); + final channelId = Snowflake.parse((response.jsonBody as Map)['channel_id']!); + return (client.channels[channelId] as PartialTextChannel).messages.parse(response.jsonBody as Map); + } + + Future _deleteResponse(String token, String messageId) async { + final route = HttpRoute() + ..webhooks(id: applicationId.toString(), token: token) + ..messages(id: messageId); + final request = BasicRequest(route, method: 'DELETE', applyGlobalRateLimit: false); + + await client.httpHandler.executeSafe(request); + } +} diff --git a/lib/src/http/managers/invite_manager.dart b/lib/src/http/managers/invite_manager.dart new file mode 100644 index 000000000..ba577c726 --- /dev/null +++ b/lib/src/http/managers/invite_manager.dart @@ -0,0 +1,89 @@ +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [Invite]s. +class InviteManager { + /// The client this [InviteManager] is for. + final NyxxRest client; + + /// Create a new [InviteManager]. + InviteManager(this.client); + + /// Parse an [Invite] from [raw]. + Invite parse(Map raw) { + final guild = maybeParse( + raw['guild'], + (Map raw) => PartialGuild(id: Snowflake.parse(raw['id'] as String), manager: client.guilds), + ); + + return Invite( + code: raw['code'] as String, + guild: guild, + channel: PartialChannel(id: Snowflake.parse((raw['channel'] as Map)['id'] as String), manager: client.channels), + inviter: maybeParse(raw['inviter'], client.users.parse), + targetType: maybeParse(raw['target_type'], TargetType.parse), + targetUser: maybeParse(raw['target_user'], client.users.parse), + targetApplication: maybeParse( + raw['target_application'], + (Map raw) => PartialApplication(id: Snowflake.parse(raw['id'] as String), manager: client.applications), + ), + approximatePresenceCount: raw['approximate_presence_count'] as int?, + approximateMemberCount: raw['approximate_member_count'] as int?, + expiresAt: maybeParse(raw['expires_at'], DateTime.parse), + guildScheduledEvent: maybeParse(raw['guild_scheduled_event'], client.guilds[guild?.id ?? Snowflake.zero].scheduledEvents.parse), + ); + } + + InviteWithMetadata parseWithMetadata(Map raw) { + final invite = parse(raw); + + return InviteWithMetadata( + code: invite.code, + guild: invite.guild, + channel: invite.channel, + inviter: invite.inviter, + targetType: invite.targetType, + targetUser: invite.targetUser, + targetApplication: invite.targetApplication, + approximatePresenceCount: invite.approximatePresenceCount, + approximateMemberCount: invite.approximateMemberCount, + expiresAt: invite.expiresAt, + guildScheduledEvent: invite.guildScheduledEvent, + uses: raw['uses'] as int, + maxUses: raw['max_uses'] as int, + maxAge: Duration(seconds: raw['max_age'] as int), + isTemporary: raw['temporary'] as bool, + createdAt: DateTime.parse(raw['created_at'] as String), + ); + } + + /// Fetch an invite. + Future fetch(String code, {bool? withCounts, bool? withExpiration, Snowflake? scheduledEventId}) async { + final route = HttpRoute()..invites(id: code); + final request = BasicRequest(route, queryParameters: { + if (withCounts != null) 'with_counts': withCounts.toString(), + if (withExpiration != null) 'with_expiration': withExpiration.toString(), + if (scheduledEventId != null) 'guild_scheduled_event_id': scheduledEventId.toString(), + }); + + final response = await client.httpHandler.executeSafe(request); + return parse(response.jsonBody as Map); + } + + /// Delete an invite. + Future delete(String code) async { + final route = HttpRoute()..invites(id: code); + final request = BasicRequest(route, method: 'DELETE'); + + final response = await client.httpHandler.executeSafe(request); + return parse(response.jsonBody as Map); + } +} diff --git a/lib/src/http/managers/manager.dart b/lib/src/http/managers/manager.dart new file mode 100644 index 000000000..33cd534af --- /dev/null +++ b/lib/src/http/managers/manager.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; + +/// A [Manager] that provides only read access to the API. +abstract class ReadOnlyManager> { + /// The cache for this manager. + final Cache cache; + + /// The client this manager belongs to. + final NyxxRest client; + + /// Create a new read-only manager. + ReadOnlyManager(CacheConfig config, this.client, {required String identifier}) : cache = Cache(client, identifier, config); + + /// Parse the [raw] data received from the API into an instance of the type of this manager. + T parse(Map raw); + + /// Get an item by its [id] from the cache if it exists, else [fetch] it from the API. + Future get(Snowflake id) async => cache[id] ?? await fetch(id); + + /// Fetch the item with the given [id] from the API. + /// + /// {@macro ensure_cache_updated} + Future fetch(Snowflake id); + + /// Return a partial instance of the entity with ID [id] containing no data. + /// + /// This allows performing API operations without fetching an instance from the API. + /// + /// Because this method doesn't perform any API checks, there might be no real entity with the + /// correct [id]. In this case, the object returned may not work with the API correctly. + ManagedSnowflakeEntity operator [](Snowflake id); +} + +/// Provides the means to interact with the API for a given entity type. +/// +/// {@template manager} +/// Managers provide methods for creating objects ([create]), caching them ([cache] and [get]), +/// fetching them from the API ([fetch]), updating them ([update]) and deleting them ([delete]). +/// +/// [parse] can be used to convert a raw API response into an instance of the managed type. +/// +/// {@endtemplate} +abstract class Manager> extends ReadOnlyManager { + /// Create a new manager. + /// + /// {@macro manager} + Manager(super.config, super.client, {required super.identifier}); + + /// Create a new instance of the type of this manager. + /// + /// {@template ensure_cache_updated} + /// Implementers should ensure this method updates the [cache]. + /// {@endtemplate} + Future create(covariant CreateBuilder builder); + + /// Update the item with the given [id] in the API. + /// + /// {@macro ensure_cache_updated} + Future update(Snowflake id, covariant UpdateBuilder builder); + + /// Delete the item with the given [id] from the API. + /// + /// {@macro ensure_cache_updated} + Future delete(Snowflake id); + + @override + WritableSnowflakeEntity operator [](Snowflake id); +} diff --git a/lib/src/http/managers/member_manager.dart b/lib/src/http/managers/member_manager.dart new file mode 100644 index 000000000..c238e5ee0 --- /dev/null +++ b/lib/src/http/managers/member_manager.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; + +import 'package:nyxx/src/builders/guild/member.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [Member]s. +class MemberManager extends Manager { + /// The ID of the [Guild] this manager is for. + final Snowflake guildId; + + MemberManager(super.config, super.client, {required this.guildId}) : super(identifier: '$guildId.members'); + + @override + PartialMember operator [](Snowflake id) => PartialMember(id: id, manager: this); + + @override + Member parse(Map raw, {Snowflake? userId}) { + return Member( + id: userId ?? Snowflake.parse((raw['user'] as Map)['id']!), + manager: this, + user: maybeParse(raw['user'], client.users.parse), + nick: raw['nick'] as String?, + avatarHash: raw['avatar'] as String?, + roleIds: parseMany(raw['roles'] as List, Snowflake.parse), + joinedAt: DateTime.parse(raw['joined_at'] as String), + premiumSince: maybeParse(raw['premium_since'], DateTime.parse), + isDeaf: raw['deaf'] as bool?, + isMute: raw['mute'] as bool?, + flags: MemberFlags(raw['flags'] as int), + isPending: raw['pending'] as bool? ?? false, + permissions: maybeParse(raw['permissions'], (String raw) => Permissions(int.parse(raw))), + communicationDisabledUntil: maybeParse(raw['communication_disabled_until'], DateTime.parse), + ); + } + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members(id: id.toString()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final member = parse(response.jsonBody as Map); + + cache[member.id] = member; + return member; + } + + /// List the members in the guild. + Future> list({int? limit, Snowflake? after}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members(); + final request = BasicRequest(route, queryParameters: { + if (limit != null) 'limit': limit.toString(), + if (after != null) 'after': after.toString(), + }); + + final response = await client.httpHandler.executeSafe(request); + final members = parseMany(response.jsonBody as List, parse); + + cache.addEntities(members); + return members; + } + + @override + Future create(MemberBuilder builder) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members(id: builder.userId.toString()); + final request = BasicRequest(route, method: 'PUT', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + if (response.statusCode == 204) { + throw MemberAlreadyExistsException(guildId, builder.userId); + } + + final member = parse(response.jsonBody as Map); + + cache[member.id] = member; + return member; + } + + @override + Future update(Snowflake id, MemberUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members(id: id.toString()); + final request = BasicRequest(route, method: 'PATCH', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final member = parse(response.jsonBody as Map); + + cache[member.id] = member; + return member; + } + + @override + Future delete(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + cache.remove(id); + } + + /// Search for members whose username begins with [query]. + Future> search(String query, {int? limit}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members() + ..search(); + final request = BasicRequest(route, queryParameters: {'query': query, if (limit != null) 'limit': limit.toString()}); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, parse); + } + + /// Update the current member in the guild. + Future updateCurrentMember(CurrentMemberUpdateBuilder builder) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members(id: '@me'); + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final member = parse(response.jsonBody as Map); + + cache[member.id] = member; + return member; + } + + /// Add a role to a member in the guild. + Future addRole(Snowflake id, Snowflake roleId, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members(id: id.toString()) + ..roles(id: roleId.toString()); + final request = BasicRequest(route, method: 'PUT', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + } + + /// Remove a role from a member in the guild. + Future removeRole(Snowflake id, Snowflake roleId, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..members(id: id.toString()) + ..roles(id: roleId.toString()); + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + } +} diff --git a/lib/src/http/managers/message_manager.dart b/lib/src/http/managers/message_manager.dart new file mode 100644 index 000000000..3099bd190 --- /dev/null +++ b/lib/src/http/managers/message_manager.dart @@ -0,0 +1,551 @@ +import 'dart:convert'; + +import 'package:http/http.dart' show MultipartFile; +import 'package:nyxx/src/builders/emoji/reaction.dart'; +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/discord_color.dart'; +import 'package:nyxx/src/models/interaction.dart'; +import 'package:nyxx/src/models/message/activity.dart'; +import 'package:nyxx/src/models/message/attachment.dart'; +import 'package:nyxx/src/models/message/author.dart'; +import 'package:nyxx/src/models/message/channel_mention.dart'; +import 'package:nyxx/src/models/message/component.dart'; +import 'package:nyxx/src/models/message/embed.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/message/reaction.dart'; +import 'package:nyxx/src/models/message/reference.dart'; +import 'package:nyxx/src/models/message/role_subscription_data.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [Message]s in a [TextChannel]. +class MessageManager extends Manager { + /// The ID of the [TextChannel] this manager is attached to. + final Snowflake channelId; + + /// Create a new [MessageManager]. + MessageManager(super.config, super.client, {required this.channelId}) : super(identifier: '$channelId.messages'); + + @override + PartialMessage operator [](Snowflake id) => PartialMessage(id: id, manager: this); + + @override + Message parse(Map raw) { + final webhookId = maybeParse(raw['webhook_id'], Snowflake.parse); + + return Message( + id: Snowflake.parse(raw['id']!), + manager: this, + author: (webhookId == null + ? client.users.parse(raw['author'] as Map) + : client.webhooks.parseWebhookAuthor(raw['author'] as Map)) as MessageAuthor, + content: raw['content'] as String, + timestamp: DateTime.parse(raw['timestamp'] as String), + editedTimestamp: maybeParse(raw['edited_timestamp'], DateTime.parse), + isTts: raw['tts'] as bool, + mentionsEveryone: raw['mention_everyone'] as bool, + mentions: parseMany(raw['mentions'] as List, client.users.parse), + roleMentions: parseMany(raw['mention_roles'] as List, client.guilds[Snowflake.zero].roles.parse), + channelMentions: maybeParseMany(raw['mention_channels'], parseChannelMention) ?? [], + attachments: parseMany(raw['attachments'] as List, parseAttachment), + embeds: parseMany(raw['embeds'] as List, parseEmbed), + reactions: maybeParseMany(raw['reactions'], parseReaction) ?? [], + nonce: raw['nonce'] /* as int | String */, + isPinned: raw['pinned'] as bool, + webhookId: webhookId, + type: MessageType.parse(raw['type'] as int), + activity: maybeParse(raw['activity'], parseMessageActivity), + application: maybeParse( + raw['application'], + (Map raw) => PartialApplication(id: Snowflake.parse(raw['id'] as String), manager: client.applications), + ), + applicationId: maybeParse(raw['application_id'], Snowflake.parse), + reference: maybeParse(raw['message_reference'], parseMessageReference), + flags: MessageFlags(raw['flags'] as int? ?? 0), + referencedMessage: maybeParse(raw['referenced_message'], parse), + interaction: maybeParse( + raw['interaction'], + (Map raw) => parseMessageInteraction(raw), + ), + thread: maybeParse(raw['thread'], client.channels.parse) as Thread?, + components: maybeParseMany(raw['components'], parseMessageComponent), + position: raw['position'] as int?, + roleSubscriptionData: maybeParse(raw['role_subscription_data'], parseRoleSubscriptionData), + stickers: parseMany(raw['sticker_items'] as List? ?? [], client.stickers.parseStickerItem), + ); + } + + ChannelMention parseChannelMention(Map raw) { + return ChannelMention( + id: Snowflake.parse(raw['id']!), + manager: client.channels, + guildId: Snowflake.parse(raw['guild_id']!), + type: ChannelType.parse(raw['type'] as int), + name: raw['name'] as String, + ); + } + + Attachment parseAttachment(Map raw) { + return Attachment( + id: Snowflake.parse(raw['id']!), + fileName: raw['filename'] as String, + description: raw['description'] as String?, + contentType: raw['content_type'] as String?, + size: raw['size'] as int, + url: Uri.parse(raw['url'] as String), + proxiedUrl: Uri.parse(raw['proxy_url'] as String), + height: raw['height'] as int?, + width: raw['width'] as int?, + isEphemeral: raw['ephemeral'] as bool? ?? false, + ); + } + + Embed parseEmbed(Map raw) { + return Embed( + title: raw['title'] as String?, + description: raw['description'] as String?, + url: maybeParse(raw['url'], Uri.parse), + timestamp: maybeParse(raw['timestamp'], DateTime.parse), + color: maybeParse(raw['color'], DiscordColor.new), + footer: maybeParse(raw['footer'], parseEmbedFooter), + image: maybeParse(raw['image'], parseEmbedImage), + thumbnail: maybeParse(raw['thumbnail'], parseEmbedThumbnail), + video: maybeParse(raw['video'], parseEmbedVideo), + provider: maybeParse(raw['provider'], parseEmbedProvider), + author: maybeParse(raw['author'], parseEmbedAuthor), + fields: maybeParseMany(raw['fields'], parseEmbedField), + ); + } + + EmbedFooter parseEmbedFooter(Map raw) { + return EmbedFooter( + text: raw['text'] as String, + iconUrl: maybeParse(raw['icon_url'], Uri.parse), + proxiedIconUrl: maybeParse(raw['proxy_icon_url'], Uri.parse), + ); + } + + EmbedImage parseEmbedImage(Map raw) { + return EmbedImage( + url: Uri.parse(raw['url'] as String), + proxiedUrl: maybeParse(raw['proxy_url'], Uri.parse), + height: raw['height'] as int?, + width: raw['width'] as int?, + ); + } + + EmbedThumbnail parseEmbedThumbnail(Map raw) { + return EmbedThumbnail( + url: Uri.parse(raw['url'] as String), + proxiedUrl: maybeParse(raw['proxy_url'], Uri.parse), + height: raw['height'] as int?, + width: raw['width'] as int?, + ); + } + + EmbedVideo parseEmbedVideo(Map raw) { + return EmbedVideo( + url: Uri.parse(raw['url'] as String), + proxiedUrl: maybeParse(raw['proxy_url'], Uri.parse), + height: raw['height'] as int?, + width: raw['width'] as int?, + ); + } + + EmbedProvider parseEmbedProvider(Map raw) { + return EmbedProvider( + name: raw['name'] as String?, + url: maybeParse(raw['url'], Uri.parse), + ); + } + + EmbedAuthor parseEmbedAuthor(Map raw) { + return EmbedAuthor( + name: raw['name'] as String, + url: maybeParse(raw['url'], Uri.parse), + iconUrl: maybeParse(raw['icon_url'], Uri.parse), + proxyIconUrl: maybeParse(raw['proxy_icon_url'], Uri.parse), + ); + } + + EmbedField parseEmbedField(Map raw) { + return EmbedField( + name: raw['name'] as String, + value: raw['value'] as String, + inline: raw['inline'] as bool? ?? false, + ); + } + + Reaction parseReaction(Map raw) { + return Reaction( + count: raw['count'] as int, + me: raw['me'] as bool, + emoji: client.guilds[Snowflake.zero].emojis.parse(raw['emoji'] as Map), + ); + } + + MessageActivity parseMessageActivity(Map raw) { + return MessageActivity( + type: MessageActivityType.parse(raw['type'] as int), + partyId: raw['party_id'] as String?, + ); + } + + MessageReference parseMessageReference(Map raw) { + return MessageReference( + manager: this, + messageId: maybeParse(raw['message_id'], Snowflake.parse), + channelId: Snowflake.parse(raw['channel_id']!), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + ); + } + + RoleSubscriptionData parseRoleSubscriptionData(Map raw) { + return RoleSubscriptionData( + listingId: Snowflake.parse(raw['role_subscription_listing_id']!), + tierName: raw['tier_name'] as String, + totalMonthsSubscribed: raw['total_months_subscribed'] as int, + isRenewal: raw['is_renewal'] as bool, + ); + } + + MessageComponent parseMessageComponent(Map raw) { + final type = MessageComponentType.parse(raw['type'] as int); + + return switch (type) { + MessageComponentType.actionRow => ActionRowComponent( + components: parseMany(raw['components'] as List, parseMessageComponent), + ), + MessageComponentType.button => ButtonComponent( + style: ButtonStyle.parse(raw['style'] as int), + label: raw['label'] as String, + emoji: maybeParse(raw['emoji'], client.guilds[Snowflake.zero].emojis.parse), + customId: raw['custom_id'] as String?, + url: maybeParse(raw['url'], Uri.parse), + isDisabled: raw['disabled'] as bool?, + ), + MessageComponentType.textInput => TextInputComponent( + customId: raw['custom_id'] as String, + style: TextInputStyle.parse(raw['style'] as int), + label: raw['label'] as String, + minLength: raw['min_length'] as int?, + maxLength: raw['max_length'] as int?, + isRequired: raw['required'] as bool?, + value: raw['value'] as String?, + placeholder: raw['placeholder'] as String?, + ), + MessageComponentType.stringSelect || + MessageComponentType.userSelect || + MessageComponentType.roleSelect || + MessageComponentType.mentionableSelect || + MessageComponentType.channelSelect => + SelectMenuComponent( + type: type, + customId: raw['custom_id'] as String, + options: maybeParseMany(raw['options'], parseSelectMenuOption), + channelTypes: maybeParseMany(raw['channel_types'], ChannelType.parse), + placeholder: raw['placeholder'] as String?, + minValues: raw['min_values'] as int?, + maxValues: raw['max_values'] as int?, + isDisabled: raw['disabled'] as bool?, + ), + }; + } + + SelectMenuOption parseSelectMenuOption(Map raw) { + return SelectMenuOption( + label: raw['label'] as String, + value: raw['value'] as String, + description: raw['description'] as String?, + emoji: maybeParse(raw['emoji'], client.guilds[Snowflake.zero].emojis.parse), + isDefault: raw['default'] as bool?, + ); + } + + MessageInteraction parseMessageInteraction(Map raw) { + final user = client.users.parse(raw['user'] as Map); + + return MessageInteraction( + id: Snowflake.parse(raw['id']!), + type: InteractionType.parse(raw['type'] as int), + name: raw['name'] as String, + user: user, + // TODO: Find a way to get the guild ID. + member: maybeParse( + raw['member'], + (Map raw) => client.guilds[Snowflake.zero].members[user.id], + ), + ); + } + + @override + Future create(MessageBuilder builder) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(); + + final HttpRequest request; + if (!identical(builder.attachments, sentinelList) && builder.attachments?.isNotEmpty == true) { + final attachments = builder.attachments!; + final payload = builder.build(); + + final files = []; + for (int i = 0; i < attachments.length; i++) { + files.add(MultipartFile.fromBytes( + 'files[$i]', + attachments[i].data, + filename: attachments[i].fileName, + )); + + ((payload['attachments'] as List)[i] as Map)['id'] = i.toString(); + } + + request = MultipartRequest( + route, + method: 'POST', + jsonPayload: jsonEncode(payload), + files: files, + ); + } else { + request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + } + + final response = await client.httpHandler.executeSafe(request); + final message = parse(response.jsonBody as Map); + + cache[message.id] = message; + return message; + } + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final message = parse(response.jsonBody as Map); + + cache[message.id] = message; + return message; + } + + @override + Future update(Snowflake id, MessageUpdateBuilder builder) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()); + + final HttpRequest request; + if (!identical(builder.attachments, sentinelList) && builder.attachments?.isNotEmpty == true) { + final attachments = builder.attachments!; + final payload = builder.build(); + + final files = []; + for (int i = 0; i < attachments.length; i++) { + files.add(MultipartFile.fromBytes( + 'files[$i]', + attachments[i].data, + filename: attachments[i].fileName, + )); + + ((payload['attachments'] as List)[i] as Map)['id'] = i.toString(); + } + + request = MultipartRequest( + route, + method: 'PATCH', + jsonPayload: jsonEncode(payload), + files: files, + ); + } else { + request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build())); + } + + final response = await client.httpHandler.executeSafe(request); + final message = parse(response.jsonBody as Map); + + cache[message.id] = message; + return message; + } + + @override + Future delete(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + + cache.remove(id); + } + + /// Fetch multiple messages in this channel. + Future> fetchMany({Snowflake? around, Snowflake? before, Snowflake? after, int? limit}) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(); + final request = BasicRequest( + route, + queryParameters: { + if (around != null) 'around': around.toString(), + if (before != null) 'before': before.toString(), + if (after != null) 'after': after.toString(), + if (limit != null) 'limit': limit.toString(), + }, + ); + + final response = await client.httpHandler.executeSafe(request); + final messages = parseMany(response.jsonBody as List, parse); + + cache.addEntities(messages); + return messages; + } + + /// Crosspost a message to all channels following the channel it was sent in. + Future crosspost(Snowflake id) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()) + ..crosspost(); + final request = BasicRequest(route, method: 'POST'); + + final response = await client.httpHandler.executeSafe(request); + final message = parse(response.jsonBody as Map); + + cache[message.id] = message; + return message; + } + + /// Bulk delete many messages at once + /// + /// This will throw an error if any of [ids] is not a valid message ID or if any of the messages are from before [Snowflake.bulkDeleteLimit]. + Future bulkDelete(Iterable ids) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages() + ..bulkDelete(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode(ids.map((e) => e.toString()).toList())); + + await client.httpHandler.executeSafe(request); + } + + /// Get the pinned messages in the channel. + Future> getPins() async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..pins(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final messages = parseMany(response.jsonBody as List, parse); + + cache.addEntities(messages); + return messages; + } + + /// Pin a message in the channel. + Future pin(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..pins(id: id.toString()); + final request = BasicRequest(route, method: 'PUT', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + } + + /// Unpin a message in the channel. + Future unpin(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..pins(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + } + + /// Adds a reaction to a message. + Future addReaction(Snowflake id, ReactionBuilder emoji) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()) + ..reactions(emoji: emoji.build(), userId: '@me'); + + final request = BasicRequest(route, method: 'PUT'); + + await client.httpHandler.executeSafe(request); + } + + /// Deletes our own reaction from a message. + Future deleteOwnReaction(Snowflake id, ReactionBuilder emoji) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()) + ..reactions(emoji: emoji.build(), userId: '@me'); + + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + } + + /// Deletes all reactions on a message. + Future deleteAllReactions(Snowflake id) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()) + ..reactions(); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + } + + /// Deletes all reactions for a given emoji on a message. + Future deleteReactionForUser(Snowflake id, Snowflake userId, ReactionBuilder emoji) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()) + ..reactions(emoji: emoji.build(), userId: userId.toString()); + + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + } + + /// Deletes all reactions for a given emoji on a message. + Future deleteReaction(Snowflake id, ReactionBuilder emoji) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()) + ..reactions(emoji: emoji.build()); + + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + } + + /// Get a list of users that reacted with a given emoji on a message. + Future> fetchReactions(Snowflake id, ReactionBuilder emoji) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..messages(id: id.toString()) + ..reactions(emoji: emoji.build()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + + return parseMany(response.jsonBody as List, client.users.parse); + } + + // TODO once oauth2 is implemented: Group DM control endpoints +} diff --git a/lib/src/http/managers/role_manager.dart b/lib/src/http/managers/role_manager.dart new file mode 100644 index 000000000..67fed3611 --- /dev/null +++ b/lib/src/http/managers/role_manager.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:nyxx/src/builders/role.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/discord_color.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [Role]s. +class RoleManager extends Manager { + /// The ID of the guild this manager is for. + final Snowflake guildId; + + /// Create a new [RoleManager]. + RoleManager(super.config, super.client, {required this.guildId}) : super(identifier: '$guildId.roles'); + + @override + PartialRole operator [](Snowflake id) => PartialRole(id: id, manager: this); + + @override + Role parse(Map raw) { + return Role( + id: Snowflake.parse(raw['id']!), + manager: this, + name: raw['name'] as String, + color: DiscordColor(raw['color'] as int), + isHoisted: raw['hoist'] as bool, + iconHash: raw['icon'] as String?, + unicodeEmoji: raw['unicode_emoji'] as String?, + position: raw['position'] as int, + permissions: Permissions(int.parse(raw['permissions'] as String)), + isMentionable: raw['mentionable'] as bool, + tags: maybeParse(raw['tags'], parseRoleTags), + ); + } + + /// Parse [RoleTags] from [raw]. + RoleTags parseRoleTags(Map raw) { + return RoleTags( + botId: maybeParse(raw['bot_id'], Snowflake.parse), + integrationId: maybeParse(raw['integration_id'], Snowflake.parse), + subscriptionListingId: maybeParse(raw['subscription_listing_id'], Snowflake.parse), + ); + } + + /// List the roles in this guild. + Future> list() async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..roles(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final roles = parseMany(response.jsonBody as List, parse); + + cache.addEntities(roles); + return roles; + } + + @override + Future fetch(Snowflake id) async { + // There isn't an endpoint to fetch a single role. Re-fetch all the roles to ensure they are up to date, + // and return the matching role. + final roles = await list(); + + return roles.firstWhere( + (role) => role.id == id, + orElse: () => throw RoleNotFoundException(guildId, id), + ); + } + + @override + Future create(RoleBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..roles(); + final request = BasicRequest(route, method: 'POST', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final role = parse(response.jsonBody as Map); + + cache[role.id] = role; + return role; + } + + @override + Future update(Snowflake id, RoleUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..roles(id: id.toString()); + final request = BasicRequest(route, method: 'PATCH', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final role = parse(response.jsonBody as Map); + + cache[role.id] = role; + return role; + } + + @override + Future delete(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..roles(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + cache.remove(id); + } + + /// Update the positions of the roles in this guild. + Future> updatePositions(Map positions, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..roles(); + final request = BasicRequest( + route, + method: 'PATCH', + auditLogReason: auditLogReason, + body: jsonEncode(positions.entries.map((e) => {'id': e.key.toString(), 'position': e.value}).toList()), + ); + + final response = await client.httpHandler.executeSafe(request); + final roles = parseMany(response.jsonBody as List, parse); + + cache.addEntities(roles); + return roles; + } +} diff --git a/lib/src/http/managers/scheduled_event_manager.dart b/lib/src/http/managers/scheduled_event_manager.dart new file mode 100644 index 000000000..bf638e862 --- /dev/null +++ b/lib/src/http/managers/scheduled_event_manager.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; + +import 'package:nyxx/src/builders/guild/scheduled_event.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/channel/stage_instance.dart'; +import 'package:nyxx/src/models/guild/scheduled_event.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A [Manager] for [ScheduledEvent]s. +class ScheduledEventManager extends Manager { + final Snowflake guildId; + + /// Create a new [ScheduledEventManager]. + ScheduledEventManager(super.config, super.client, {required this.guildId}) : super(identifier: '$guildId.scheduledEvents'); + + @override + PartialScheduledEvent operator [](Snowflake id) => PartialScheduledEvent(id: id, manager: this); + + @override + ScheduledEvent parse(Map raw) { + return ScheduledEvent( + id: Snowflake.parse(raw['id']!), + manager: this, + guildId: Snowflake.parse(raw['guild_id']!), + channelId: Snowflake.parse(raw['channel_id']!), + creatorId: maybeParse(raw['creator_id'], Snowflake.parse), + name: raw['name'] as String, + description: raw['description'] as String?, + scheduledStartTime: DateTime.parse(raw['scheduled_start_time'] as String), + scheduledEndTime: maybeParse(raw['scheduled_end_time'], DateTime.parse), + privacyLevel: PrivacyLevel.parse(raw['privacy_level'] as int), + status: EventStatus.parse(raw['status'] as int), + type: ScheduledEntityType.parse(raw['entity_type'] as int), + entityId: maybeParse(raw['entity_id'], Snowflake.parse), + metadata: maybeParse(raw['entity_metadata'], parseEntityMetadata), + creator: maybeParse(raw['creator'], client.users.parse), + userCount: raw['user_count'] as int?, + coverImageHash: raw['image'] as String?, + ); + } + + EntityMetadata parseEntityMetadata(Map raw) { + return EntityMetadata( + location: raw['location'] as String?, + ); + } + + ScheduledEventUser parseScheduledEventUser(Map raw) { + return ScheduledEventUser( + manager: this, + scheduledEventId: Snowflake.parse(raw['guild_scheduled_event_id']!), + user: client.users.parse(raw['user'] as Map), + member: maybeParse(raw['member'], client.guilds[guildId].members.parse), + ); + } + + @override + Future fetch(Snowflake id, {bool? withUserCount}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..scheduledEvents(id: id.toString()); + final request = BasicRequest(route, queryParameters: {if (withUserCount != null) 'with_user_count': withUserCount.toString()}); + + final response = await client.httpHandler.executeSafe(request); + final event = parse(response.jsonBody as Map); + + cache[event.id] = event; + return event; + } + + /// List the [ScheduledEvent]s in the guild. + Future> list({bool? withUserCounts}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..scheduledEvents(); + final request = BasicRequest(route, queryParameters: {if (withUserCounts != null) 'with_user_count': withUserCounts.toString()}); + + final response = await client.httpHandler.executeSafe(request); + final events = parseMany(response.jsonBody as List, parse); + + cache.addEntities(events); + return events; + } + + @override + Future create(ScheduledEventBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..scheduledEvents(); + final request = BasicRequest(route, method: 'POST', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final event = parse(response.jsonBody as Map); + + cache[event.id] = event; + return event; + } + + @override + Future update(Snowflake id, ScheduledEventUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..scheduledEvents(id: id.toString()); + final request = BasicRequest(route, method: 'PATCH', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final event = parse(response.jsonBody as Map); + + cache[event.id] = event; + return event; + } + + @override + Future delete(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..scheduledEvents(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + + cache.remove(id); + } + + /// List the users that have followed an event. + Future> listEventUsers(Snowflake id, {int? limit, bool? withMembers, Snowflake? before, Snowflake? after}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..scheduledEvents(id: id.toString()) + ..users(); + final request = BasicRequest(route, queryParameters: { + if (limit != null) 'limit': limit.toString(), + if (withMembers != null) 'with_member': withMembers.toString(), + if (before != null) 'before': before.toString(), + if (after != null) 'after': after.toString(), + }); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, parseScheduledEventUser); + } +} diff --git a/lib/src/http/managers/sticker_manager.dart b/lib/src/http/managers/sticker_manager.dart new file mode 100644 index 000000000..34480b895 --- /dev/null +++ b/lib/src/http/managers/sticker_manager.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:nyxx/src/builders/sticker.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/sticker/global_sticker.dart'; +import 'package:nyxx/src/models/sticker/guild_sticker.dart'; +import 'package:nyxx/src/models/sticker/sticker.dart'; +import 'package:nyxx/src/models/sticker/sticker_pack.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +class GuildStickerManager extends Manager { + final Snowflake guildId; + + GuildStickerManager(super.config, super.client, {required this.guildId}) : super(identifier: "$guildId.stickers"); + + @override + PartialGuildSticker operator [](Snowflake id) => PartialGuildSticker(id: id, manager: this); + + @override + Future create(StickerBuilder builder) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..stickers(); + + final request = FormDataRequest(route, + method: 'POST', formParams: builder.build().cast(), files: [MultipartFile.fromBytes('file', builder.file.buildRawData())]); + final response = await client.httpHandler.executeSafe(request); + + final sticker = parse(response.jsonBody as Map); + cache[sticker.id] = sticker; + + return sticker; + } + + Future> list() async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..stickers(); + + final request = BasicRequest(route); + final response = await client.httpHandler.executeSafe(request); + + final stickers = parseMany(response.jsonBody as List, (Map raw) => parse(raw)); + cache.addEntities(stickers); + + return stickers; + } + + @override + Future delete(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..stickers(id: id.toString()); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + cache.remove(id); + } + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..stickers(id: id.toString()); + + final request = BasicRequest(route); + final response = await client.httpHandler.executeSafe(request); + + final sticker = parse(response.jsonBody as Map); + + cache[sticker.id] = sticker; + return sticker; + } + + @override + GuildSticker parse(Map raw) { + return GuildSticker( + manager: this, + id: Snowflake.parse(raw['id'] as String), + name: raw['name'] as String, + description: raw['description'] as String?, + tags: raw['tags'] as String, + type: StickerType.parse(raw['type'] as int), + formatType: StickerFormatType.parse(raw['format_type'] as int), + available: raw['available'] as bool? ?? false, + guildId: Snowflake.parse(raw['guild_id'] as String), + user: ((raw['user'] ?? {}) as Map)['id'] != null ? client.users[Snowflake.parse((raw['user'] as Map)['id'] as String)] : null, + sortValue: raw['sort_value'] as int?, + ); + } + + @override + Future update(Snowflake id, StickerUpdateBuilder builder) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..stickers(id: id.toString()); + + final request = BasicRequest(route, body: jsonEncode(builder.build()), method: 'PATCH'); + final response = await client.httpHandler.executeSafe(request); + + final sticker = parse(response.jsonBody as Map); + + cache[sticker.id] = sticker; + return sticker; + } +} + +class GlobalStickerManager extends ReadOnlyManager { + GlobalStickerManager(super.config, super.client) : super(identifier: 'stickers'); + + @override + PartialGlobalSticker operator [](Snowflake id) => PartialGlobalSticker(id: id, manager: this); + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute()..stickers(id: id.toString()); + + final request = BasicRequest(route); + final response = await client.httpHandler.executeSafe(request); + + final sticker = parse(response.jsonBody as Map); + + cache[sticker.id] = sticker; + return sticker; + } + + Future fetchStickerPack(Snowflake id) async { + final route = HttpRoute()..stickerPacks(id: id.toString()); + + final request = BasicRequest(route); + final response = (await client.httpHandler.executeSafe(request)).jsonBody as Map; + + return parseStickerPack(response); + } + + Future> fetchNitroStickerPacks() async { + final route = HttpRoute()..stickerPacks(); + + final request = BasicRequest(route); + final response = (await client.httpHandler.executeSafe(request)).jsonBody as Map; + + return (response['sticker_packs'] as List).map((e) => parseStickerPack(e as Map)).toList(); + } + + StickerItem parseStickerItem(Map raw) { + return StickerItem( + id: Snowflake.parse(raw['id'] as String), + name: raw['name'] as String, + formatType: StickerFormatType.parse(raw['format_type'] as int), + ); + } + + StickerPack parseStickerPack(Map raw) { + return StickerPack( + id: Snowflake.parse(raw['id'] as String), + manager: this, + stickers: (raw['stickers'] as List).map((e) => parse(e as Map)).toList(), + name: raw['name'] as String, + skuId: Snowflake.parse(raw['sku_id'] as String), + coverStickerId: raw['cover_sticker_id'] != null ? Snowflake.parse(raw['cover_sticker_id'] as String) : null, + description: raw['description'] as String, + bannerAssetId: raw['banner_asset_id'] != null ? Snowflake.parse(raw['banner_asset_id'] as String) : null, + ); + } + + @override + GlobalSticker parse(Map raw) { + return GlobalSticker( + manager: this, + id: Snowflake.parse(raw['id'] as String), + packId: Snowflake.parse(raw['pack_id'] as String), + name: raw['name'] as String, + description: raw['description'] as String?, + tags: raw['tags'] as String, + type: StickerType.parse(raw['type'] as int), + formatType: StickerFormatType.parse(raw['format_type'] as int), + available: raw['available'] as bool? ?? false, + user: ((raw['user'] ?? {}) as Map)['id'] != null ? client.users[Snowflake.parse((raw['user'] as Map)['id'] as String)] : null, + sortValue: raw['sort_value'] as int?, + ); + } +} diff --git a/lib/src/http/managers/user_manager.dart b/lib/src/http/managers/user_manager.dart new file mode 100644 index 000000000..d23138df6 --- /dev/null +++ b/lib/src/http/managers/user_manager.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:nyxx/src/builders/application_role_connection.dart'; +import 'package:nyxx/src/builders/user.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/channel/types/dm.dart'; +import 'package:nyxx/src/models/channel/types/group_dm.dart'; +import 'package:nyxx/src/models/discord_color.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/integration.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/application_role_connection.dart'; +import 'package:nyxx/src/models/user/connection.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [User]s. +class UserManager extends ReadOnlyManager { + /// Create a new [UserManager]. + UserManager(super.config, super.client) : super(identifier: 'users'); + + @override + PartialUser operator [](Snowflake id) => PartialUser(id: id, manager: this); + + @override + User parse(Map raw) { + final hasAccentColor = raw['accent_color'] != null; + final hasLocale = raw['locale'] != null; + final hasFlags = raw['flags'] != null; + final hasPremiumType = raw['premium_type'] != null; + final hasPublicFlags = raw['public_flags'] != null; + + return User( + manager: this, + id: Snowflake.parse(raw['id']!), + username: raw['username'] as String, + discriminator: raw['discriminator'] as String, + globalName: raw['global_name'] as String?, + avatarHash: raw['avatar'] as String?, + isBot: raw['bot'] as bool? ?? false, + isSystem: raw['system'] as bool? ?? false, + hasMfaEnabled: raw['mfa_enabled'] as bool? ?? false, + bannerHash: raw['banner'] as String?, + accentColor: hasAccentColor ? DiscordColor(raw['accent_color'] as int) : null, + locale: hasLocale ? Locale.parse(raw['locale'] as String) : null, + flags: hasFlags ? UserFlags(raw['flags'] as int) : null, + nitroType: hasPremiumType ? NitroType.parse(raw['premium_type'] as int) : NitroType.none, + publicFlags: hasPublicFlags ? UserFlags(raw['public_flags'] as int) : null, + ); + } + + Connection parseConnection(Map raw) { + return Connection( + id: raw['id'] as String, + name: raw['name'] as String, + type: ConnectionType.parse(raw['type'] as String), + isRevoked: raw['revoked'] as bool?, + integrations: maybeParseMany( + raw['integrations'], + (Map raw) => PartialIntegration( + id: Snowflake.parse(raw['id'] as String), + // TODO: Can we know what guild the integrations are from? + manager: client.guilds[Snowflake.zero].integrations, + ), + ), + isVerified: raw['verified'] as bool, + isFriendSyncEnabled: raw['friend_sync'] as bool, + showActivity: raw['show_activity'] as bool, + isTwoWayLink: raw['two_way_link'] as bool, + visibility: ConnectionVisibility.parse(raw['visibility'] as int), + ); + } + + ApplicationRoleConnection parseApplicationRoleConnection(Map raw) { + return ApplicationRoleConnection( + platformName: raw['platform_name'] as String?, + platformUsername: raw['platform_username'] as String?, + metadata: (raw['metadata'] as Map).cast(), + ); + } + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute()..users(id: id.toString()); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final user = parse(response.jsonBody as Map); + + cache[user.id] = user; + return user; + } + + /// Fetch the current user from the API. + Future fetchCurrentUser() async { + final route = HttpRoute()..users(id: '@me'); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final user = parse(response.jsonBody as Map); + + cache[user.id] = user; + return user; + } + + /// Update the current user. + Future updateCurrentUser(UserUpdateBuilder builder) async { + final route = HttpRoute()..users(id: '@me'); + final request = BasicRequest( + route, + method: 'PATCH', + body: jsonEncode(builder.build()), + ); + + final response = await client.httpHandler.executeSafe(request); + final user = parse(response.jsonBody as Map); + + cache[user.id] = user; + return user; + } + + /// List the guilds the current user is a member of. + Future> listCurrentUserGuilds({Snowflake? after, Snowflake? before, int? limit}) async { + final route = HttpRoute() + ..users(id: '@me') + ..guilds(); + final request = BasicRequest(route, queryParameters: { + if (before != null) 'before': before.toString(), + if (after != null) 'after': after.toString(), + if (limit != null) 'limit': limit.toString(), + }); + + final response = await client.httpHandler.executeSafe(request); + return parseMany( + response.jsonBody as List, + (Map raw) => PartialGuild(id: Snowflake.parse(raw['id'] as String), manager: client.guilds), + ); + } + + /// Fetch the current user's member for a guild. + Future fetchCurrentUserMember(Snowflake guildId) async { + final route = HttpRoute() + ..users(id: '@me') + ..guilds(id: guildId.toString()) + ..member(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return client.guilds[guildId].members.parse(response.jsonBody as Map); + } + + /// Leave a guild. + Future leaveGuild(Snowflake guildId) async { + final route = HttpRoute() + ..users(id: '@me') + ..guilds(id: guildId.toString()); + final request = BasicRequest(route, method: 'DELETE'); + + await client.httpHandler.executeSafe(request); + } + + /// Create a DM channel with another user. + Future createDm(Snowflake recipientId) async { + final route = HttpRoute() + ..users(id: '@me') + ..channels(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode({'recipient_id': recipientId.toString()})); + + final response = await client.httpHandler.executeSafe(request); + final channel = client.channels.parse(response.jsonBody as Map) as DmChannel; + + client.channels.cache[channel.id] = channel; + return channel; + } + + /// Create a DM channel with multiple other users. + Future createGroupDm(List tokens, Map nicks) async { + final route = HttpRoute() + ..users(id: '@me') + ..channels(); + final request = BasicRequest( + route, + method: 'POST', + body: jsonEncode({ + 'access_tokens': tokens, + 'nicks': { + for (final entry in nicks.entries) entry.key.toString(): entry.value, + } + }), + ); + + final response = await client.httpHandler.executeSafe(request); + final channel = client.channels.parse(response.jsonBody as Map) as GroupDmChannel; + + client.channels.cache[channel.id] = channel; + return channel; + } + + /// Fetch the current user's connections. + Future> fetchCurrentUserConnections() async { + final route = HttpRoute() + ..users(id: '@me') + ..connections(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final rawObjects = response.jsonBody as List; + + return List.generate( + rawObjects.length, + (index) => parseConnection(rawObjects[index] as Map), + ); + } + + /// Fetch the current user's application role connection for an application. + Future fetchCurrentUserApplicationRoleConnection(Snowflake applicationId) async { + final route = HttpRoute() + ..users(id: '@me') + ..applications(id: applicationId.toString()) + ..roleConnection(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseApplicationRoleConnection(response.jsonBody as Map); + } + + /// Update the current user's application role connection for an application. + Future updateCurrentUserApplicationRoleConnection(Snowflake applicationId, ApplicationRoleConnectionUpdateBuilder builder) async { + final route = HttpRoute() + ..users(id: '@me') + ..applications(id: applicationId.toString()) + ..roleConnection(); + final request = BasicRequest(route, method: 'PUT', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + return parseApplicationRoleConnection(response.jsonBody as Map); + } +} diff --git a/lib/src/http/managers/voice_manager.dart b/lib/src/http/managers/voice_manager.dart new file mode 100644 index 000000000..4ac7a3107 --- /dev/null +++ b/lib/src/http/managers/voice_manager.dart @@ -0,0 +1,64 @@ +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/voice/voice_region.dart'; +import 'package:nyxx/src/models/voice/voice_state.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [VoiceState]s. +class VoiceManager { + /// The client this manager belongs to. + final NyxxRest client; + + /// The cache for voice states. + final Cache cache; + + /// Create a new [VoiceManager]. + VoiceManager(this.client) : cache = Cache(client, 'voiceStates', client.options.voiceStateConfig); + + /// Parse a [VoiceState] from a [Map]. + VoiceState parseVoiceState(Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + + return VoiceState( + manager: this, + guildId: guildId, + channelId: maybeParse(raw['channel_id'], Snowflake.parse), + userId: Snowflake.parse(raw['user_id'] as String), + member: maybeParse(raw['member'], client.guilds[guildId ?? Snowflake.zero].members.parse), + sessionId: raw['session_id'] as String, + isServerDeafened: raw['deaf'] as bool, + isServerMuted: raw['mute'] as bool, + isSelfDeafened: raw['self_deaf'] as bool, + isSelfMuted: raw['self_mute'] as bool, + isStreaming: raw['self_stream'] as bool? ?? false, + isVideoEnabled: raw['self_video'] as bool, + isSuppressed: raw['suppress'] as bool, + requestedToSpeakAt: maybeParse(raw['request_to_speak_timestamp'], DateTime.parse), + ); + } + + /// Parse a [VoiceRegion] from a [Map]. + VoiceRegion parseVoiceRegion(Map raw) { + return VoiceRegion( + id: raw['id'] as String, + name: raw['name'] as String, + isOptimal: raw['optimal'] as bool, + isDeprecated: raw['deprecated'] as bool, + isCustom: raw['custom'] as bool, + ); + } + + /// List all the available voice regions. + Future> listRegions() async { + final route = HttpRoute() + ..voice() + ..regions(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + return parseMany(response.jsonBody as List, parseVoiceRegion); + } +} diff --git a/lib/src/http/managers/webhook_manager.dart b/lib/src/http/managers/webhook_manager.dart new file mode 100644 index 000000000..491e9fbea --- /dev/null +++ b/lib/src/http/managers/webhook_manager.dart @@ -0,0 +1,300 @@ +import 'dart:convert'; + +import 'package:http/http.dart' hide MultipartRequest; + +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/builders/webhook.dart'; +import 'package:nyxx/src/cache/cache.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +/// A manager for [Webhook]s. +class WebhookManager extends Manager { + /// Create a new [WebhookManager]. + WebhookManager(super.config, super.client) : super(identifier: 'webhooks'); + + @override + PartialWebhook operator [](Snowflake id) => PartialWebhook(id: id, manager: this); + + @override + Webhook parse(Map raw) { + return Webhook( + id: Snowflake.parse(raw['id']!), + manager: this, + type: WebhookType.parse(raw['type'] as int), + guildId: maybeParse(raw['guild_id'], Snowflake.parse), + channelId: maybeParse(raw['channel_id'], Snowflake.parse), + user: maybeParse(raw['user'], client.users.parse), + name: raw['name'] as String?, + avatarHash: raw['avatar'] as String?, + token: raw['token'] as String?, + applicationId: maybeParse(raw['application_id'], Snowflake.parse), + sourceGuild: maybeParse( + raw['source_guild'], + (Map raw) => PartialGuild( + id: Snowflake.parse(raw['id']!), + manager: client.guilds, + ), + ), + sourceChannel: maybeParse( + raw['source_channel'], + (Map raw) => PartialChannel( + id: Snowflake.parse(raw['id']!), + manager: client.channels, + ), + ), + url: maybeParse(raw['url'], Uri.parse), + ); + } + + WebhookAuthor parseWebhookAuthor(Map raw) { + return WebhookAuthor( + id: Snowflake.parse(raw['id']!), + manager: this, + avatarHash: raw['avatar'] as String?, + username: raw['username'] as String, + ); + } + + @override + Future fetch(Snowflake id, {String? token}) async { + final route = HttpRoute()..webhooks(id: id.toString()); + if (token != null) { + route.add(HttpRoutePart(token)); + } + final request = BasicRequest(route, authenticated: token == null); + + final response = await client.httpHandler.executeSafe(request); + final webhook = parse(response.jsonBody as Map); + + cache[webhook.id] = webhook; + return webhook; + } + + @override + Future create(WebhookBuilder builder) async { + final route = HttpRoute() + ..channels(id: builder.channelId.toString()) + ..webhooks(); + final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + final webhook = parse(response.jsonBody as Map); + + cache[webhook.id] = webhook; + return webhook; + } + + @override + Future update(Snowflake id, WebhookUpdateBuilder builder, {String? token}) async { + final route = HttpRoute()..webhooks(id: id.toString()); + if (token != null) { + route.add(HttpRoutePart(token)); + } + final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build()), authenticated: token == null); + + final response = await client.httpHandler.executeSafe(request); + final webhook = parse(response.jsonBody as Map); + + cache[webhook.id] = webhook; + return webhook; + } + + @override + Future delete(Snowflake id, {String? token, String? auditLogReason}) async { + final route = HttpRoute()..webhooks(id: id.toString()); + if (token != null) { + route.add(HttpRoutePart(token)); + } + final request = BasicRequest(route, method: 'DELETE', authenticated: token == null, auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + + cache.remove(id); + } + + /// Fetch the webhooks in a channel. + Future> fetchChannelWebhooks(Snowflake channelId) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..webhooks(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final webhooks = parseMany(response.jsonBody as List, parse); + + cache.addEntities(webhooks); + return webhooks; + } + + /// Fetch the webhooks in a guild. + Future> fetchGuildWebhooks(Snowflake guildId) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..webhooks(); + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + final webhooks = parseMany(response.jsonBody as List, parse); + + cache.addEntities(webhooks); + return webhooks; + } + + /// Execute a webhook. + Future execute(Snowflake id, MessageBuilder builder, {required String token, bool? wait, Snowflake? threadId}) async { + final route = HttpRoute() + ..webhooks(id: id.toString()) + ..add(HttpRoutePart(token)); + + final queryParameters = {if (wait != null) 'wait': wait.toString(), if (threadId != null) 'thread_id': threadId.toString()}; + final HttpRequest request; + if (!identical(builder.attachments, sentinelList) && builder.attachments?.isNotEmpty == true) { + final attachments = builder.attachments!; + final payload = builder.build(); + + final files = []; + for (int i = 0; i < attachments.length; i++) { + files.add(MultipartFile.fromBytes( + 'files[$i]', + attachments[i].data, + filename: attachments[i].fileName, + )); + + ((payload['attachments'] as List)[i] as Map)['id'] = i.toString(); + } + + request = MultipartRequest( + route, + method: 'POST', + jsonPayload: jsonEncode(payload), + files: files, + queryParameters: queryParameters, + authenticated: false, + ); + } else { + request = BasicRequest( + route, + method: 'POST', + body: jsonEncode(builder.build()), + queryParameters: queryParameters, + authenticated: false, + ); + } + + final response = await client.httpHandler.executeSafe(request); + + if (wait != true) { + return null; + } + + final channelId = Snowflake.parse((response.jsonBody as Map)['channel_id']!); + final messageManager = (client.channels[channelId] as PartialTextChannel).messages; + final message = messageManager.parse(response.jsonBody as Map); + + messageManager.cache[message.id] = message; + return message; + } + + /// Fetch a message sent by a webhook. + Future fetchWebhookMessage(Snowflake webhookId, Snowflake messageId, {required String token, Snowflake? threadId}) async { + final route = HttpRoute() + ..webhooks(id: webhookId.toString()) + ..add(HttpRoutePart(token)) + ..messages(id: messageId.toString()); + final request = BasicRequest( + route, + queryParameters: {if (threadId != null) 'thread_id': threadId.toString()}, + authenticated: false, + ); + + final response = await client.httpHandler.executeSafe(request); + final channelId = Snowflake.parse((response.jsonBody as Map)['channel_id']!); + final messageManager = (client.channels[channelId] as PartialTextChannel).messages; + final message = messageManager.parse(response.jsonBody as Map); + + messageManager.cache[message.id] = message; + return message; + } + + /// Update a message sent by a webhook. + Future updateWebhookMessage( + Snowflake webhookId, + Snowflake messageId, + MessageUpdateBuilder builder, { + required String token, + Snowflake? threadId, + }) async { + final route = HttpRoute() + ..webhooks(id: webhookId.toString(), token: token) + ..messages(id: messageId.toString()); + + final queryParameters = {if (threadId != null) 'thread_id': threadId.toString()}; + final HttpRequest request; + if (!identical(builder.attachments, sentinelList) && builder.attachments?.isNotEmpty == true) { + final attachments = builder.attachments!; + final payload = builder.build(); + + final files = []; + for (int i = 0; i < attachments.length; i++) { + files.add(MultipartFile.fromBytes( + 'files[$i]', + attachments[i].data, + filename: attachments[i].fileName, + )); + + ((payload['attachments'] as List)[i] as Map)['id'] = i.toString(); + } + + request = MultipartRequest( + route, + method: 'PATCH', + jsonPayload: jsonEncode(payload), + files: files, + queryParameters: queryParameters, + authenticated: false, + ); + } else { + request = BasicRequest( + route, + method: 'PATCH', + body: jsonEncode(builder.build()), + queryParameters: queryParameters, + authenticated: false, + ); + } + + final response = await client.httpHandler.executeSafe(request); + final channelId = Snowflake.parse((response.jsonBody as Map)['channel_id']!); + final messageManager = (client.channels[channelId] as PartialTextChannel).messages; + final message = messageManager.parse(response.jsonBody as Map); + + messageManager.cache[message.id] = message; + return message; + } + + /// Delete a message sent by a webhook. + Future deleteWebhookMessage(Snowflake webhookId, Snowflake messageId, {required String token, Snowflake? threadId}) async { + final route = HttpRoute() + ..webhooks(id: webhookId.toString()) + ..add(HttpRoutePart(token)) + ..messages(id: messageId.toString()); + final request = BasicRequest( + route, + method: 'DELETE', + queryParameters: {if (threadId != null) 'thread_id': threadId.toString()}, + authenticated: false, + ); + + await client.httpHandler.executeSafe(request); + } +} diff --git a/lib/src/http/request.dart b/lib/src/http/request.dart new file mode 100644 index 000000000..0f7fa795c --- /dev/null +++ b/lib/src/http/request.dart @@ -0,0 +1,168 @@ +import 'package:http/http.dart' hide MultipartRequest; +import 'package:http/http.dart' as http show MultipartRequest; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/http/route.dart'; + +/// An HTTP request to be made against the API. +/// +/// {@template http_request} +/// This class is a wrapper around the [BaseRequest] class from `package:http`, providing rate +/// limit, audit log and authentication support. +/// {@endtemplate} +abstract class HttpRequest { + /// The name of the header containing the audit log reason for a request. + static const xAuditLogReason = 'X-Audit-Log-Reason'; + + /// The name of the header containing the authentication for a request. + static const authorization = 'Authorization'; + + /// The name of the header containing the user agent for a request. + static const userAgent = 'User-Agent'; + + /// The route for this request. + final HttpRoute route; + + /// The method for this request. + final String method; + + /// The query parameters for this request. + final Map queryParameters; + + /// The headers for this request. + /// + /// This map will not contain the [authorization] or the [xAuditLogReason] headers generated by + /// nyxx, but it will override their values if they are present. + final Map headers; + + /// The audit log reason for this request. + /// + /// Only supported on certain endpoints. + final String? auditLogReason; + + /// Whether to add authentication to this request when sending it. + final bool authenticated; + + /// Whether to apply the global rate limit to this request. + final bool applyGlobalRateLimit; + + /// The identifier for the rate limit bucket for this request. + String get rateLimitId => '$method ${route.rateLimitId}'; + + /// Create a new [HttpRequest]. + /// + /// {@macro http_request} + HttpRequest( + this.route, { + this.method = 'GET', + this.queryParameters = const {}, + this.headers = const {}, + this.auditLogReason, + this.authenticated = true, + this.applyGlobalRateLimit = true, + }); + + /// Transform this [HttpRequest] into a [BaseRequest] to be sent. + /// + /// The [client] will be used for authentication if authentication is enabled for this request. + BaseRequest prepare(Nyxx client); + + Uri _getUri(Nyxx client) => Uri.https( + client.apiOptions.host, + client.apiOptions.baseUri + route.path, + queryParameters.isNotEmpty ? queryParameters : null, + ); + + Map _getHeaders(Nyxx client) => { + userAgent: client.apiOptions.userAgent, + if (auditLogReason != null) xAuditLogReason: auditLogReason!, + if (authenticated) authorization: client.apiOptions.authorizationHeader, + ...headers, + }; + + @override + String toString() => 'HttpRequest($method $route)'; +} + +/// An [HttpRequest] with a JSON body. +class BasicRequest extends HttpRequest { + /// The `Content-Type` header for JSON requests. + static const jsonContentTypeHeader = {'Content-Type': 'application/json'}; + + /// The JSON-encoded body of this request. + /// + /// Set to `null` to send no body. + final String? body; + + /// Create a new [BasicRequest]. + BasicRequest( + super.route, { + this.body, + super.method, + super.queryParameters, + super.applyGlobalRateLimit, + super.auditLogReason, + super.authenticated, + super.headers, + }); + + @override + Request prepare(Nyxx client) { + final request = Request(method, _getUri(client)); + request.headers.addAll(_getHeaders(client)); + + if (body != null) { + request.headers.addAll(jsonContentTypeHeader); + request.body = body!; + } + + return request; + } +} + +class FormDataRequest extends HttpRequest { + /// A list of files to be sent in this request. + final List files; + + /// Form params to send with http request + final Map formParams; + + /// Create a new [FormDataRequest]. + FormDataRequest( + super.route, { + this.formParams = const {}, + this.files = const [], + super.applyGlobalRateLimit, + super.auditLogReason, + super.authenticated, + super.headers, + super.method, + super.queryParameters, + }); + + @override + http.MultipartRequest prepare(Nyxx client) { + final request = http.MultipartRequest(method, _getUri(client)); + request + ..headers.addAll(_getHeaders(client)) + ..fields.addAll(formParams) + ..files.addAll(files); + + return request; + } +} + +/// An [HttpRequest] with files & a JSON payload. +class MultipartRequest extends FormDataRequest { + /// Create a new [MultipartRequest]. + MultipartRequest( + super.route, { + String? jsonPayload, + super.files, + super.applyGlobalRateLimit, + super.auditLogReason, + super.authenticated, + super.headers, + super.method, + super.queryParameters, + }) : super(formParams: jsonPayload != null ? {'payload_json': jsonPayload} : {}); +} diff --git a/lib/src/http/response.dart b/lib/src/http/response.dart new file mode 100644 index 000000000..65200ec32 --- /dev/null +++ b/lib/src/http/response.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/http/request.dart'; + +/// A response to a [HttpRequest] from the Discord API. +/// +/// {@template http_response} +/// This class is a wrapper around [BaseResponse] from `package:http` providing support for errors +/// and for parsing the received body. +/// {@endtemplate} +abstract class HttpResponse { + /// The status code of the response. + final int statusCode; + + /// The headers from the response. + final Map headers; + + /// The body of the response as it was received from the API. + final Uint8List body; + + /// The UTF-8 decoded body of the response. + /// + /// Will be null if [body] is not a valid UTF-8 string. + late final String? textBody; + + /// The JSON decoded body of the response. + /// + /// Will be `null` and [hasJsonBody] will be false if [textBody] is not a valid JSON string. + late final dynamic jsonBody; + + /// Whether [jsonBody] contains the JSON decoded body of the response. + late final bool hasJsonBody; + + /// The [HttpRequest] this response is for. + final HttpRequest request; + + /// The [BaseResponse] from `package:http` this [HttpResponse] is wrapping. + final BaseResponse response; + + /// Create a new [HttpResponse]. + /// + /// {@macro http_response} + HttpResponse({ + required this.response, + required this.request, + required this.body, + }) : statusCode = response.statusCode, + headers = response.headers { + String? textBody; + dynamic jsonBody; + bool hasJsonBody = false; + + try { + textBody = utf8.decode(body); + jsonBody = jsonDecode(textBody); + hasJsonBody = true; + } on FormatException { + // ignore: Invalid format, leave the defaults + } + + this.textBody = textBody; + this.jsonBody = jsonBody; + this.hasJsonBody = hasJsonBody; + } + + @override + String toString() => '$statusCode (${response.reasonPhrase}) ${request.method} ${response.request!.url}'; +} + +/// A successful [HttpResponse]. +class HttpResponseSuccess extends HttpResponse { + /// Create a new [HttpResponseSuccess]. + HttpResponseSuccess({required super.response, required super.request, required super.body}); + + /// Create a [HttpResponseSuccess] from a [StreamedResponse]. + static Future fromResponse( + HttpRequest request, + StreamedResponse response, + ) async => + HttpResponseSuccess( + request: request, + body: await response.stream.toBytes(), + response: response, + ); +} + +/// An [HttpResponse] which represents an error from the API. +class HttpResponseError extends HttpResponse implements NyxxException { + /// A message containing details about why the request failed. + @override + String get message => errorData?.errorMessage ?? response.reasonPhrase ?? textBody!; + + /// The error code of this response. + /// + /// If Discord sets its own status code, this can be found here. Otherwise, this is equal to + /// [statusCode]. + int get errorCode => errorData?.errorCode ?? statusCode; + + /// Additional information about the error, if any. + late final HttpErrorData? errorData; + + /// Create a new [HttpResponseError]. + HttpResponseError({required super.response, required super.request, required super.body}) { + HttpErrorData? errorData; + if (hasJsonBody) { + try { + errorData = HttpErrorData.parse(jsonBody as Map); + } on TypeError { + // ignore: Response was not a valid error object. We'll just fall back to the response status code and message. + } + } + + this.errorData = errorData; + } + + /// Create a new [HttpResponseError] from a [StreamedResponse]. + static Future fromResponse( + HttpRequest request, + StreamedResponse response, + ) async => + HttpResponseError( + request: request, + body: await response.stream.toBytes(), + response: response, + ); + + @override + String toString({bool short = false}) { + if (short) { + return super.toString(); + } + + final result = StringBuffer('$message ($errorCode) ${request.method} ${response.request!.url}\n'); + + if (errorData?.fieldErrors.isNotEmpty ?? false) { + result.writeln('Errors:'); + + for (final field in errorData!.fieldErrors.values) { + result.writeln(' ${field.name}: ${field.errorMessage} (${field.errorCode})'); + } + } + + // Trim trailing newline + return result.toString().trim(); + } +} + +/// Information about an error from the API. +class HttpErrorData { + /// The error code. + /// + /// You can find a full list of these [here](https://discord.com/developers/docs/topics/opcodes-and-status-codes#json). + final int errorCode; + + /// A human-readable description of the error represented by [errorCode]. + final String errorMessage; + + /// A mapping of field path to field error. + /// + /// Will be empty if Discord did not send any errors associated with specific fields in the request. + final Map fieldErrors = {}; + + /// Parse a JSON error response to an instance of [HttpErrorData]. + /// + /// Throws a [TypeError] if [raw] is not a valid error response. + HttpErrorData.parse(Map raw) + : errorCode = raw['code'] as int, + errorMessage = raw['message'] as String { + final errors = raw['errors'] as Map?; + + if (errors != null) { + _initErrors(errors); + } + } + + void _initErrors(Map fields, [List path = const []]) { + final errors = fields['_errors'] as List?; + + if (errors != null) { + for (final error in errors.cast>()) { + final fieldError = FieldError( + path: path, + errorCode: error['code'] as String, + errorMessage: error['message'] as String, + ); + + fieldErrors[fieldError.name] = fieldError; + } + } + + for (final nestedElement in fields.entries) { + if (nestedElement.value is! Map) { + continue; + } + + _initErrors(nestedElement.value as Map, [...path, nestedElement.key]); + } + } +} + +/// Information about an error associated with a specific field in a request. +class FieldError { + /// A human-readable name of this field. + final String name; + + /// The segments of the path to this field in the request. + final List path; + + /// The error code. + final String errorCode; + + /// A human-readable description of the error represented by [errorCode]. + final String errorMessage; + + /// Create a new [FieldError]. + FieldError({ + required this.path, + required this.errorCode, + required this.errorMessage, + }) : name = pathToName(path); + + /// Convert a list of path segments to a human-readable field name. + /// + /// For example, the path `[foo, bar, 1, baz]` becomes `foo.bar[1].baz`. + static String pathToName(List path) { + if (path.isEmpty) { + return ''; + } + + final result = StringBuffer(path.first); + + for (final part in path.skip(1)) { + final isArrayIndex = RegExp(r'^\d+$').hasMatch(part); + + if (isArrayIndex) { + result.write('[$part]'); + } else { + result.write('.$part'); + } + } + + return result.toString(); + } +} diff --git a/lib/src/http/route.dart b/lib/src/http/route.dart new file mode 100644 index 000000000..65df5b3d2 --- /dev/null +++ b/lib/src/http/route.dart @@ -0,0 +1,305 @@ +import 'dart:collection'; + +/// A route within the Discord API. +/// +/// {@template http_route} +/// The path of a request made to the API is encoded as a [HttpRoute]. This allows for anticipation +/// of rate limits based on the request's route. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/topics/rate-limits#rate-limits +/// {@endtemplate} +class HttpRoute { + final List _parts = []; + + /// The [HttpRoutePart]s that make up this route. + List get parts => UnmodifiableListView(_parts); + + /// The segments of this route. + /// + /// This includes the names and parameters of this [parts]. + Iterable get segments => parts.expand((part) => part.segments); + + /// The path this [HttpRoute] represents, relative to Discord's root API URL. + String get path => '/${segments.join('/')}'; + + /// An id used for rate limiting. + /// + /// Requests wit the same [HttpRoute.rateLimitId] are put in the same [HttpBucket] for + /// ratelimiting. + String get rateLimitId => parts + .expand((part) => [ + part.name, + ...part.params.map((param) => param.isMajor ? param.value : r'$param'), + ]) + .join('/'); + + /// Add [part] to this route. + void add(HttpRoutePart part) => _parts.add(part); + + @override + String toString() => path; +} + +/// A part of a [HttpRoute]. +/// +/// {@template http_route_part} +/// HTTP route parts are made up of an identifier (such as `/users`) and, optionally, one or more +/// parameters (such as the id of a user). +/// {@endtemplate} +class HttpRoutePart { + /// The name of this part. + final String name; + + /// The parameters of this part. + final List params; + + /// The segments of this part. + /// + /// This includes this part's name and parameter values. + List get segments => [name, ...params.map((param) => param.value)]; + + /// Create a new [HttpRoutePart]. + /// + /// {@macro http_route_part} + HttpRoutePart(this.name, [this.params = const []]); +} + +/// A parameter in a [HttpRoutePart]. +/// +/// {@template http_route_part} +/// This is not a query parameter, it is a parameter encoded in the path of the request itself, such +/// as the id of a guild in `/guilds/0123456789`. +/// {@endtemplate} +class HttpRouteParam { + /// The value of this parameter. + final String value; + + /// Whether this parameter is a major parameter. + /// + /// Major parameters will be included in the [HttpRoute.rateLimitId], so requests to the same + /// endpoint but different major parameters will be in separate rate limit buckets. + final bool isMajor; + + /// Create a new [HttpRouteParam]. + /// + /// {@macro http_route_param} + HttpRouteParam(this.value, {this.isMajor = false}); +} + +/// Helper methods for constructing [HttpRoute]s. +extension RouteHelpers on HttpRoute { + /// Adds the [`guilds`](https://discord.com/developers/docs/resources/guild#get-guild) part to this [HttpRoute]. + void guilds({String? id}) => add(HttpRoutePart("guilds", [if (id != null) HttpRouteParam(id, isMajor: true)])); + + /// Adds the [`channels`](https://discord.com/developers/docs/resources/channel#get-channel) part to this [HttpRoute]. + void channels({String? id}) => add(HttpRoutePart("channels", [if (id != null) HttpRouteParam(id, isMajor: true)])); + + /// Adds the [`webhooks`](https://discord.com/developers/docs/resources/webhook#get-webhook) part to this [HttpRoute]. + void webhooks({String? id, String? token}) => add(HttpRoutePart("webhooks", [ + if (id != null) HttpRouteParam(id, isMajor: token != null), + if (token != null) HttpRouteParam(token, isMajor: id != null), + ])); + + /// Adds the [`reactions`](https://discord.com/developers/docs/resources/channel#get-reactions) part to this [HttpRoute]. + void reactions({String? emoji, String? userId}) => add(HttpRoutePart("reactions", [ + if (emoji != null) HttpRouteParam(emoji), + if (userId != null) HttpRouteParam(userId), + ])); + + /// Adds the [`emojis`](https://discord.com/developers/docs/resources/emoji#get-guild-emoji) part to this [HttpRoute]. + void emojis({String? id}) => add(HttpRoutePart("emojis", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`roles`](https://discord.com/developers/docs/resources/guild#get-guild-roles) part to this [HttpRoute]. + void roles({String? id}) => add(HttpRoutePart("roles", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`members`](https://discord.com/developers/docs/resources/guild#get-guild-member) part to this [HttpRoute]. + void members({String? id}) => add(HttpRoutePart("members", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`bans`](https://discord.com/developers/docs/resources/guild#get-guild-bans) part to this [HttpRoute]. + void bans({String? id}) => add(HttpRoutePart("bans", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`users`](https://discord.com/developers/docs/resources/user#get-user) part to this [HttpRoute]. + void users({String? id}) => add(HttpRoutePart("users", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`permissions`](https://discord.com/developers/docs/interactions/application-commands#get-guild-application-command-permissions) part to this [HttpRoute]. + void permissions({String? id}) => add(HttpRoutePart("permissions", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`messages`](https://discord.com/developers/docs/resources/channel#get-channel-messages) part to this [HttpRoute]. + void messages({String? id}) => add(HttpRoutePart("messages", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`pins`](https://discord.com/developers/docs/resources/channel#get-pinned-messages) part to this [HttpRoute]. + void pins({String? id}) => add(HttpRoutePart("pins", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`invites`](https://discord.com/developers/docs/resources/guild#get-guild-invites) part to this [HttpRoute]. + void invites({String? id}) => add(HttpRoutePart("invites", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`applications`](https://discord.com/developers/docs/topics/oauth2#get-current-bot-application-information) part to this [HttpRoute]. + void applications({String? id}) => add(HttpRoutePart("applications", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`stage-instances`](https://discord.com/developers/docs/resources/stage-instance#get-stage-instance) part to this [HttpRoute]. + void stageInstances({String? id}) => add(HttpRoutePart("stage-instances", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`thread-members`](https://discord.com/developers/docs/resources/channel#get-thread-member) part to this [HttpRoute]. + void threadMembers({String? id}) => add(HttpRoutePart("thread-members", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`stickers`](https://discord.com/developers/docs/resources/sticker#get-sticker) part to this [HttpRoute]. + void stickers({String? id}) => add(HttpRoutePart("stickers", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`scheduled-events`](https://discord.com/developers/docs/resources/guild-scheduled-event#get-guild-scheduled-event) part to this [HttpRoute]. + void scheduledEvents({String? id}) => add(HttpRoutePart("scheduled-events", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`rules`](https://discord.com/developers/docs/resources/auto-moderation#get-auto-moderation-rule) part to this [HttpRoute]. + void rules({String? id}) => add(HttpRoutePart('rules', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`prune`](https://discord.com/developers/docs/resources/guild#get-guild-prune-count) part to this [HttpRoute]. + void prune() => add(HttpRoutePart("prune")); + + /// Adds the [`nick`](https://discord.com/developers/docs/resources/guild#modify-current-user-nick) part to this [HttpRoute]. + void nick() => add(HttpRoutePart("nick")); + + /// Adds the [`audit-logs`](https://discord.com/developers/docs/resources/audit-log#get-guild-audit-log) part to this [HttpRoute]. + void auditLogs() => add(HttpRoutePart("audit-logs")); + + /// Adds the [`regions`](https://discord.com/developers/docs/resources/voice#list-voice-regions) part to this [HttpRoute]. + void regions() => add(HttpRoutePart("regions")); + + /// Adds the [`search`](https://discord.com/developers/docs/resources/guild#search-guild-members) part to this [HttpRoute]. + void search() => add(HttpRoutePart("search")); + + /// Adds the [`bulk-delete`](https://discord.com/developers/docs/resources/channel#bulk-delete-messages) part to this [HttpRoute]. + void bulkDelete() => add(HttpRoutePart("bulk-delete")); + + /// Adds the [`typing`](https://discord.com/developers/docs/resources/channel#trigger-typing-indicator) part to this [HttpRoute]. + void typing() => add(HttpRoutePart("typing")); + + /// Adds the [`crosspost`](https://discord.com/developers/docs/resources/channel#crosspost-message) part to this [HttpRoute]. + void crosspost() => add(HttpRoutePart("crosspost")); + + /// Adds the [`threads`](https://discord.com/developers/docs/resources/channel#start-thread-from-message) part to this [HttpRoute]. + void threads() => add(HttpRoutePart("threads")); + + /// Adds the [`gateway`](https://discord.com/developers/docs/topics/gateway#get-gateway) part to this [HttpRoute]. + void gateway() => add(HttpRoutePart("gateway")); + + /// Adds the [`bot`](https://discord.com/developers/docs/topics/gateway#get-gateway-bot) part to this [HttpRoute]. + void bot() => add(HttpRoutePart("bot")); + + /// Adds the [`oauth2`](https://discord.com/developers/docs/topics/oauth2#get-current-authorization-information) part to this [HttpRoute]. + void oauth2() => add(HttpRoutePart("oauth2")); + + /// Adds the [`preview`](https://discord.com/developers/docs/resources/guild#get-guild-preview) part to this [HttpRoute]. + void preview() => add(HttpRoutePart("preview")); + + /// Adds the [`active`](https://discord.com/developers/docs/resources/guild#list-active-guild-threads) part to this [HttpRoute]. + void active() => add(HttpRoutePart("active")); + + /// Adds the [`archived`](https://discord.com/developers/docs/resources/channel#list-public-archived-threads) part to this [HttpRoute]. + void archived() => add(HttpRoutePart("archived")); + + /// Adds the [`private`](https://discord.com/developers/docs/resources/channel#list-private-archived-threads) part to this [HttpRoute]. + void private() => add(HttpRoutePart("private")); + + /// Adds the [`public`](https://discord.com/developers/docs/resources/channel#list-public-archived-threads) part to this [HttpRoute]. + void public() => add(HttpRoutePart("public")); + + /// Adds the [`sticker-packs`](https://discord.com/developers/docs/resources/sticker#list-nitro-sticker-packs) part to this [HttpRoute]. + void stickerPacks({String? id}) => add(HttpRoutePart("sticker-packs", [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`welcome-screen`](https://discord.com/developers/docs/resources/guild#get-guild-welcome-screen) part to this [HttpRoute]. + void welcomeScreen() => add(HttpRoutePart('welcome-screen')); + + /// Adds the [`auto-moderation`](https://discord.com/developers/docs/resources/auto-moderation#list-auto-moderation-rules-for-guild) part to this [HttpRoute]. + void autoModeration() => add(HttpRoutePart('auto-moderation')); + + /// Adds the [`connections`](https://discord.com/developers/docs/resources/user#get-user-connections) part to this [HttpRoute]. + void connections() => add(HttpRoutePart('connections')); + + /// Adds the [`followers`](https://discord.com/developers/docs/resources/channel#follow-announcement-channel) part to this [HttpRoute]. + void followers() => add(HttpRoutePart('followers')); + + /// Adds the [`mfa`](https://discord.com/developers/docs/resources/guild#modify-guild-mfa-level) part to this [HttpRoute]. + void mfa() => add(HttpRoutePart('mfa')); + + /// Adds the [`voice`](https://discord.com/developers/docs/resources/voice#list-voice-regions) part to this [HttpRoute]. + void voice() => add(HttpRoutePart('voice')); + + /// Adds the [`integrations`](https://discord.com/developers/docs/resources/guild#get-guild-integrations) part to this [HttpRoute]. + void integrations({String? id}) => add(HttpRoutePart('integrations', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`widget`](https://discord.com/developers/docs/resources/guild#get-guild-widget-settings) part to this [HttpRoute]. + void widget() => add(HttpRoutePart('widget')); + + /// Adds the [`widget.json`](https://discord.com/developers/docs/resources/guild#get-guild-widget) part to this [HttpRoute]. + void widgetJson() => add(HttpRoutePart('widget.json')); + + /// Adds the [`widget.png`](https://discord.com/developers/docs/resources/guild#get-guild-widget-image) part to this [HttpRoute]. + void widgetPng() => add(HttpRoutePart('widget.png')); + + /// Adds the [`onboarding`](https://discord.com/developers/docs/resources/guild#get-guild-onboarding) part to this [HttpRoute]. + void onboarding() => add(HttpRoutePart('onboarding')); + + /// Adds the [`voice-states`](https://discord.com/developers/docs/resources/guild#modify-current-user-voice-state) part to this [HttpRoute]. + void voiceStates({String? id}) => add(HttpRoutePart('voice-states', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`role-connections`](https://discord.com/developers/docs/resources/application-role-connection-metadata#get-application-role-connection-metadata-records) + /// part to this [HttpRoute]. + void roleConnections() => add(HttpRoutePart('role-connections')); + + /// Adds the [`metadata`](https://discord.com/developers/docs/resources/application-role-connection-metadata#get-application-role-connection-metadata-records) + /// part to this [HttpRoute]. + void metadata() => add(HttpRoutePart('metadata')); + + /// Adds the [`templates`](https://discord.com/developers/docs/resources/guild-template#get-guild-template) part to this [HttpRoute]. + void templates({String? code}) => add(HttpRoutePart('templates', [if (code != null) HttpRouteParam(code)])); + + /// Adds the [`role-connection`](https://discord.com/developers/docs/resources/user#get-user-application-role-connection) + /// part to this [HttpRoute]. + void roleConnection() => add(HttpRoutePart('role-connection')); + + /// Adds the [`member`](https://discord.com/developers/docs/resources/user#get-current-user-guild-member) part to this [HttpRoute]. + void member() => add(HttpRoutePart("member")); + + /// Adds the [`vanity-url`](https://discord.com/developers/docs/resources/guild#get-guild-vanity-url) part to this [HttpRoute]. + void vanityUrl() => add(HttpRoutePart('vanity-url')); + + /// Adds the [`icons`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void icons({String? id}) => add(HttpRoutePart('icons', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`splashes`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void splashes({String? id}) => add(HttpRoutePart('splashes', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`discovery-splashes`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void discoverySplashes({String? id}) => add(HttpRoutePart('discovery-splashes', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`banners`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void banners({String? id}) => add(HttpRoutePart('banners', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`embed`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void embed({String? id}) => add(HttpRoutePart('embed', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`avatars`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void avatars({String? id}) => add(HttpRoutePart('avatars', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`app-icons`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void appIcons({String? id}) => add(HttpRoutePart('app-icons', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`team-icons`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void teamIcons({String? id}) => add(HttpRoutePart('team-icons', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`role-icons`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void roleIcons({String? id}) => add(HttpRoutePart('role-icons', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`guild-events`](https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints) part to this [HttpRoute]. + void guildEvents({String? id}) => add(HttpRoutePart('guild-events', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`commands`](https://discord.com/developers/docs/interactions/application-commands#get-global-application-commands) part to this [HttpRoute]. + void commands({String? id}) => add(HttpRoutePart('commands', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`interactions`](https://discord.com/developers/docs/interactions/receiving-and-responding#create-interaction-response) part to this [HttpRoute]. + void interactions({String? id, String? token}) => + add(HttpRoutePart('interactions', [if (id != null) HttpRouteParam(id), if (token != null) HttpRouteParam(token)])); + + /// Adds the [`callback`](https://discord.com/developers/docs/interactions/receiving-and-responding#create-interaction-response) part to this [HttpRoute]. + void callback() => add(HttpRoutePart('callback')); +} diff --git a/lib/src/intents.dart b/lib/src/intents.dart new file mode 100644 index 000000000..786a40607 --- /dev/null +++ b/lib/src/intents.dart @@ -0,0 +1,39 @@ +import 'package:nyxx/src/utils/flags.dart'; + +/// Flags used to set the intents when opening a Gateway session. +class GatewayIntents extends Flags { + static const guilds = Flag.fromOffset(0); + static const guildMembers = Flag.fromOffset(1); + static const guildModeration = Flag.fromOffset(2); + static const guildEmojisAndStickers = Flag.fromOffset(3); + static const guildIntegrations = Flag.fromOffset(4); + static const guildWebhooks = Flag.fromOffset(5); + static const guildInvites = Flag.fromOffset(6); + static const guildVoiceStates = Flag.fromOffset(7); + static const guildPresences = Flag.fromOffset(8); + static const guildMessages = Flag.fromOffset(9); + static const guildMessageReactions = Flag.fromOffset(10); + static const guildMessageTyping = Flag.fromOffset(11); + static const directMessages = Flag.fromOffset(12); + static const directMessageReactions = Flag.fromOffset(13); + static const directMessageTyping = Flag.fromOffset(14); + static const messageContent = Flag.fromOffset(15); + static const guildScheduledEvents = Flag.fromOffset(16); + static const autoModerationConfiguration = Flag.fromOffset(20); + static const autoModerationExecution = Flag.fromOffset(21); + + /// A [GatewayIntents] with all intents enabled. + static const all = GatewayIntents(0x1fffff); + + /// A [GatewayIntents] with all unprivileged intents enabled. + static const allUnprivileged = GatewayIntents(0x317efd); + + /// A [GatewayIntents] with all privileged intents enabled. + static const allPrivileged = GatewayIntents(0x8102); + + /// A [GatewayIntents] with no intents enabled. + static const none = GatewayIntents(0); + + /// Create a new [GatewayIntents]. + const GatewayIntents(super.value); +} diff --git a/lib/src/internal/cache/cache.dart b/lib/src/internal/cache/cache.dart deleted file mode 100644 index 887816356..000000000 --- a/lib/src/internal/cache/cache.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:collection'; - -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/internal/interfaces/disposable.dart'; - -class SnowflakeCache extends InMemoryCache { - final int cacheSize; - - /// Creates instance of cache that has finite size. - /// New entry will replace entries that are the longest in cache - SnowflakeCache([this.cacheSize = -1]) : super(); - - @override - void operator []=(Snowflake key, V value) { - if (cacheSize == 0) { - return; - } - - if (cacheSize > 0 && length >= cacheSize) { - remove(keys.first); - } - - _map[key] = value; - } -} - -abstract class InMemoryCache extends MapMixin implements ICache { - final Map _map = {}; - - @override - V? operator [](Object? key) => _map[key]; - - @override - void operator []=(K key, V value) => _map[key] = value; - - @override - void clear() => _map.clear(); - - @override - Iterable get keys => _map.keys; - - @override - V? remove(Object? key) => _map.remove(key); - - @override - Future dispose() async => _map.clear(); -} - -abstract class ICache implements MapMixin, Disposable {} diff --git a/lib/src/internal/cache/cache_policy.dart b/lib/src/internal/cache/cache_policy.dart deleted file mode 100644 index bd75f5725..000000000 --- a/lib/src/internal/cache/cache_policy.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -/// Predicate which will decide if entity could be cached -typedef CachePolicyPredicate = bool Function(T); - -/// Describes in which places entity should be cached -class CachePolicyLocation { - /// Allows entities to be cached inside events - bool event = true; - - /// Allows entities to be cached inside other entities constructors, eg. member object inside message - bool objectConstructor = false; - - /// Allows entities to be cached in other places - bool other = false; - - /// Allows entities downloaded from http api to be cached - bool http = true; - - /// Default options. - /// [event] and [http] will be enabled by default - CachePolicyLocation(); - - /// Enables all cache locations - CachePolicyLocation.all() { - event = true; - objectConstructor = true; - other = true; - http = true; - } - - /// Disabled all cache locations - CachePolicyLocation.none() { - event = false; - objectConstructor = false; - other = false; - http = false; - } -} - -/// CachePolicy is set of rules which will decide if entity should be cached. -class CachePolicy { - final CachePolicyPredicate _predicate; - - /// Constructor - CachePolicy(this._predicate); - - /// Pure function which will decide based on given predicate if [entity] will be cached - bool canCache(T entity) => _predicate(entity); - - /// Convenience method to concatenate other policy - CachePolicy or(CachePolicy other) => CachePolicy((entity) => canCache(entity) || other.canCache(entity)); - - /// Convenience method to require other policy - CachePolicy and(CachePolicy other) => CachePolicy((entity) => canCache(entity) && other.canCache(entity)); - - /// Composes a policy by concatenating multiple other policies from list - static CachePolicy any(List> policies) => - CachePolicy((entity) => policies.any((policy) => policy.canCache(entity))); -} - -/// Cache policies for caching members -class MemberCachePolicy extends CachePolicy { - /// Do not cache members - static final CachePolicy none = MemberCachePolicy((member) => false); - - /// Cache all members - static final CachePolicy all = MemberCachePolicy((member) => true); - - /// Cache members which have online status - static final CachePolicy online = MemberCachePolicy((member) => member.user.getFromCache()?.status?.isOnline ?? false); - - /// Cache only members which have voice state not null - static final CachePolicy voice = MemberCachePolicy((member) => member.voiceState != null); - - /// Cache only member which are owner of guild - static final CachePolicy owner = MemberCachePolicy((member) => member.guild.getFromCache()?.owner.id == member.id); - - /// Default policy is [owner] or [voice]. So it caches guild owners and users in voice channels - static final CachePolicy def = owner.or(voice); - - /// Constructor - MemberCachePolicy(CachePolicyPredicate predicate) : super(predicate); -} - -/// Cache policies for caching channels -class ChannelCachePolicy extends CachePolicy { - /// Do not cache channels - static final CachePolicy none = ChannelCachePolicy((channel) => false); - - /// Cache all channels - static final CachePolicy all = ChannelCachePolicy((channel) => true); - - /// Cache only voice channels - static final CachePolicy voice = ChannelCachePolicy((channel) => channel is IVoiceGuildChannel); - - /// Cache only text channels - static final CachePolicy text = ChannelCachePolicy((channel) => channel is ITextChannel); - - /// Cache only thread channels - static final CachePolicy thread = ChannelCachePolicy((channel) => channel is IThreadChannel); - - /// Default policy is [all] - static final CachePolicy def = all; - - /// Constructor - ChannelCachePolicy(CachePolicyPredicate predicate) : super(predicate); -} - -class MessageCachePolicy extends CachePolicy { - /// Do not any messages - static final CachePolicy none = MessageCachePolicy((message) => false); - - /// Cache all messages - static final CachePolicy all = MessageCachePolicy((message) => true); - - /// Cache only guild messages - static final CachePolicy guildMessages = MessageCachePolicy((message) => message.guild != null || message.member != null); - - /// Cache only dm messages - static final CachePolicy dmMessages = MessageCachePolicy((message) => message.guild == null && message.member == null); - - /// Default policy is [all] - static final CachePolicy def = all; - - /// Constructor - MessageCachePolicy(CachePolicyPredicate predicate) : super(predicate); -} diff --git a/lib/src/internal/cache/cacheable.dart b/lib/src/internal/cache/cacheable.dart deleted file mode 100644 index 14b5fa6e6..000000000 --- a/lib/src/internal/cache/cacheable.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; - -/// Wraps [SnowflakeEntity] that can be taken from cache or optionally downloaded from API. -/// Always provides [id] of entity. `download()` method tries to get entity from API and returns it upon success or -/// throws Error if something happens in the process. -abstract class Cacheable { - final INyxx client; - - /// Id of entity - final T id; - - Cacheable(this.client, this.id); - - /// Returns entity from cache or null if not present - S? getFromCache(); - - /// Downloads entity from cache and caches result - Future download(); - - /// Returns entity from cache or tries to download from API if not found. - /// If downloading is successful it caches results - FutureOr getOrDownload() async { - final cacheResult = getFromCache(); - - if (cacheResult != null) { - return cacheResult; - } - - return download(); - } - - @override - bool operator ==(Object other) => other is Cacheable && other.id == id; - - @override - int get hashCode => id.hashCode; -} - -class RoleCacheable extends Cacheable { - final Cacheable guild; - - /// Creates an instance of [RoleCacheable] - RoleCacheable(INyxx client, Snowflake id, this.guild) : super(client, id); - - @override - Future download() async => _fetchGuildRole(); - - @override - IRole? getFromCache() { - final guildInstance = guild.getFromCache(); - - if (guildInstance == null) { - return null; - } - - return guildInstance.roles[id]; - } - - // We cant download single role - Future _fetchGuildRole() async { - final roles = await client.httpEndpoints.fetchGuildRoles(guild.id).toList(); - - final guildCacheable = client.guilds[guild.id]; - if (guildCacheable != null) { - guildCacheable.roles.clear(); - - guildCacheable.roles.addEntries(roles.map((e) => MapEntry(e.id, e))); - } - - try { - return roles.firstWhere((element) => element.id == id); - } on Error { - throw ArgumentError("Cannot fetch role with id `$id` in guild with id `${guild.id}`"); - } - } -} - -class ChannelCacheable extends Cacheable { - /// Creates an instance of [ChannelCacheable] - ChannelCacheable(INyxx client, Snowflake id) : super(client, id); - - @override - T? getFromCache() => client.channels[id] as T?; - - @override - Future download() => client.httpEndpoints.fetchChannel(id); -} - -class GuildCacheable extends Cacheable { - GuildCacheable(INyxx client, Snowflake id) : super(client, id); - - @override - IGuild? getFromCache() => client.guilds[id]; - - @override - Future download() => client.httpEndpoints.fetchGuild(id); -} - -class UserCacheable extends Cacheable { - /// Creates an instance of [UserCacheable] - UserCacheable(INyxx client, Snowflake id) : super(client, id); - - @override - Future download() => client.httpEndpoints.fetchUser(id); - - @override - IUser? getFromCache() => client.users[id]; -} - -class MemberCacheable extends Cacheable { - final Cacheable guild; - - /// Creates an instance of [ChannelCacheable] - MemberCacheable(INyxx client, Snowflake id, this.guild) : super(client, id); - - @override - Future download() => client.httpEndpoints.fetchGuildMember(guild.id, id); - - @override - IMember? getFromCache() { - final guildInstance = guild.getFromCache(); - - if (guildInstance != null) { - return guildInstance.members[id]; - } - - return null; - } -} - -class MessageCacheable extends Cacheable { - final Cacheable channel; - - /// Creates an instance of [ChannelCacheable] - MessageCacheable(INyxx client, Snowflake id, this.channel) : super(client, id); - - @override - Future download() async { - final channelInstance = await channel.getOrDownload(); - return channelInstance.fetchMessage(id); - } - - @override - IMessage? getFromCache() { - final channelInstance = channel.getFromCache(); - - if (channelInstance != null) { - return channelInstance.messageCache[id]; - } - - return null; - } -} diff --git a/lib/src/internal/cdn_http_endpoints.dart b/lib/src/internal/cdn_http_endpoints.dart deleted file mode 100644 index 02420899d..000000000 --- a/lib/src/internal/cdn_http_endpoints.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/internal/http/http_route.dart'; -import 'package:nyxx/src/internal/constants.dart'; -import 'package:nyxx/src/utils/utils.dart'; - -/// All known routes for Discord's CDN endpoints. -/// Theses are used internally by specific classes; however it's possible to use -/// them like [IHttpEndpoints]. -/// -/// The docs assume the root (`/`) is `https://cdn.discordapp.com/`. -abstract class ICdnHttpEndpoints { - /// Returns URL to ``/app-assets/[assetHash]``. - /// With given [format] and [size]. - String appAsset(Snowflake clientId, String assetHash, {String format = 'webp', int? size}); - - /// Returns URL to ``/app-icons/[iconHash]``. - /// With given [format] and [size]. - String appIcon(Snowflake clientId, String iconHash, {String format = 'webp', int? size}); - - /// Returns URL to ``/avatars/[avatarHash]``. - /// With given [format], [size] and whether or not returns the animated version (if applicable) of this URL with [animated]. - String avatar(Snowflake id, String avatarHash, {String format = 'webp', int? size, bool animated = false}); - - /// Returns URL to ``/banners/[bannerHash]``. - /// With given [format], [size] and whether or not returns the animated version (if applicable) of this URL with [animated]. - String banner(Snowflake guildOrUserId, String hash, {String format = 'webp', int? size, bool animated = false}); - - /// Returns URL to ``/channel-icons/[iconHash]``. - /// With given [format] and [size]. - String channelIcon(Snowflake channelId, String iconHash, {String format = 'webp', int? size}); - - /// Returns URL to ``/embed/avatars/[discriminator]``. - /// - /// The [discriminator] is passed as modulo 5 (`% 5`); and will lead to 0,1,2,3,4. (There's 5, but 5 modulo 5 will never give 5). - /// - /// E.g: - /// ```dart - /// client.cdnHttpEndpoints.defaultAvatar(6712); // https://cdn.discordapp.com/embed/avatars/2.png - /// ``` - String defaultAvatar(int discriminator); - - /// Returns URL to ``/emojis/[emojiId]``. - /// With given [format] and [size]. - String emoji(Snowflake emojiId, {String format = 'webp', int? size}); - - /// Returns URL to ``/discovery-splashes/[splashHash]``. - /// With given [format] and [size]. - String discoverySplash(Snowflake guildId, String splashHash, {String format = 'webp', int? size}); - - /// Returns URL to ``/guilds/[guildId]/users/[userId]/[avatarHash]``. - /// With given [format], [size] and whether or not returns the animated version (if applicable) of this URL with [animated]. - String memberAvatar(Snowflake guildId, Snowflake userId, String avatarHash, {String format = 'webp', int? size, bool animated = false}); - - /// Returns URL tp ``/icons/[iconHash]``. - /// With given [format], [size] and whether or not returns the animated version (if applicable) of this URL with [animated]. - String icon(Snowflake id, String iconHash, {String format = 'webp', int? size, bool animated = false}); - - /// Returns URL to ``/role-icons/[roleIconHash]``. - /// With given [format] and [size]. - String roleIcon(Snowflake roleId, String roleIconHash, {String format = 'webp', int? size}); - - /// Returns URL to ``/splashes/[splashHash]``. - /// With given [format] and [size]. - String splash(Snowflake guildId, String splashHash, {String format = 'webp', int? size}); - - /// Returns URL to ``/stickers/[stickerId]``. - /// With given [format], must be `png` or `json`. - String sticker(Snowflake stickerId, {String format = 'png'}); - - /// Returns URL to ``/app-assets/710982414301790216/store/[bannerId]``. - /// With given [format] and [size]. - String stickerPackBanner(Snowflake bannerId, {String format = 'webp', int? size}); - - /// Returns URL to ``/team-icons/[teamId]/[teamIconHash]``. - /// With given [format] and [size]. - String teamIcon(Snowflake teamId, String teamIconHash, {String format = 'webp', int? size}); - - /// Returns URL to ``/guild-events/[eventId]/[eventCoverHash]``. - /// With given [format] and [size]. - String guildEventCoverImage(Snowflake eventId, String eventCoverHash, {String format = 'webp', int? size}); - - /// Returns URL to ``/avatar-decorations/[userId]/[decorationHash]``. - /// With given [size]. - String avatarDecoration(Snowflake userId, String decorationHash, {int? size}); -} - -class CdnHttpEndpoints implements ICdnHttpEndpoints { - String _makeAnimatedCdnUrl(ICdnHttpRoute fragment, String hash, {String format = 'webp', int? size, bool animated = false}) { - if (hash.startsWith('a_') && animated) { - animated = true; - } else { - animated = false; - } - - return _makeCdnUrl(fragment, format: format, size: size, animated: animated); - } - - String _makeCdnUrl(ICdnHttpRoute fragments, {String format = 'webp', int? size, bool animated = false}) { - if (!CdnConstants.allowedExtensions.contains(format)) { - throw Exception('Invalid extension provided, must be one of ${CdnConstants.allowedExtensions.and()}; given: $format'); - } - - if (size != null && !CdnConstants.allowedSizes.contains(size)) { - throw RangeError('Size out of range: size should be ${CdnConstants.allowedSizes.and()}; given: $size'); - } - - if ((fragments as CdnHttpRoute).path.contains('stickers') && !CdnConstants.allowedExtensionsForSickers.contains(format)) { - throw Exception('Cannot use other extensions than ${CdnConstants.allowedExtensionsForSickers.and()} for stickers'); - } - - var uri = Uri.https('cdn.${Constants.cdnHost}', '${fragments.path}.${animated ? 'gif' : format}'); - - if (size != null) { - uri = uri.replace(queryParameters: {'size': size.toString()}); - } - - return uri.toString(); - } - - @override - String appAsset(Snowflake clientId, String assetHash, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..appAssets(id: clientId.toString()) - ..addHash(hash: assetHash), - format: format, - size: size, - ); - - @override - String appIcon(Snowflake clientId, String iconHash, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..appIcons(id: clientId.toString()) - ..addHash(hash: iconHash), - format: format, - size: size, - ); - - @override - String avatar(Snowflake id, String avatarHash, {String format = 'webp', int? size, bool animated = false}) => _makeAnimatedCdnUrl( - ICdnHttpRoute() - ..avatars(id: id.toString()) - ..addHash(hash: avatarHash), - avatarHash, - format: format, - size: size, - animated: animated, - ); - - @override - String banner(Snowflake guildOrUserId, String hash, {String format = 'webp', int? size, bool animated = false}) => _makeAnimatedCdnUrl( - ICdnHttpRoute() - ..banners(id: guildOrUserId.toString()) - ..addHash(hash: hash), - hash, - format: format, - size: size, - animated: animated, - ); - - @override - String channelIcon(Snowflake channelId, String iconHash, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..channelIcons(id: channelId.toString()) - ..addHash(hash: iconHash), - format: format, - size: size, - ); - - @override - String defaultAvatar(int discriminator) => _makeCdnUrl( - ICdnHttpRoute() - ..embed() - ..avatars() - ..addHash(hash: (discriminator % 5).toString()), - format: 'png', - ); - - @override - String discoverySplash(Snowflake guildId, String splashHash, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..discoverySplashes(id: guildId.toString()) - ..addHash(hash: splashHash), - format: format, - size: size, - ); - - @override - String memberAvatar(Snowflake guildId, Snowflake userId, String avatarHash, {String format = 'webp', int? size, bool animated = false}) => - _makeAnimatedCdnUrl( - ICdnHttpRoute() - ..guilds(id: guildId.toString()) - ..users(id: userId.toString()) - ..avatars(id: avatarHash), - avatarHash, - format: format, - size: size, - animated: animated, - ); - - @override - String emoji(Snowflake emojiId, {String format = 'webp', int? size}) => - _makeCdnUrl(ICdnHttpRoute()..emojis(id: emojiId.toString()), format: format, size: size); - - @override - String icon(Snowflake id, String iconHash, {String format = 'webp', int? size, bool animated = false}) => _makeAnimatedCdnUrl( - ICdnHttpRoute() - ..icons(id: id.toString()) - ..addHash(hash: iconHash), - iconHash, - format: format, - size: size, - animated: animated, - ); - - @override - String roleIcon(Snowflake roleId, String roleIconHash, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..roleIcons(id: roleId.toString()) - ..addHash(hash: roleIconHash), - format: format, - size: size, - ); - - @override - String splash(Snowflake guildId, String splashHash, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..splashes(id: guildId.toString()) - ..addHash(hash: splashHash), - format: format, - size: size, - ); - @override - String sticker(Snowflake stickerId, {String format = 'png'}) => _makeCdnUrl( - ICdnHttpRoute()..stickers(id: stickerId.toString()), - format: format, - ); - - @override - String stickerPackBanner(Snowflake bannerId, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..appAssets(id: '710982414301790216') - ..store(id: bannerId.toString()), - format: format, - size: size, - ); - - @override - String teamIcon(Snowflake teamId, String teamIconHash, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..teamIcons(id: teamId.toString()) - ..addHash(hash: teamIconHash), - format: format, - size: size, - ); - - @override - String guildEventCoverImage(Snowflake eventId, String eventCoverHash, {String format = 'webp', int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..guildEvents(id: eventId.toString()) - ..addHash(hash: eventCoverHash), - format: format, - size: size, - ); - - @override - String avatarDecoration(Snowflake userId, String decorationHash, {int? size}) => _makeCdnUrl( - ICdnHttpRoute() - ..avatarDecorations(id: userId.toString()) - ..addHash(hash: decorationHash), - size: size, - ); -} diff --git a/lib/src/internal/connection_manager.dart b/lib/src/internal/connection_manager.dart deleted file mode 100644 index f4c37d1e8..000000000 --- a/lib/src/internal/connection_manager.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:logging/logging.dart'; -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/events/ready_event.dart'; -import 'package:nyxx/src/internal/event_controller.dart'; -import 'package:nyxx/src/internal/http_endpoints.dart'; -import 'package:nyxx/src/internal/http/http_response.dart'; -import 'package:nyxx/src/internal/shard/shard_manager.dart'; - -/// The WS manager for the client. -class ConnectionManager { - final NyxxWebsocket client; - - /// The base websocket URL. - late final String gateway; - - late final int remaining; - late final DateTime resetAt; - late final int recommendedShardsNum; - late final int maxConcurrency; - - final Logger _logger = Logger("Connection Manager"); - - int _shardsReady = 0; - - /// Makes a new WS manager. - ConnectionManager(this.client); - - Future connect() async { - final httpResponse = await (client.httpEndpoints as HttpEndpoints).getGatewayBot(); - - if (httpResponse is HttpResponseError) { - throw UnrecoverableNyxxError("Cannot get gateway url: $httpResponse"); - } - - final response = httpResponse as HttpResponseSuccess; - - gateway = response.jsonBody["url"] as String; - remaining = response.jsonBody["session_start_limit"]["remaining"] as int; - resetAt = DateTime.now().add(Duration(milliseconds: response.jsonBody["session_start_limit"]["reset_after"] as int)); - recommendedShardsNum = response.jsonBody["shards"] as int; - maxConcurrency = response.jsonBody["session_start_limit"]["max_concurrency"] as int; - - _logger.config([ - 'Got gateway info:', - 'Gateway URL: $gateway', - 'Remaining connections: $remaining (reset at $resetAt)', - 'Recommended shard count: $recommendedShardsNum', - 'Max concurrency: $maxConcurrency', - ].join('\n')); - - checkForConnections(); - - client.shardManager = ShardManager(this, maxConcurrency); - } - - void checkForConnections() { - _logger.info("Remaining $remaining connections starts. Limit will reset at $resetAt"); - - if (remaining < 50) { - _logger.warning("50 connection starts left."); - } - - if (remaining < 10) { - throw UnrecoverableNyxxError('Exiting nyxx to prevent API ban. Less than 10 connections to gateway ($remaining)'); - } - } - - Future propagateReady() async { - _shardsReady++; - if (client.ready || _shardsReady < client.shardManager.numShards) { - return; - } - - (client.eventsWs as WebsocketEventController).onReadyController.add(ReadyEvent(client)); - - client.ready = true; - _logger.info("Connected and ready! Logged as `${client.self.tag}`"); - } -} diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart deleted file mode 100644 index 807a2d0e9..000000000 --- a/lib/src/internal/constants.dart +++ /dev/null @@ -1,73 +0,0 @@ -/// Gateway constants -class OPCodes { - static const int dispatch = 0; - static const int heartbeat = 1; - static const int identify = 2; - static const int statusUpdate = 3; - static const int voiceStateUpdate = 4; - static const int voiceGuildPing = 5; - static const int resume = 6; - static const int reconnect = 7; - static const int requestGuildMember = 8; - static const int invalidSession = 9; - static const int hello = 10; - static const int heartbeatAck = 11; - static const int guildSync = 12; -} - -/// The client constants. -class Constants { - /// Discord CDN host - static const String cdnHost = "discordapp.com"; - - /// Url for cdn host - static const String cdnUrl = "https://cdn.${Constants.cdnHost}"; - - /// Discord API host - static const String host = "discord.com"; - - /// Base API uri - static const String baseUri = "/api/v$apiVersion"; - - /// Version of API - static const int apiVersion = 10; - - /// Version of Nyxx - static const String version = "5.0.0"; - - /// Url to Nyxx repo - static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; - - /// Returns [Uri] to gateway - static Uri gatewayUri(String gatewayHost, bool useCompression, [Encoding encoding = Encoding.json]) { - var uriString = "$gatewayHost?v=$apiVersion&encoding=${encoding.name}"; - - if (useCompression) { - uriString += "&compress=zlib-stream"; - } - - return Uri.parse(uriString); - } -} - -class CdnConstants { - /// The allowed extensions for the CDN urls. - static const List allowedExtensions = ['webp', 'png', 'gif', 'jpg', 'jpeg']; - - /// The allowed extensions for the stickers. - static const List allowedExtensionsForSickers = ['png', 'json']; - - /// The allowed sizes. - static const List allowedSizes = [16, 32, 48, 64, 80, 96, 128, 160, 240, 256, 320, 480, 512, 640, 1024, 1280, 1536, 2048, 3072, 4096]; -} - -/// The type of encoding to receive/send payloads to discord. -enum Encoding { - /// ETF (External Term Format) encoding - /// It's more performant on a ton of servers, use it if you want to optimise your bot scaling. - etf, - - /// JSON (JavaScript Object Notation) is an universal data transferring model, - /// it can be used on production too. - json -} diff --git a/lib/src/internal/event_controller.dart b/lib/src/internal/event_controller.dart deleted file mode 100644 index 675207e50..000000000 --- a/lib/src/internal/event_controller.dart +++ /dev/null @@ -1,753 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/src/events/channel_events.dart'; -import 'package:nyxx/src/events/disconnect_event.dart'; -import 'package:nyxx/src/events/guild_events.dart'; -import 'package:nyxx/src/events/http_events.dart'; -import 'package:nyxx/src/events/invite_events.dart'; -import 'package:nyxx/src/events/message_events.dart'; -import 'package:nyxx/src/events/presence_update_event.dart'; -import 'package:nyxx/src/events/ratelimit_event.dart'; -import 'package:nyxx/src/events/ready_event.dart'; -import 'package:nyxx/src/events/thread_create_event.dart'; -import 'package:nyxx/src/events/thread_deleted_event.dart'; -import 'package:nyxx/src/events/thread_list_sync_event.dart'; -import 'package:nyxx/src/events/thread_members_update_event.dart'; -import 'package:nyxx/src/events/typing_event.dart'; -import 'package:nyxx/src/events/user_update_event.dart'; -import 'package:nyxx/src/events/voice_server_update_event.dart'; -import 'package:nyxx/src/events/voice_state_update_event.dart'; -import 'package:nyxx/src/internal/interfaces/disposable.dart'; -import 'package:nyxx/src/nyxx.dart'; - -abstract class IRestEventController implements Disposable { - /// Emitted when a successful HTTP response is received. - Stream get onHttpResponse; - - /// Emitted when a HTTP request failed. - Stream get onHttpError; - - /// Sent when the client is rate limited, either by the rate limit handler itself, - /// or when a 429 is received. - Stream get onRateLimited; -} - -class RestEventController extends IRestEventController { - /// Emitted when a successful HTTP response is received. - late final StreamController onHttpResponseController; - - /// Emitted when a HTTP request failed. - late final StreamController onHttpErrorController; - - /// Sent when the client is rate limited, either by the rate limit handler itself, - /// or when a 429 is received. - late final StreamController onRateLimitedController; - - /// Emitted when a successful HTTP response is received. - @override - late final Stream onHttpResponse; - - /// Emitted when a HTTP request failed. - @override - late final Stream onHttpError; - - /// Sent when the client is rate limited, either by the rate limit handler itself, - /// or when a 429 is received. - @override - late final Stream onRateLimited; - - /// Creats an instance of [RestEventController] - RestEventController() { - onHttpErrorController = StreamController.broadcast(); - onHttpError = onHttpErrorController.stream; - - onHttpResponseController = StreamController.broadcast(); - onHttpResponse = onHttpResponseController.stream; - - onRateLimitedController = StreamController.broadcast(); - onRateLimited = onRateLimitedController.stream; - } - - @override - Future dispose() async { - await onRateLimitedController.close(); - await onHttpResponseController.close(); - await onRateLimitedController.close(); - } -} - -abstract class IWebsocketEventController implements IRestEventController { - /// Emitted when a shard is disconnected from the websocket. - Stream get onDisconnect; - - /// Emitted when the client is ready. Should be sent only once. - Stream get onReady; - - /// Emitted when a message is received. It includes private messages. - Stream get onMessageReceived; - - /// Emitted when private message is received. - Stream get onDmReceived; - - /// Emitted when channel"s pins are updated. - Stream get onChannelPinsUpdate; - - /// Emitted when guild"s emojis are changed. - Stream get onGuildEmojisUpdate; - - /// Emitted when a message is edited. Old message can be null if isn"t cached. - Stream get onMessageUpdate; - - /// Emitted when a message is deleted. - Stream get onMessageDelete; - - /// Emitted when a channel is created. - Stream get onChannelCreate; - - /// Emitted when a channel is updated. - Stream get onChannelUpdate; - - /// Emitted when a channel is deleted. - Stream get onChannelDelete; - - /// Emitted when a member is banned. - Stream get onGuildBanAdd; - - /// Emitted when a user is unbanned. - Stream get onGuildBanRemove; - - /// Emitted when the client joins a guild. - Stream get onGuildCreate; - - /// Emitted when a guild is updated. - Stream get onGuildUpdate; - - /// Emitted when the client leaves a guild. - Stream get onGuildDelete; - - /// Emitted when a member joins a guild. - Stream get onGuildMemberAdd; - - /// Emitted when a member joins a guild but is not yet screened by: - /// https://support.discord.com/hc/en-us/articles/1500000466882 - Stream get onGuildMemberAddScreening; - - /// Emitted when a member joins a guild but passed member screening - /// https://support.discord.com/hc/en-us/articles/1500000466882 - Stream get onGuildMemberAddPassedScreening; - - /// Emitted when a member is updated. - Stream get onGuildMemberUpdate; - - /// Emitted when a user leaves a guild. - Stream get onGuildMemberRemove; - - /// Emitted when a member"s presence is changed. - Stream get onPresenceUpdate; - - /// Emitted when a user starts typing. - Stream get onTyping; - - /// Emitted when a role is created. - Stream get onRoleCreate; - - /// Emitted when a role is updated. - Stream get onRoleUpdate; - - /// Emitted when a role is deleted. - Stream get onRoleDelete; - - /// Emitted when many messages are deleted at once - Stream get onMessageDeleteBulk; - - /// Emitted when a user adds a reaction to a message. - Stream get onMessageReactionAdded; - - /// Emitted when a user deletes a reaction to a message. - Stream get onMessageReactionRemove; - - /// Emitted when a user explicitly removes all reactions from a message. - Stream get onMessageReactionsRemoved; - - /// Emitted when someone joins/leaves/moves voice channel. - Stream get onVoiceStateUpdate; - - /// Emitted when a guild"s voice server is updated. - /// This is sent when initially connecting to voice, and when the current voice instance fails over to a new server. - Stream get onVoiceServerUpdate; - - /// Emitted when user was updated - Stream get onUserUpdate; - - /// Emitted when bot is mentioned - Stream get onSelfMention; - - /// Emitted when invite is created - Stream get onInviteCreated; - - /// Emitted when invite is deleted - Stream get onInviteDeleted; - - /// Emitted when a bot removes all instances of a given emoji from the reactions of a message - Stream get onMessageReactionRemoveEmoji; - - /// Emitted when a thread is created - Stream get onThreadCreated; - - /// Fired when a thread has a member added/removed - Stream get onThreadMembersUpdate; - - /// Fired when a thread gets deleted - Stream get onThreadDelete; - - /// Emitted when stage channel instance is created - Stream get onStageInstanceCreate; - - /// Emitted when stage channel instance is updated - Stream get onStageInstanceUpdate; - - /// Emitted when stage channel instance is deleted - Stream get onStageInstanceDelete; - - /// Emitted when stage channel instance is deleted - Stream get onGuildStickersUpdate; - - /// Emitted when stage channel instance is deleted - Stream get onGuildEventCreate; - - /// Emitted when stage channel instance is deleted - Stream get onGuildEventUpdate; - - /// Emitted when stage channel instance is deleted - Stream get onGuildEventDelete; - - /// Emitted when an auto moderation rule is created - Stream get onAutoModerationRuleCreate; - - /// Emitted when an auto moderation rule is updated - Stream get onAutoModerationRuleUpdate; - - /// Emitted when an auto moderation rule is deleted - Stream get onAutoModerationRuleDelete; - - /// Emitted when a webhook is created, updated or deleted. - Stream get onWebhookUpdate; - - /// Emitted when an auto moderation rule was triggered and an action was executed (e.g. a message was blocked). - Stream get onAutoModerationActionExecution; - - /// Sent when a guild audit log entry is created. - Stream get onAuditLogEntryCreate; - - /// Emitted when the thread member for the current user is updated in a guild. - Stream get onThreadMemberUpdate; - - /// Emitted when a thread the user is in is updated. - Stream get onThreadUpdate; - - /// Sent when the thread list for a guild is synchronised. - Stream get onThreadListSync; -} - -/// A controller for all events. -class WebsocketEventController extends RestEventController implements IWebsocketEventController { - late final StreamController onDisconnectController; - late final StreamController onReadyController; - late final StreamController onMessageReceivedController; - late final StreamController onChannelPinsUpdateController; - late final StreamController onGuildEmojisUpdateController; - late final StreamController onMessageUpdateController; - late final StreamController onMessageDeleteController; - late final StreamController onChannelCreateController; - late final StreamController onChannelUpdateController; - late final StreamController onChannelDeleteController; - late final StreamController onGuildBanAddController; - late final StreamController onGuildBanRemoveController; - late final StreamController onGuildCreateController; - late final StreamController onGuildUpdateController; - late final StreamController onGuildDeleteController; - late final StreamController onGuildMemberAddController; - late final StreamController onGuildMemberUpdateController; - late final StreamController onGuildMemberRemoveController; - late final StreamController onPresenceUpdateController; - late final StreamController onTypingController; - late final StreamController onRoleCreateController; - late final StreamController onRoleUpdateController; - late final StreamController onRoleDeleteController; - late final StreamController onMessageDeleteBulkController; - late final StreamController onMessageReactionAddedController; - late final StreamController onMessageReactionRemoveController; - late final StreamController onMessageReactionsRemovedController; - late final StreamController onVoiceStateUpdateController; - late final StreamController onVoiceServerUpdateController; - late final StreamController onUserUpdateController; - late final StreamController onInviteCreatedController; - late final StreamController onInviteDeleteController; - late final StreamController onMessageReactionRemoveEmojiController; - late final StreamController onThreadCreatedController; - late final StreamController onThreadMembersUpdateController; - late final StreamController onThreadDeleteController; - late final StreamController onStageInstanceCreateController; - late final StreamController onStageInstanceUpdateController; - late final StreamController onStageInstanceDeleteController; - late final StreamController onGuildStickersUpdateController; - late final StreamController onGuildEventCreateController; - late final StreamController onGuildEventDeleteController; - late final StreamController onGuildEventUpdateController; - late final StreamController onAutoModerationRuleCreateController; - late final StreamController onAutoModerationRuleUpdateController; - late final StreamController onAutoModerationRuleDeleteController; - late final StreamController onWebhookUpdateController; - late final StreamController onAutoModerationActionExecutionController; - late final StreamController onAuditLogEntryCreateController; - late final StreamController onThreadMemberUpdateController; - late final StreamController onThreadUpdateController; - late final StreamController onThreadListSyncController; - - /// Emitted when a shard is disconnected from the websocket. - @override - late final Stream onDisconnect; - - /// Emitted when the client is ready. Should be sent only once. - @override - late final Stream onReady; - - /// Emitted when a message is received. It includes private messages. - @override - late final Stream onMessageReceived; - - /// Emitted when private message is received. - @override - late final Stream onDmReceived = onMessageReceived.where((event) => event.message.guild == null); - - /// Emitted when channel"s pins are updated. - @override - late final Stream onChannelPinsUpdate; - - /// Emitted when guild"s emojis are changed. - @override - late final Stream onGuildEmojisUpdate; - - /// Emitted when a message is edited. Old message can be null if isn"t cached. - @override - late final Stream onMessageUpdate; - - /// Emitted when a message is deleted. - @override - late final Stream onMessageDelete; - - /// Emitted when a channel is created. - @override - late final Stream onChannelCreate; - - /// Emitted when a channel is updated. - @override - late final Stream onChannelUpdate; - - /// Emitted when a channel is deleted. - @override - late final Stream onChannelDelete; - - /// Emitted when a member is banned. - @override - late final Stream onGuildBanAdd; - - /// Emitted when a user is unbanned. - @override - late final Stream onGuildBanRemove; - - /// Emitted when the client joins a guild. - @override - late final Stream onGuildCreate; - - /// Emitted when a guild is updated. - @override - late final Stream onGuildUpdate; - - /// Emitted when the client leaves a guild. - @override - late final Stream onGuildDelete; - - /// Emitted when a member joins a guild. - @override - late final Stream onGuildMemberAdd; - - /// Emitted when a member is updated. - @override - late final Stream onGuildMemberUpdate; - - /// Emitted when a user leaves a guild. - @override - late final Stream onGuildMemberRemove; - - /// Emitted when a member"s presence is changed. - @override - late final Stream onPresenceUpdate; - - /// Emitted when a user starts typing. - @override - late final Stream onTyping; - - /// Emitted when a role is created. - @override - late final Stream onRoleCreate; - - /// Emitted when a role is updated. - @override - late final Stream onRoleUpdate; - - /// Emitted when a role is deleted. - @override - late final Stream onRoleDelete; - - /// Emitted when many messages are deleted at once - @override - late final Stream onMessageDeleteBulk; - - /// Emitted when a user adds a reaction to a message. - @override - late final Stream onMessageReactionAdded; - - /// Emitted when a user deletes a reaction to a message. - @override - late final Stream onMessageReactionRemove; - - /// Emitted when a user explicitly removes all reactions from a message. - @override - late final Stream onMessageReactionsRemoved; - - /// Emitted when someone joins/leaves/moves voice channel. - @override - late final Stream onVoiceStateUpdate; - - /// Emitted when a guild"s voice server is updated. - /// This is sent when initially connecting to voice, and when the current voice instance fails over to a new server. - @override - late final Stream onVoiceServerUpdate; - - /// Emitted when user was updated - @override - late final Stream onUserUpdate; - - /// Emitted when bot is mentioned - @override - late final Stream onSelfMention = - onMessageReceived.where((event) => event.message.mentions.map((e) => e.id).contains(_client.self.id)); - - /// Emitted when invite is created - @override - late final Stream onInviteCreated; - - /// Emitted when invite is deleted - @override - late final Stream onInviteDeleted; - - /// Emitted when a bot removes all instances of a given emoji from the reactions of a message - @override - late final Stream onMessageReactionRemoveEmoji; - - /// Emitted when a thread is created - @override - late final Stream onThreadCreated; - - /// Fired when a thread has a member added/removed - @override - late final Stream onThreadMembersUpdate; - - /// Fired when a thread gets deleted - @override - late final Stream onThreadDelete; - - /// Emitted when stage channel instance is created - @override - late final Stream onStageInstanceCreate; - - /// Emitted when stage channel instance is updated - @override - late final Stream onStageInstanceUpdate; - - /// Emitted when stage channel instance is deleted - @override - late final Stream onStageInstanceDelete; - - /// Emitted when stage channel instance is deleted - @override - late final Stream onGuildStickersUpdate; - - /// Guild scheduled event was created - @override - late final Stream onGuildEventCreate; - - /// Guild scheduled event was deleted - @override - late final Stream onGuildEventDelete; - - /// Guild scheduled event was updated - @override - late final Stream onGuildEventUpdate; - - @override - late final Stream onAutoModerationRuleCreate; - - @override - late final Stream onAutoModerationRuleUpdate; - - @override - late final Stream onAutoModerationRuleDelete; - - @override - late final Stream onWebhookUpdate; - - @override - late final Stream onAutoModerationActionExecution; - - @override - late final Stream onGuildMemberAddScreening; - - @override - late final Stream onGuildMemberAddPassedScreening; - - @override - late final Stream onAuditLogEntryCreate; - - @override - late final Stream onThreadMemberUpdate; - - @override - late final Stream onThreadUpdate; - - @override - late final Stream onThreadListSync; - - final INyxxWebsocket _client; - - /// Makes a new `EventController`. - WebsocketEventController(this._client) : super() { - onDisconnectController = StreamController.broadcast(); - onDisconnect = onDisconnectController.stream; - - onReadyController = StreamController.broadcast(); - onReady = onReadyController.stream; - - onMessageReceivedController = StreamController.broadcast(); - onMessageReceived = onMessageReceivedController.stream; - - onMessageUpdateController = StreamController.broadcast(); - onMessageUpdate = onMessageUpdateController.stream; - - onMessageDeleteController = StreamController.broadcast(); - onMessageDelete = onMessageDeleteController.stream; - - onChannelCreateController = StreamController.broadcast(); - onChannelCreate = onChannelCreateController.stream; - - onChannelUpdateController = StreamController.broadcast(); - onChannelUpdate = onChannelUpdateController.stream; - - onChannelDeleteController = StreamController.broadcast(); - onChannelDelete = onChannelDeleteController.stream; - - onGuildBanAddController = StreamController.broadcast(); - onGuildBanAdd = onGuildBanAddController.stream; - - onGuildBanRemoveController = StreamController.broadcast(); - onGuildBanRemove = onGuildBanRemoveController.stream; - - onGuildCreateController = StreamController.broadcast(); - onGuildCreate = onGuildCreateController.stream; - - onGuildUpdateController = StreamController.broadcast(); - onGuildUpdate = onGuildUpdateController.stream; - - onGuildDeleteController = StreamController.broadcast(); - onGuildDelete = onGuildDeleteController.stream; - - onGuildMemberAddController = StreamController.broadcast(); - onGuildMemberAdd = onGuildMemberAddController.stream; - - onGuildMemberUpdateController = StreamController.broadcast(); - onGuildMemberUpdate = onGuildMemberUpdateController.stream; - - onGuildMemberRemoveController = StreamController.broadcast(); - onGuildMemberRemove = onGuildMemberRemoveController.stream; - - onPresenceUpdateController = StreamController.broadcast(); - onPresenceUpdate = onPresenceUpdateController.stream; - - onTypingController = StreamController.broadcast(); - onTyping = onTypingController.stream; - - onRoleCreateController = StreamController.broadcast(); - onRoleCreate = onRoleCreateController.stream; - - onRoleUpdateController = StreamController.broadcast(); - onRoleUpdate = onRoleUpdateController.stream; - - onRoleDeleteController = StreamController.broadcast(); - onRoleDelete = onRoleDeleteController.stream; - - onChannelPinsUpdateController = StreamController.broadcast(); - onChannelPinsUpdate = onChannelPinsUpdateController.stream; - - onGuildEmojisUpdateController = StreamController.broadcast(); - onGuildEmojisUpdate = onGuildEmojisUpdateController.stream; - - onMessageDeleteBulkController = StreamController.broadcast(); - onMessageDeleteBulk = onMessageDeleteBulkController.stream; - - onMessageReactionAddedController = StreamController.broadcast(); - onMessageReactionAdded = onMessageReactionAddedController.stream; - - onMessageReactionRemoveController = StreamController.broadcast(); - onMessageReactionRemove = onMessageReactionRemoveController.stream; - - onMessageReactionsRemovedController = StreamController.broadcast(); - onMessageReactionsRemoved = onMessageReactionsRemovedController.stream; - - onVoiceStateUpdateController = StreamController.broadcast(); - onVoiceStateUpdate = onVoiceStateUpdateController.stream; - - onVoiceServerUpdateController = StreamController.broadcast(); - onVoiceServerUpdate = onVoiceServerUpdateController.stream; - - onUserUpdateController = StreamController.broadcast(); - onUserUpdate = onUserUpdateController.stream; - - onInviteCreatedController = StreamController.broadcast(); - onInviteCreated = onInviteCreatedController.stream; - - onInviteDeleteController = StreamController.broadcast(); - onInviteDeleted = onInviteDeleteController.stream; - - onMessageReactionRemoveEmojiController = StreamController.broadcast(); - onMessageReactionRemoveEmoji = onMessageReactionRemoveEmojiController.stream; - - onThreadCreatedController = StreamController.broadcast(); - onThreadCreated = onThreadCreatedController.stream; - - onThreadMembersUpdateController = StreamController.broadcast(); - onThreadMembersUpdate = onThreadMembersUpdateController.stream; - - onThreadDeleteController = StreamController.broadcast(); - onThreadDelete = onThreadDeleteController.stream; - - onStageInstanceCreateController = StreamController.broadcast(); - onStageInstanceCreate = onStageInstanceCreateController.stream; - - onStageInstanceUpdateController = StreamController.broadcast(); - onStageInstanceUpdate = onStageInstanceUpdateController.stream; - - onStageInstanceDeleteController = StreamController.broadcast(); - onStageInstanceDelete = onStageInstanceDeleteController.stream; - - onGuildStickersUpdateController = StreamController.broadcast(); - onGuildStickersUpdate = onGuildStickersUpdateController.stream; - - onGuildEventCreateController = StreamController.broadcast(); - onGuildEventCreate = onGuildEventCreateController.stream; - - onGuildEventUpdateController = StreamController.broadcast(); - onGuildEventUpdate = onGuildEventUpdateController.stream; - - onGuildEventDeleteController = StreamController.broadcast(); - onGuildEventDelete = onGuildEventDeleteController.stream; - - onAutoModerationRuleCreateController = StreamController.broadcast(); - onAutoModerationRuleCreate = onAutoModerationRuleCreateController.stream; - - onAutoModerationRuleUpdateController = StreamController.broadcast(); - onAutoModerationRuleUpdate = onAutoModerationRuleUpdateController.stream; - - onAutoModerationRuleDeleteController = StreamController.broadcast(); - onAutoModerationRuleDelete = onAutoModerationRuleDeleteController.stream; - - onWebhookUpdateController = StreamController.broadcast(); - onWebhookUpdate = onWebhookUpdateController.stream; - - onAutoModerationActionExecutionController = StreamController.broadcast(); - onAutoModerationActionExecution = onAutoModerationActionExecutionController.stream; - - onGuildMemberAddScreening = onGuildMemberAdd.where((event) => event.member.isPending); - onGuildMemberAddPassedScreening = onGuildMemberUpdate.where((event) => !(event.member.getFromCache()?.isPending ?? true)); - - onAuditLogEntryCreateController = StreamController.broadcast(); - onAuditLogEntryCreate = onAuditLogEntryCreateController.stream; - - onThreadMemberUpdateController = StreamController.broadcast(); - onThreadMemberUpdate = onThreadMemberUpdateController.stream; - - onThreadUpdateController = StreamController.broadcast(); - onThreadUpdate = onThreadUpdateController.stream; - - onThreadListSyncController = StreamController.broadcast(); - onThreadListSync = onThreadListSyncController.stream; - } - - @override - Future dispose() async { - await super.dispose(); - - await onDisconnectController.close(); - await onGuildUpdateController.close(); - await onReadyController.close(); - await onMessageReceivedController.close(); - await onMessageUpdateController.close(); - await onMessageDeleteController.close(); - await onChannelCreateController.close(); - await onChannelUpdateController.close(); - await onChannelDeleteController.close(); - await onGuildBanAddController.close(); - await onGuildBanRemoveController.close(); - await onGuildCreateController.close(); - await onGuildUpdateController.close(); - await onGuildDeleteController.close(); - await onGuildMemberAddController.close(); - await onGuildMemberUpdateController.close(); - await onGuildMemberRemoveController.close(); - await onPresenceUpdateController.close(); - await onTypingController.close(); - await onRoleCreateController.close(); - await onRoleUpdateController.close(); - await onRoleDeleteController.close(); - - await onChannelPinsUpdateController.close(); - await onGuildEmojisUpdateController.close(); - - await onMessageDeleteBulkController.close(); - await onMessageReactionAddedController.close(); - await onMessageReactionRemoveController.close(); - await onMessageReactionsRemovedController.close(); - await onVoiceStateUpdateController.close(); - await onVoiceServerUpdateController.close(); - await onMessageReactionRemoveEmojiController.close(); - - await onInviteCreatedController.close(); - await onInviteDeleteController.close(); - - await onUserUpdateController.close(); - - await onThreadCreatedController.close(); - await onThreadMembersUpdateController.close(); - await onThreadDeleteController.close(); - - await onGuildStickersUpdateController.close(); - - await onGuildEventCreateController.close(); - await onGuildEventUpdateController.close(); - await onGuildEventDeleteController.close(); - - await onAutoModerationRuleCreateController.close(); - await onAutoModerationRuleDeleteController.close(); - await onAutoModerationRuleUpdateController.close(); - - await onWebhookUpdateController.close(); - - await onAutoModerationActionExecutionController.close(); - - await onAuditLogEntryCreateController.close(); - - await onThreadMemberUpdateController.close(); - await onThreadUpdateController.close(); - await onThreadListSyncController.close(); - } -} diff --git a/lib/src/internal/exceptions/embed_builder_argument_exception.dart b/lib/src/internal/exceptions/embed_builder_argument_exception.dart deleted file mode 100644 index 62858fd44..000000000 --- a/lib/src/internal/exceptions/embed_builder_argument_exception.dart +++ /dev/null @@ -1,11 +0,0 @@ -/// Thrown when embed doesnt meet requirements to be valid -class EmbedBuilderArgumentException implements Exception { - /// Custom error message specific to context of exception - final String message; - - /// Creates an instance of [EmbedBuilderArgumentException] - EmbedBuilderArgumentException(this.message); - - @override - String toString() => "EmbedBuilderArgumentException: $message"; -} diff --git a/lib/src/internal/exceptions/invalid_shard_exception.dart b/lib/src/internal/exceptions/invalid_shard_exception.dart deleted file mode 100644 index 320d73b53..000000000 --- a/lib/src/internal/exceptions/invalid_shard_exception.dart +++ /dev/null @@ -1,11 +0,0 @@ -/// Thrown when operation is unsupported due invalid or wrong shard being accessed. -class InvalidShardException implements Exception { - /// Custom error message specific to context of exception - final String message; - - /// Creates an instance of [InvalidShardException] - InvalidShardException(this.message); - - @override - String toString() => "InvalidShardException: Unsupported shard operation: $message"; -} diff --git a/lib/src/internal/exceptions/invalid_snowflake_exception.dart b/lib/src/internal/exceptions/invalid_snowflake_exception.dart deleted file mode 100644 index a250eea9c..000000000 --- a/lib/src/internal/exceptions/invalid_snowflake_exception.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; - -/// Thrown when cannot convert provided data to [Snowflake] -class InvalidSnowflakeException implements Exception { - final dynamic _invalidSnowflake; - - /// Creates an instance of [InvalidSnowflakeException] - InvalidSnowflakeException(this._invalidSnowflake); - - @override - String toString() => "InvalidSnowflakeException: Cannot parse [$_invalidSnowflake] to Snowflake"; -} diff --git a/lib/src/internal/exceptions/missing_token_error.dart b/lib/src/internal/exceptions/missing_token_error.dart deleted file mode 100644 index 5cd866e17..000000000 --- a/lib/src/internal/exceptions/missing_token_error.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// Thrown when token is empty or null -class MissingTokenError implements Error { - /// Returns a string representation of this object. - @override - String toString() => "MissingTokenError: Token is null or empty!"; - - @override - StackTrace? get stackTrace => StackTrace.empty; -} diff --git a/lib/src/internal/exceptions/unknown_enum_value.dart b/lib/src/internal/exceptions/unknown_enum_value.dart deleted file mode 100644 index 5f1a9ede4..000000000 --- a/lib/src/internal/exceptions/unknown_enum_value.dart +++ /dev/null @@ -1,10 +0,0 @@ -/// Thrown when a parsing method of an enum failed. -class UnknownEnumValueError extends Error { - final Object value; - - /// Creates a new instance of [UnknownEnumValueError]. - UnknownEnumValueError(this.value); - - @override - String toString() => 'Unknown enum value: ${Error.safeToString(value)}'; -} diff --git a/lib/src/internal/exceptions/unrecoverable_nyxx_error.dart b/lib/src/internal/exceptions/unrecoverable_nyxx_error.dart deleted file mode 100644 index d81a185de..000000000 --- a/lib/src/internal/exceptions/unrecoverable_nyxx_error.dart +++ /dev/null @@ -1,11 +0,0 @@ -class UnrecoverableNyxxError implements Error { - final String message; - - UnrecoverableNyxxError(this.message); - - @override - StackTrace? get stackTrace => StackTrace.current; - - @override - String toString() => "UnrecoverableNyxxError: $message"; -} diff --git a/lib/src/internal/http/http_bucket.dart b/lib/src/internal/http/http_bucket.dart deleted file mode 100644 index b663a68e3..000000000 --- a/lib/src/internal/http/http_bucket.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:logging/logging.dart'; - -import 'package:nyxx/src/internal/http/http_request.dart'; - -class HttpBucket { - static const String xRateLimitBucket = "x-ratelimit-bucket"; - static const String xRateLimitLimit = "x-ratelimit-limit"; - static const String xRateLimitRemaining = "x-ratelimit-remaining"; - static const String xRateLimitReset = "x-ratelimit-reset"; - static const String xRateLimitResetAfter = "x-ratelimit-reset-after"; - - int _remaining; - DateTime _reset; - Duration _resetAfter; - final String _bucketId; - - final List _inFlightRequests = []; - - int get remaining => _remaining - _inFlightRequests.length; - - DateTime get reset => _reset; - - Duration get resetAfter => _resetAfter; - - String get id => _bucketId; - - late final Logger _logger = Logger('HttpBucket $id'); - - HttpBucket(this._remaining, this._reset, this._resetAfter, this._bucketId); - - static HttpBucket? fromResponseSafe(http.StreamedResponse response) { - final limit = getLimitFromHeaders(response.headers); - final remaining = getRemainingFromHeaders(response.headers); - final reset = getResetFromHeaders(response.headers); - final resetAfter = getResetAfterFromHeaders(response.headers); - final bucketId = getBucketIdFromHeaders(response.headers); - - if (limit == null || remaining == null || reset == null || resetAfter == null || bucketId == null) { - return null; - } - - return HttpBucket(remaining, reset, resetAfter, bucketId); - } - - static String? getBucketIdFromHeaders(Map headers) => headers[xRateLimitBucket]; - - static int? getLimitFromHeaders(Map headers) => headers[xRateLimitLimit] == null ? null : int.parse(headers[xRateLimitLimit]!); - - static int? getRemainingFromHeaders(Map headers) => headers[xRateLimitRemaining] == null ? null : int.parse(headers[xRateLimitRemaining]!); - - // Server-Client clock drift makes headers.reset useless, build reset from headers.resetAfter and DateTime.now() - static DateTime? getResetFromHeaders(Map headers) => - headers[xRateLimitResetAfter] == null ? null : DateTime.now().add(getResetAfterFromHeaders(headers)!); - - static Duration? getResetAfterFromHeaders(Map headers) => - headers[xRateLimitResetAfter] == null ? null : Duration(milliseconds: (double.parse(headers[xRateLimitResetAfter]!) * 1000).ceil()); - - void addInFlightRequest(HttpRequest httpRequest) => _inFlightRequests.add(httpRequest); - - void removeInFlightRequest(HttpRequest httpRequest) => _inFlightRequests.remove(httpRequest); - - bool isInBucket(http.StreamedResponse response) { - return getBucketIdFromHeaders(response.headers) == _bucketId; - } - - void updateRateLimit(http.StreamedResponse response) { - if (isInBucket(response)) { - _logger.finest('Updating bucket'); - - _remaining = getRemainingFromHeaders(response.headers) ?? _remaining; - - _reset = getResetFromHeaders(response.headers) ?? _reset; - - _resetAfter = getResetAfterFromHeaders(response.headers) ?? _resetAfter; - - _logger.finest([ - 'Remaining: $_remaining', - 'Reset at: $_reset', - 'Reset after: $_resetAfter', - ].join('\n')); - } - } -} diff --git a/lib/src/internal/http/http_handler.dart b/lib/src/internal/http/http_handler.dart deleted file mode 100644 index a233e2402..000000000 --- a/lib/src/internal/http/http_handler.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:http/http.dart' as http; - -import 'package:logging/logging.dart'; -import 'package:nyxx/src/events/http_events.dart'; -import 'package:nyxx/src/events/ratelimit_event.dart'; -import 'package:nyxx/src/internal/event_controller.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/internal/http/http_bucket.dart'; -import 'package:nyxx/src/internal/http/http_request.dart'; -import 'package:nyxx/src/internal/http/http_response.dart'; -import 'package:nyxx/src/utils/utils.dart'; - -class HttpHandler { - late final http.Client httpClient; - - final Logger logger = Logger("Http"); - final INyxxRest client; - - RestEventController get _events => client.eventsRest as RestEventController; - - final Map _bucketByRequestRateLimitId = {}; - DateTime globalRateLimitReset = DateTime.fromMillisecondsSinceEpoch(0); - - /// Creates an instance of [HttpHandler] - HttpHandler(this.client) { - httpClient = http.Client(); - } - - HttpBucket? _upsertBucket(HttpRequest request, http.StreamedResponse response) { - //Get or Create Bucket - final bucket = _bucketByRequestRateLimitId.values.toList().firstWhereSafe((bucket) => bucket.isInBucket(response)) ?? HttpBucket.fromResponseSafe(response); - //Update Bucket - bucket?.updateRateLimit(response); - - //Update request -> bucket mapping - if (bucket != null) { - _bucketByRequestRateLimitId.update( - request.rateLimitId, - (b) => bucket, - ifAbsent: () => bucket, - ); - } - - return bucket; - } - - Future execute(HttpRequest request) async { - if (request.auth) { - request.headers.addAll({"Authorization": "Bot ${client.token}"}); - } - - HttpBucket? currentBucket = _bucketByRequestRateLimitId[request.rateLimitId]; - - logger.fine('Executing request $request'); - logger.finer([ - 'Headers: ${request.headers}', - 'Authenticated: ${request.auth}', - if (request.auditLog != null) 'Audit Log Reason: ${request.auditLog}', - 'Global rate limit: ${request.globalRateLimit}', - 'Rate limit ID: ${request.rateLimitId}', - 'Rate limit bucket: ${currentBucket?.id}', - if (currentBucket != null) ...[ - 'Reset at: ${currentBucket.reset}', - 'Reset after: ${currentBucket.resetAfter}', - 'Remaining: ${currentBucket.remaining}', - ], - if (request is BasicRequest) 'Request body: ${request.body}', - if (request is MultipartRequest) ...[ - 'Request body: ${request.fields}', - 'Files: ${request.files.map((file) => file.filename).join(', ')}', - ], - ].join('\n')); - - // Get actual time and check if request can be executed based on data that bucket already have - // and wait if rate limit could be possibly hit - final now = DateTime.now(); - final globalWaitTime = request.globalRateLimit ? globalRateLimitReset.difference(now) : Duration.zero; - final bucketWaitTime = (currentBucket?.remaining ?? 1) > 0 ? Duration.zero : currentBucket!.reset.difference(now); - final waitTime = globalWaitTime.compareTo(bucketWaitTime) > 0 ? globalWaitTime : bucketWaitTime; - - if (globalWaitTime > Duration.zero) { - logger.warning("Global rate limit reached on endpoint: ${request.uri}"); - } - - if (bucketWaitTime > Duration.zero) { - logger.warning("Bucket rate limit reached on endpoint: ${request.uri}"); - } - - if (waitTime > Duration.zero) { - logger.warning("Trying to send request again in $waitTime"); - _events.onRateLimitedController.add(RatelimitEvent(request, true)); - return await Future.delayed(waitTime, () async => await execute(request)); - } - - // Execute request - currentBucket?.addInFlightRequest(request); - final response = await client.options.httpRetryOptions.retry( - () async => httpClient.send(await request.prepareRequest()), - onRetry: (ex) => logger.warning('Exception when sending HTTP request (retrying automatically)', ex), - ); - currentBucket?.removeInFlightRequest(request); - currentBucket = _upsertBucket(request, response); - return _handle(request, response); - } - - Future _handle(HttpRequest request, http.StreamedResponse response) async { - logger.fine('Handling response (${response.statusCode}) from request $request'); - logger.finer('Headers: ${response.headers}'); - - if (response.statusCode >= 200 && response.statusCode < 300) { - final responseSuccess = await HttpResponseSuccess.fromResponse(response); - - (client.eventsRest as RestEventController).onHttpResponseController.add(HttpResponseEvent(responseSuccess)); - logger.finest('Successful response: $responseSuccess'); - - return responseSuccess; - } - - final responseError = await HttpResponseError.fromResponse(response); - - // Check for 429, emit events and wait given in response body time - if (responseError.statusCode == 429) { - final responseBody = responseError.jsonBody; - final retryAfter = Duration(milliseconds: ((responseBody["retry_after"] as double) * 1000).ceil()); - final isGlobal = responseBody["global"] as bool; - - if (isGlobal) { - globalRateLimitReset = DateTime.now().add(retryAfter); - } - - _events.onRateLimitedController.add(RatelimitEvent(request, false, response)); - - logger.warning( - "${isGlobal ? "Global " : ""}Rate limited via 429 on endpoint: ${request.uri}. Trying to send request again in $retryAfter", - responseError, - ); - - return Future.delayed(retryAfter, () => execute(request)); - } - - (client.eventsRest as RestEventController).onHttpErrorController.add(HttpErrorEvent(responseError)); - logger.finest('Unknown/error response: ${responseError.toString(short: true)}', responseError); - - return responseError; - } -} diff --git a/lib/src/internal/http/http_request.dart b/lib/src/internal/http/http_request.dart deleted file mode 100644 index 4a935b037..000000000 --- a/lib/src/internal/http/http_request.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -import 'package:nyxx/src/internal/constants.dart'; -import 'package:nyxx/src/internal/http/http_route.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class HttpRequest { - late final Uri uri; - late final Map headers; - - final String method; - final RawApiMap? queryParams; - final String? auditLog; - - final bool auth; - final bool globalRateLimit; - final HttpRoute route; - String get rateLimitId => method + route.routeId; - - /// Creates and instance of [HttpRequest] - HttpRequest(this.route, {this.method = "GET", this.queryParams, Map? headers, this.auditLog, this.globalRateLimit = true, this.auth = true}) { - uri = Uri.https(Constants.host, Constants.baseUri + route.path); - this.headers = headers ?? {}; - } - - Map genHeaders() => - {...headers, if (auditLog != null) "X-Audit-Log-Reason": auditLog!, "User-Agent": "Nyxx (${Constants.repoUrl}, ${Constants.version})"}; - - Future prepareRequest(); - - @override - String toString() => '$method $uri'; -} - -/// [BasicRequest] with json body -class BasicRequest extends HttpRequest { - /// Body of request - final dynamic body; - - BasicRequest(HttpRoute route, - {String method = "GET", this.body, RawApiMap? queryParams, String? auditLog, Map? headers, bool globalRateLimit = true, bool auth = true}) - : super(route, method: method, queryParams: queryParams, auditLog: auditLog, headers: headers, globalRateLimit: globalRateLimit, auth: auth); - - @override - Future prepareRequest() async { - final request = http.Request(method, uri.replace(queryParameters: queryParams?.map((key, value) => MapEntry(key, value.toString())))) - ..headers.addAll(genHeaders()); - - if (body != null && method != "GET") { - request.headers.addAll(_getJsonContentTypeHeader()); - if (body is String) { - request.body = body as String; - } else if (body is RawApiMap || body is RawApiList) { - request.body = jsonEncode(body); - } - } - - return request; - } - - Map _getJsonContentTypeHeader() => {"Content-Type": "application/json"}; -} - -/// Request with which files will be sent. Cannot contain request body. -class MultipartRequest extends HttpRequest { - /// Files which will be sent - final List files; - - /// Additional data to sent - final dynamic fields; - - /// Creates an instance of [MultipartRequest] - MultipartRequest(HttpRoute route, this.files, - {this.fields, - String method = "GET", - RawApiMap? queryParams, - Map? headers, - String? auditLog, - bool auth = true, - bool globalRateLimit = true}) - : super(route, method: method, queryParams: queryParams, headers: headers, auditLog: auditLog, globalRateLimit: globalRateLimit, auth: auth); - - @override - Future prepareRequest() async { - final request = http.MultipartRequest(method, uri.replace(queryParameters: queryParams?.map((key, value) => MapEntry(key, value.toString())))) - ..headers.addAll(genHeaders()); - - request.files.addAll(files); - - if (fields != null) { - request.fields.addAll({"payload_json": jsonEncode(fields)}); - } - - return request; - } -} diff --git a/lib/src/internal/http/http_response.dart b/lib/src/internal/http/http_response.dart deleted file mode 100644 index a321b33b3..000000000 --- a/lib/src/internal/http/http_response.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:http/http.dart' as http; -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx/src/internal/response_wrapper/error_response_wrapper.dart'; - -/// Represents a HTTP result from the API. -abstract class IHttpResponse { - /// The status code of the response. - int get statusCode; - - /// The headers associated with the response. - Map get headers; - - /// The body of this response. - /// - /// See also: - /// - [textBody], for getting the body decoded as a [String]. - /// - [jsonBody], for decoding the body decoded as JSON. - Uint8List get body; - - /// The body of this response, decoded as UTF-8. - /// - /// Will be `null` if the body was not valid UTF-8. - /// - /// See also: - /// - [jsonBody], for getting the body decoded as JSON. - String? get textBody; - - /// The body of this response, decoded as JSON. - /// - /// Will be `null` is the body was not valid JSON, and [hasJsonBody] will be set to `false`. - dynamic get jsonBody; - - /// Whether this response has a JSON body. - bool get hasJsonBody; -} - -abstract class HttpResponse implements IHttpResponse { - @override - final int statusCode; - - @override - final Map headers; - - @override - final Uint8List body; - - @override - late final String? textBody; - - @override - late final dynamic jsonBody; - - @override - late final bool hasJsonBody; - - final http.BaseResponse response; - http.BaseRequest get request => response.request!; - - HttpResponse({ - required this.response, - required this.body, - }) : statusCode = response.statusCode, - headers = response.headers { - String? textBody; - dynamic jsonBody; - bool hasJsonBody = false; - - try { - textBody = utf8.decode(body); - jsonBody = jsonDecode(textBody); - hasJsonBody = true; - } on FormatException { - // ignore: Invalid format, leave the defaults - } - - this.textBody = textBody; - this.jsonBody = jsonBody; - this.hasJsonBody = hasJsonBody; - } - - @override - String toString() => '$statusCode (${response.reasonPhrase}) ${request.method} ${request.url}'; -} - -/// A successful HTTP response. -abstract class IHttpResponseSuccess implements IHttpResponse {} - -class HttpResponseSuccess extends HttpResponse implements IHttpResponseSuccess { - HttpResponseSuccess({required super.body, required super.response}); - - static Future fromResponse(http.StreamedResponse response) async => HttpResponseSuccess( - body: await response.stream.toBytes(), - response: response, - ); -} - -abstract class IHttpResponseError implements IHttpResponse, Exception { - /// Message why http request failed - String get message; - - /// Error code of response - /// - /// If Discord sets its own status code, this can be found here. Otherwise, this is equal to [statusCode]. - int get errorCode; - - /// Additional information about the error, if any. - IHttpErrorData? get errorData; -} - -/// Returned when client fails to execute http request. -/// Will contain reason why request failed. -class HttpResponseError extends HttpResponse implements IHttpResponseError { - @override - String get message => errorData?.errorMessage ?? response.reasonPhrase ?? textBody!; - - @override - int get errorCode => errorData?.errorCode ?? statusCode; - - @override - late final HttpErrorData? errorData; - - HttpResponseError({required super.body, required super.response}) { - HttpErrorData? errorData; - if (hasJsonBody) { - try { - errorData = HttpErrorData(jsonBody as RawApiMap); - } on TypeError { - // ignore: Response was not a valid error object. We'll just fall back to the response status code and message. - } - } - - this.errorData = errorData; - } - - static Future fromResponse(http.StreamedResponse response) async => HttpResponseError( - body: await response.stream.toBytes(), - response: response, - ); - - @override - String toString({bool short = false}) { - if (short) { - return super.toString(); - } - - final result = StringBuffer('$message ($errorCode) ${request.method} ${request.url}\n'); - - if (errorData?.fieldErrors.isNotEmpty ?? false) { - result.writeln('Errors:'); - - for (final field in errorData!.fieldErrors.values) { - result.writeln(' ${field.name}: ${field.errorMessage} (${field.errorCode})'); - } - } - - // Trim trailing newline - return result.toString().trim(); - } -} diff --git a/lib/src/internal/http/http_route.dart b/lib/src/internal/http/http_route.dart deleted file mode 100644 index b04f95855..000000000 --- a/lib/src/internal/http/http_route.dart +++ /dev/null @@ -1,403 +0,0 @@ -import 'http_route_param.dart'; -import 'http_route_part.dart'; - -/// Builds routes according to Discord's dynamic bucket rate limiting scheme. -/// -/// Use builder syntax such as: -/// ```dart -/// var route = HttpRoute()..guilds(id: id)..members()..search(); -/// ``` -/// to keep route definitions brief while reusing route rate limiting definitions. -/// If creating custom routes with [add], remember to comply with Discord's -/// rate limiting scheme by toggling the appropriate [HttpRouteParam.isMajor]. -abstract class IHttpRoute { - /// Creates a new empty [IHttpRoute]. - factory IHttpRoute() = HttpRoute; - - /// Adds a [HttpRoutePart] to this [IHttpRoute]. - void add(HttpRoutePart httpRoutePart); - - /// Adds the [`guilds`](https://discord.com/developers/docs/resources/guild#get-guild) part to this [IHttpRoute]. - void guilds({String? id}); - - /// Adds the [`channels`](https://discord.com/developers/docs/resources/channel#get-channel) part to this [IHttpRoute]. - void channels({String? id}); - - /// Adds the [`webhooks`](https://discord.com/developers/docs/resources/webhook#get-webhook) part to this [IHttpRoute]. - void webhooks({String? id, String? token}); - - /// Adds the [`reactions`](https://discord.com/developers/docs/resources/channel#get-reactions) part to this [IHttpRoute]. - void reactions({String? emoji, String? userId}); - - /// Adds the [`emojis`](https://discord.com/developers/docs/resources/emoji#get-guild-emoji) part to this [IHttpRoute]. - void emojis({String? id}); - - /// Adds the [`roles`](https://discord.com/developers/docs/resources/guild#get-guild-roles) part to this [IHttpRoute]. - void roles({String? id}); - - /// Adds the [`members`](https://discord.com/developers/docs/resources/guild#get-guild-member) part to this [IHttpRoute]. - void members({String? id}); - - /// Adds the [`bans`](https://discord.com/developers/docs/resources/guild#get-guild-bans) part to this [IHttpRoute]. - void bans({String? id}); - - /// Adds the [`users`](https://discord.com/developers/docs/resources/user#get-user) part to this [IHttpRoute]. - void users({String? id}); - - /// Adds the [`permissions`](https://discord.com/developers/docs/interactions/application-commands#get-guild-application-command-permissions) part to this [IHttpRoute]. - void permissions({String? id}); - - /// Adds the [`messages`](https://discord.com/developers/docs/resources/channel#get-channel-messages) part to this [IHttpRoute]. - void messages({String? id}); - - /// Adds the [`pins`](https://discord.com/developers/docs/resources/channel#get-pinned-messages) part to this [IHttpRoute]. - void pins({String? id}); - - /// Adds the [`invites`](https://discord.com/developers/docs/resources/guild#get-guild-invites) part to this [IHttpRoute]. - void invites({String? id}); - - /// Adds the [`applications`](https://discord.com/developers/docs/topics/oauth2#get-current-bot-application-information) part to this [IHttpRoute]. - void applications({String? id}); - - /// Adds the [`stage-instances`](https://discord.com/developers/docs/resources/stage-instance#get-stage-instance) part to this [IHttpRoute]. - void stageInstances({String? id}); - - /// Adds the [`thread-members`](https://discord.com/developers/docs/resources/channel#get-thread-member) part to this [IHttpRoute]. - void threadMembers({String? id}); - - /// Adds the [`stickers`](https://discord.com/developers/docs/resources/sticker#get-sticker) part to this [IHttpRoute]. - void stickers({String? id}); - - /// Adds the [`scheduled-events`](https://discord.com/developers/docs/resources/guild-scheduled-event#get-guild-scheduled-event) part to this [IHttpRoute]. - void scheduledEvents({String? id}); - - /// Adds the [`rules`](https://discord.com/developers/docs/resources/auto-moderation#get-auto-moderation-rule) part to this [IHttpRoute]. - void rules({String? id}); - - /// Adds the [`prune`](https://discord.com/developers/docs/resources/guild#get-guild-prune-count) part to this [IHttpRoute]. - void prune(); - - /// Adds the [`nick`](https://discord.com/developers/docs/resources/guild#modify-current-user-nick) part to this [IHttpRoute]. - void nick(); - - /// Adds the [`audit-logs`](https://discord.com/developers/docs/resources/audit-log#get-guild-audit-log) part to this [IHttpRoute]. - void auditlogs(); - - /// Adds the [`regions`](https://discord.com/developers/docs/resources/voice#list-voice-regions) part to this [IHttpRoute]. - void regions(); - - /// Adds the [`search`](https://discord.com/developers/docs/resources/guild#search-guild-members) part to this [IHttpRoute]. - void search(); - - /// Adds the [`bulk-delete`](https://discord.com/developers/docs/resources/channel#bulk-delete-messages) part to this [IHttpRoute]. - void bulkdelete(); - - /// Adds the [`typing`](https://discord.com/developers/docs/resources/channel#trigger-typing-indicator) part to this [IHttpRoute]. - void typing(); - - /// Adds the [`crosspost`](https://discord.com/developers/docs/resources/channel#crosspost-message) part to this [IHttpRoute]. - void crosspost(); - - /// Adds the [`threads`](https://discord.com/developers/docs/resources/channel#start-thread-from-message) part to this [IHttpRoute]. - void threads(); - - /// Adds the [`gateway`](https://discord.com/developers/docs/topics/gateway#get-gateway) part to this [IHttpRoute]. - void gateway(); - - /// Adds the [`bot`](https://discord.com/developers/docs/topics/gateway#get-gateway-bot) part to this [IHttpRoute]. - void bot(); - - /// Adds the [`oauth2`](https://discord.com/developers/docs/topics/oauth2#get-current-authorization-information) part to this [IHttpRoute]. - void oauth2(); - - /// Adds the [`preview`](https://discord.com/developers/docs/resources/guild#get-guild-preview) part to this [IHttpRoute]. - void preview(); - - /// Adds the [`active`](https://discord.com/developers/docs/resources/guild#list-active-guild-threads) part to this [IHttpRoute]. - void active(); - - /// Adds the [`archived`](https://discord.com/developers/docs/resources/channel#list-public-archived-threads) part to this [IHttpRoute]. - void archived(); - - /// Adds the [`private`](https://discord.com/developers/docs/resources/channel#list-private-archived-threads) part to this [IHttpRoute]. - void private(); - - /// Adds the [`public`](https://discord.com/developers/docs/resources/channel#list-public-archived-threads) part to this [IHttpRoute]. - void public(); - - /// Adds the [`sticker-packs`](https://discord.com/developers/docs/resources/sticker#list-nitro-sticker-packs) part to this [IHttpRoute]. - void stickerpacks(); - - /// Adds the [`welcome-screen`](https://discord.com/developers/docs/resources/guild#get-guild-welcome-screen) part to this [IHttpRoute]. - void welcomeScreen(); - - /// Adds the [`auto-moderation`](https://discord.com/developers/docs/resources/auto-moderation#list-auto-moderation-rules-for-guild) part to this [IHttpRoute]. - void autoModeration(); -} - -class HttpRoute implements IHttpRoute { - final List _httpRouteParts = []; - - List get pathSegments => _httpRouteParts - .expand((part) => [ - part.path, - ...part.params.map((param) => param.param), - ]) - .toList(); - - String get path => "/${pathSegments.join("/")}"; - - String get routeId => _httpRouteParts - .expand((part) => [ - part.path, - ...List.generate( - part.params.length, - (index) => part.params[index].isMajor ? part.params[index].param : r"$param", - ), - ]) - .join("/"); - - @override - void add(HttpRoutePart httpRoutePart) => _httpRouteParts.add(httpRoutePart); - - @override - void guilds({String? id}) => add(HttpRoutePart("guilds", [if (id != null) HttpRouteParam(id, isMajor: true)])); - - @override - void channels({String? id}) => add(HttpRoutePart("channels", [if (id != null) HttpRouteParam(id, isMajor: true)])); - - @override - void webhooks({String? id, String? token}) => _httpRouteParts.add(HttpRoutePart("webhooks", [ - if (id != null) HttpRouteParam(id, isMajor: token != null), - if (token != null) HttpRouteParam(token, isMajor: id != null), - ])); - - @override - void reactions({String? emoji, String? userId}) => add(HttpRoutePart("reactions", [ - if (emoji != null) HttpRouteParam(emoji), - if (userId != null) HttpRouteParam(userId), - ])); - - @override - void emojis({String? id}) => add(HttpRoutePart("emojis", [if (id != null) HttpRouteParam(id)])); - - @override - void roles({String? id}) => add(HttpRoutePart("roles", [if (id != null) HttpRouteParam(id)])); - - @override - void members({String? id}) => add(HttpRoutePart("members", [if (id != null) HttpRouteParam(id)])); - - @override - void bans({String? id}) => add(HttpRoutePart("bans", [if (id != null) HttpRouteParam(id)])); - - @override - void users({String? id}) => add(HttpRoutePart("users", [if (id != null) HttpRouteParam(id)])); - - @override - void permissions({String? id}) => add(HttpRoutePart("permissions", [if (id != null) HttpRouteParam(id)])); - - @override - void messages({String? id}) => add(HttpRoutePart("messages", [if (id != null) HttpRouteParam(id)])); - - @override - void pins({String? id}) => add(HttpRoutePart("pins", [if (id != null) HttpRouteParam(id)])); - - @override - void invites({String? id}) => add(HttpRoutePart("invites", [if (id != null) HttpRouteParam(id)])); - - @override - void applications({String? id}) => add(HttpRoutePart("applications", [if (id != null) HttpRouteParam(id)])); - - @override - void stageInstances({String? id}) => add(HttpRoutePart("stage-instances", [if (id != null) HttpRouteParam(id)])); - - @override - void threadMembers({String? id}) => add(HttpRoutePart("thread-members", [if (id != null) HttpRouteParam(id)])); - - @override - void stickers({String? id}) => add(HttpRoutePart("stickers", [if (id != null) HttpRouteParam(id)])); - - @override - void scheduledEvents({String? id}) => add(HttpRoutePart("scheduled-events", [if (id != null) HttpRouteParam(id)])); - - @override - void rules({String? id}) => add(HttpRoutePart('rules', [if (id != null) HttpRouteParam(id)])); - - @override - void prune() => add(HttpRoutePart("prune")); - - @override - void nick() => add(HttpRoutePart("nick")); - - @override - void auditlogs() => add(HttpRoutePart("audit-logs")); - - @override - void regions() => add(HttpRoutePart("regions")); - - @override - void search() => add(HttpRoutePart("search")); - - @override - void bulkdelete() => add(HttpRoutePart("bulk-delete")); - - @override - void typing() => add(HttpRoutePart("typing")); - - @override - void crosspost() => add(HttpRoutePart("crosspost")); - - @override - void threads() => add(HttpRoutePart("threads")); - - @override - void gateway() => add(HttpRoutePart("gateway")); - - @override - void bot() => add(HttpRoutePart("bot")); - - @override - void oauth2() => add(HttpRoutePart("oauth2")); - - @override - void preview() => add(HttpRoutePart("preview")); - - @override - void active() => add(HttpRoutePart("active")); - - @override - void archived() => add(HttpRoutePart("archived")); - - @override - void private() => add(HttpRoutePart("private")); - - @override - void public() => add(HttpRoutePart("public")); - - @override - void stickerpacks() => add(HttpRoutePart("sticker-packs")); - - @override - void welcomeScreen() => add(HttpRoutePart('welcome-screen')); - - @override - void autoModeration() => add(HttpRoutePart('auto-moderation')); -} - -/// Build static cdn routes that are not constrained by rate-limits. -abstract class ICdnHttpRoute implements IHttpRoute { - factory ICdnHttpRoute() = CdnHttpRoute; - - /// Adds a [CdnHttpRoutePart] to this [ICdnHttpRoute]. - @override - void add(HttpRoutePart part); - - /// Adds the `app-assets` part to this [ICdnHttpRoute]. - void appAssets({required String id}); - - /// Adds the `app-icons` part to this [ICdnHttpRoute]. - void appIcons({required String id}); - - /// Adds the hash to any [ICdnHttpRoute]. - /// - /// This route is generated dynamically and does not well conform to [CdnHttpRoutePart.path]. - void addHash({required String hash}); - - /// Adds the `avatars` part to this [ICdnHttpRoute]. - void avatars({String? id}); - - /// Adds the `banners` part to this [ICdnHttpRoute]. - void banners({required String id}); - - /// Adds the `channel-icons` part to this [ICdnHttpRoute]. - void channelIcons({required String id}); - - /// Adds the `discovery-splashes` part to this [ICdnHttpRoute]. - void discoverySplashes({required String id}); - - /// Adds the `embed` part to this [ICdnHttpRoute]. - void embed(); - - /// Adds the `icons` part to this [ICdnHttpRoute]. - void icons({required String id}); - - /// Adds the `role-icons` part to this [ICdnHttpRoute]. - void roleIcons({required String id}); - - /// Adds the `splashes` part to this [ICdnHttpRoute]. - void splashes({required String id}); - - /// Adds the `store` part to this [ICdnHttpRoute]. - void store({String? id}); - - /// Adds the `team-icons` part to this [ICdnHttpRoute]. - void teamIcons({required String id}); - - /// Adds the `guild-events` part to this [ICdnHttpRoute]. - void guildEvents({required String id}); - - /// Adds the `avatar-decorations` part to this [ICdnHttpRoute]. - void avatarDecorations({required String id}); -} - -class CdnHttpRoute extends HttpRoute implements ICdnHttpRoute { - final List _httpCdnRouteParts = []; - - @override - List get pathSegments => _httpCdnRouteParts - .expand((part) => [ - part.path, - ...part.params.map((param) => param.param), - ]) - .toList(); - - @override - // Cannot use "covariant CdnHttpRoutePart" here because some methods are called from [HttpRoute]; therefore throw an error as "HttpRoutePart" is not a subtype of "CdnHttpRoutePart". - // ignore: avoid_renaming_method_parameters - void add(/* covariant CdnHttpRoutePart */ HttpRoutePart cdnHttpRoutePart) => _httpCdnRouteParts.add(cdnHttpRoutePart); - - @override - void appAssets({required String id}) => add(CdnHttpRoutePart('app-assets', [CdnHttpRouteParam(id)])); - - @override - void addHash({required String hash}) => add(CdnHttpRoutePart(hash)); - - @override - void appIcons({required String id}) => add(CdnHttpRoutePart('app-icons', [CdnHttpRouteParam(id)])); - - @override - void avatars({String? id}) => add(CdnHttpRoutePart('avatars', [if (id != null) CdnHttpRouteParam(id)])); - - @override - void banners({required String id}) => add(CdnHttpRoutePart('banners', [CdnHttpRouteParam(id)])); - - @override - void channelIcons({required String id}) => add(CdnHttpRoutePart('channel-icons', [CdnHttpRouteParam(id)])); - - @override - void discoverySplashes({required String id}) => add(CdnHttpRoutePart('discovery-splashes', [CdnHttpRouteParam(id)])); - - @override - void embed() => add(CdnHttpRoutePart('embed')); - - @override - void icons({required String id}) => add(CdnHttpRoutePart('icons', [CdnHttpRouteParam(id)])); - - @override - void roleIcons({required String id}) => add(CdnHttpRoutePart('role-icons', [CdnHttpRouteParam(id)])); - - @override - void splashes({required String id}) => add(CdnHttpRoutePart('splashes', [CdnHttpRouteParam(id)])); - - @override - void store({String? id}) => add(CdnHttpRoutePart('store', [if (id != null) CdnHttpRouteParam(id)])); - - @override - void teamIcons({required String id}) => add(CdnHttpRoutePart('team-icons', [CdnHttpRouteParam(id)])); - - @override - void guildEvents({required String id}) => add(CdnHttpRoutePart('guild-events', [CdnHttpRouteParam(id)])); - - @override - void avatarDecorations({required String id}) => add(CdnHttpRoutePart('avatar-decorations', [CdnHttpRouteParam(id)])); -} diff --git a/lib/src/internal/http/http_route_param.dart b/lib/src/internal/http/http_route_param.dart deleted file mode 100644 index faf0d0d30..000000000 --- a/lib/src/internal/http/http_route_param.dart +++ /dev/null @@ -1,31 +0,0 @@ -/// Represents a HTTP route parameter. -/// -/// A HTTP route parameter is a URL fragment that contains data specific to an invocation of a route (i.e a guild or message id). -/// -/// In the Discord documentation, these are the parts of URLs {enclosed in curly braces}. -class HttpRouteParam { - /// The value of this parameter. - final String param; - - /// Whether this parameter is a major parameter. - /// - /// Major parameters influence Discord's rate limiting. Requests with different major parameters will go into separate buckets for rate limiting, whereas - /// routes with different minor parameters will use the same bucket. - final bool isMajor; - - HttpRouteParam(this.param, {this.isMajor = false}); -} - -/// Represents a HTTP CDN route. -/// -/// These routes does not complain to global/bucket rate-limits as they're static. -class CdnHttpRouteParam implements HttpRouteParam { - /// The value of this parameter. - @override - final String param; - - CdnHttpRouteParam(this.param); - - @override - bool get isMajor => throw UnimplementedError(); -} diff --git a/lib/src/internal/http/http_route_part.dart b/lib/src/internal/http/http_route_part.dart deleted file mode 100644 index b69b4b19f..000000000 --- a/lib/src/internal/http/http_route_part.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'http_route_param.dart'; - -/// Represents a HTTP route part. -/// -/// A HTTP route part is a route fragment (i.e /foo or /bar) followed by 0 or more parameters (i.e a guild or message id) that can change across invocations of -/// the route.. -class HttpRoutePart { - /// The unchanging part of this route part. - final String path; - - /// The parameters of this route. May change across invocations of this route. - final List params; - - HttpRoutePart(this.path, [this.params = const []]); -} - -/// Represents a static CDN HTTP route part. -class CdnHttpRoutePart implements HttpRoutePart { - /// The unchanging part of this route part. - @override - final String path; - - /// The parameters of this route. May change across invocations of this route. - @override - final List params; - - CdnHttpRoutePart(this.path, [this.params = const []]); -} diff --git a/lib/src/internal/http_endpoints.dart b/lib/src/internal/http_endpoints.dart deleted file mode 100644 index 475abeed6..000000000 --- a/lib/src/internal/http_endpoints.dart +++ /dev/null @@ -1,1898 +0,0 @@ -import 'package:nyxx/src/core/audit_logs/audit_log_entry.dart'; -import 'package:nyxx/src/core/guild/auto_moderation.dart'; -import 'package:nyxx/src/core/guild/scheduled_event.dart'; -import 'package:nyxx/src/internal/http/http_route.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/channel/invite.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/core/audit_logs/audit_log.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/dm_channel.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/core/channel/thread_preview_channel.dart'; -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/channel/guild/voice_channel.dart'; -import 'package:nyxx/src/core/guild/ban.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/guild_preview.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/guild/webhook.dart'; -import 'package:nyxx/src/core/guild/guild_welcome_screen.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/message/sticker.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/core/voice/voice_region.dart'; -import 'package:nyxx/src/internal/constants.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/internal/http/http_handler.dart'; -import 'package:nyxx/src/internal/http/http_request.dart'; -import 'package:nyxx/src/internal/http/http_response.dart'; -import 'package:nyxx/src/internal/response_wrapper/thread_list_result_wrapper.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; -import 'package:nyxx/src/utils/builders/auto_moderation_builder.dart'; -import 'package:nyxx/src/utils/builders/channel_builder.dart'; -import 'package:nyxx/src/utils/builders/forum_thread_builder.dart'; -import 'package:nyxx/src/utils/builders/guild_builder.dart'; -import 'package:nyxx/src/utils/builders/guild_event_builder.dart'; -import 'package:nyxx/src/utils/builders/member_builder.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; -import 'package:nyxx/src/utils/builders/permissions_builder.dart'; -import 'package:nyxx/src/utils/builders/sticker_builder.dart'; -import 'package:nyxx/src/utils/builders/thread_builder.dart'; -import 'package:nyxx/src/utils/utils.dart'; - -/// Raw access to all http endpoints exposed by nyxx. -/// Allows to execute specific action without any context. -abstract class IHttpEndpoints { - /// Creates an OAuth2 URL with the specified permissions. - String getApplicationInviteUrl(Snowflake applicationId, [int? permissions]); - - /// Returns url to guild widget for given [guildId]. Additionally accepts [style] parameter. - String getGuildWidgetUrl(Snowflake guildId, [String style = "shield"]); - - /// Allows to modify guild emoji. - Future editGuildEmoji(Snowflake guildId, Snowflake emojiId, {String? name, List? roles, AttachmentBuilder? avatarAttachment}); - - /// Removes emoji from given guild - Future deleteGuildEmoji(Snowflake guildId, Snowflake emojiId); - - /// Edits role using builder form [role] parameter - Future editRole(Snowflake guildId, Snowflake roleId, RoleBuilder role, {String? auditReason}); - - /// Deletes from with given [roleId] - Future deleteRole(Snowflake guildId, Snowflake roleId, {String? auditReason}); - - /// Adds role to user - Future addRoleToUser(Snowflake guildId, Snowflake roleId, Snowflake userId, {String? auditReason}); - - /// Fetches [Guild] object from API - Future fetchGuild(Snowflake guildId, {bool? withCounts = true}); - - /// Fetches [IChannel] from API. Channel cas be cast to wanted type using generics - Future fetchChannel(Snowflake id); - - /// Returns [BaseGuildEmoji] for given [emojiId] - Future fetchGuildEmoji(Snowflake guildId, Snowflake emojiId); - - /// Fetches a [IGuildWelcomeScreen] from the given [guildId] - Future fetchGuildWelcomeScreen(Snowflake guildId); - - /// Creates emoji in given guild - Future createEmoji(Snowflake guildId, String name, {List? roles, AttachmentBuilder? emojiAttachment}); - - /// Fetches a [IUser] that created the emoji from the given [emojiId] - Future fetchEmojiCreator(Snowflake guildId, Snowflake emojiId); - - /// Returns how many user will be pruned in prune operation - Future guildPruneCount(Snowflake guildId, int days, {Iterable? includeRoles}); - - /// Executes actual prune action, returning how many users were pruned. - Future guildPrune(Snowflake guildId, int days, {Iterable? includeRoles, String? auditReason}); - - /// Get all guild bans. - Stream getGuildBans(Snowflake guildId, {int limit = 1000, Snowflake? before, Snowflake? after}); - - Future modifyCurrentMember(Snowflake guildId, {String? nick}); - - /// Get [IBan] object for given [bannedUserId] - Future getGuildBan(Snowflake guildId, Snowflake bannedUserId); - - /// Changes guild owner of guild from bot to [member]. - /// Bot needs to be owner of guild to use that endpoint. - Future changeGuildOwner(Snowflake guildId, SnowflakeEntity member, {String? auditReason}); - - /// Leaves guild with given id - Future leaveGuild(Snowflake guildId); - - /// Creates a new guild. - Future createGuild(GuildBuilder builder); - - /// Returns list of all guild invites - Stream fetchGuildInvites(Snowflake guildId); - - /// Creates an activity invite - Future createVoiceActivityInvite(Snowflake activityId, Snowflake channelId, {int? maxAge, int? maxUses}); - - /// Fetches audit logs of guild - Future fetchAuditLogs(Snowflake guildId, {Snowflake? userId, AuditLogEntryType? auditType, Snowflake? before, int? limit}); - - /// Creates new role - Future createGuildRole(Snowflake guildId, RoleBuilder roleBuilder, {String? auditReason}); - - /// Returns list of all voice regions that guild has access to - Stream fetchGuildVoiceRegions(Snowflake guildId); - - /// Moves guild channel in hierachy. - Future moveGuildChannel(Snowflake guildId, Snowflake channelId, int position, {String? auditReason}); - - /// Ban user with given id - Future guildBan(Snowflake guildId, Snowflake userId, {int deleteMessageDays = 0, String? auditReason}); - - /// Kick user from guild - Future guildKick(Snowflake guildId, Snowflake userId, {String? auditReason}); - - /// Unban user with given id - Future guildUnban(Snowflake guildId, Snowflake userId); - - /// Allows to edit basic guild properties - Future editGuild(Snowflake guildId, GuildBuilder builder, {String? auditReason}); - - /// Fetches [Member] object from guild - Future fetchGuildMember(Snowflake guildId, Snowflake memberId); - - /// Fetches list of members from guild. - /// Requires GUILD_MEMBERS intent to work properly. - Stream fetchGuildMembers(Snowflake guildId, {int limit = 1, Snowflake? after}); - - /// Searches guild for user with [query] parameter - /// Requires GUILD_MEMBERS intent to work properly. - Stream searchGuildMembers(Snowflake guildId, String query, {int limit = 1}); - - /// Returns all [Webhook]s in given channel - Stream fetchChannelWebhooks(Snowflake channelId); - - /// Deletes guild. Requires bot to be owner of guild - Future deleteGuild(Snowflake guildId); - - /// Returns all roles of guild - Stream fetchGuildRoles(Snowflake guildId); - - /// Fetches [User] object for given [userId] - Future fetchUser(Snowflake userId); - - /// "Edits" guild member. Allows to manipulate other guild users. - Future editGuildMember(Snowflake guildId, Snowflake memberId, {required MemberBuilder builder, String? auditReason}); - - /// Removes role from user - Future removeRoleFromUser(Snowflake guildId, Snowflake roleId, Snowflake userId, {String? auditReason}); - - /// Returns invites for given channel. Includes additional metadata. - Stream fetchChannelInvites(Snowflake channelId); - - /// Allows to edit permission for channel - Future editChannelPermissions(Snowflake channelId, PermissionsBuilder perms, SnowflakeEntity entity, {String? auditReason}); - - /// Allows to edit permission of channel (channel overrides) - Future editChannelPermissionOverrides(Snowflake channelId, PermissionOverrideBuilder permissionBuilder, {String? auditReason}); - - /// Deletes permission overrides for given entity [id] - Future deleteChannelPermission(Snowflake channelId, SnowflakeEntity id, {String? auditReason}); - - /// Creates new invite for given [channelId] - Future createInvite(Snowflake channelId, {int? maxAge, int? maxUses, bool? temporary, bool? unique, String? auditReason}); - - /// Sends message in channel with given [channelId] using [builder] - Future sendMessage(Snowflake channelId, MessageBuilder builder); - - /// Fetches single message with given [messageId] - Future fetchMessage(Snowflake channelId, Snowflake messageId); - - /// Bulk removes messages in given [channelId]. - Future bulkRemoveMessages(Snowflake channelId, Iterable messagesIds); - - /// Downloads messages in given channel. - Stream downloadMessages(Snowflake channelId, {int limit = 50, Snowflake? after, Snowflake? before, Snowflake? around}); - - /// Crates new webhook - Future createWebhook(Snowflake channelId, String name, {AttachmentBuilder? avatarAttachment, String? auditReason}); - - /// Returns all pinned messages in channel - Stream fetchPinnedMessages(Snowflake channelId); - - /// Triggers typing indicator in channel - Future triggerTyping(Snowflake channelId); - - /// Cross posts message in new channel to all subsribed channels - Future crossPostGuildMessage(Snowflake channelId, Snowflake messageId); - - /// Sends message and creates new thread in one action. - Future createThreadWithMessage(Snowflake channelId, Snowflake messageId, ThreadBuilder builder); - - /// Creates new thread. - Future createThread(Snowflake channelId, ThreadBuilder builder); - - /// Returns all member of given thread - /// Returns [IThreadMemberWithMember] when [withMembers] set to true - Stream fetchThreadMembers(Snowflake channelId, Snowflake guildId, {bool withMembers = false, Snowflake? after, int limit = 100}); - - /// Fetches single thread member - /// Returns [IThreadMemberWithMember] when [withMembers] set to true - Future fetchThreadMember(Snowflake channelId, Snowflake guildId, Snowflake memberId, {bool withMembers = false}); - - /// Joins thread with given id - Future joinThread(Snowflake channelId); - - /// Adds member to thread given bot has sufficient permissions - Future addThreadMember(Snowflake channelId, Snowflake userId); - - /// Leave thread with given id - Future leaveThread(Snowflake channelId); - - /// Removes member from thread given bot has sufficient permissions - Future removeThreadMember(Snowflake channelId, Snowflake userId); - - /// Returns all public archived thread in given channel - Future fetchPublicArchivedThreads(Snowflake channelId, {DateTime? before, int? limit}); - - /// Returns all private archived thread in given channel - Future fetchPrivateArchivedThreads(Snowflake channelId, {DateTime? before, int? limit}); - - /// Returns all joined private archived thread in given channel - Future fetchJoinedPrivateArchivedThreads(Snowflake channelId, {DateTime? before, int? limit}); - - /// Returns all active threads in the guild, including public and private threads. - /// Threads are ordered by their id, in descending order. - Future fetchGuildActiveThreads(Snowflake guildId); - - /// Removes all embeds from given message - Future suppressMessageEmbeds(Snowflake channelId, Snowflake messageId); - - /// Edits message with given id using [builder] - Future editMessage(Snowflake channelId, Snowflake messageId, MessageBuilder builder); - - /// Edits message sent by webhook - Future editWebhookMessage(Snowflake webhookId, Snowflake messageId, MessageBuilder builder, {String? token, Snowflake? threadId}); - - /// Creates reaction with given [emoji] on given message - Future createMessageReaction(Snowflake channelId, Snowflake messageId, IEmoji emoji); - - /// Deletes the bot's reaction with a given [emoji] from message - Future deleteMessageReaction(Snowflake channelId, Snowflake messageId, IEmoji emoji); - - /// Deletes all reactions of given user from message. - Future deleteMessageUserReaction(Snowflake channelId, Snowflake messageId, IEmoji emoji, Snowflake userId); - - /// Deletes all reactions on given message - Future deleteMessageAllReactions(Snowflake channelId, Snowflake messageId); - - /// Fetches all reactions with a given emoji on a message - Stream fetchMessageReactionUsers( - Snowflake channelId, - Snowflake messageId, - IEmoji emoji, { - Snowflake? after, - int? limit, - }); - - /// Deletes all reactions with a given emoji on a message - Future deleteMessageReactions(Snowflake channelId, Snowflake messageId, IEmoji emoji); - - /// Deletes message from given channel - Future deleteMessage(Snowflake channelId, Snowflake messageId, {String? auditReason}); - - /// Deletes message sent by webhook - Future deleteWebhookMessage(Snowflake webhookId, Snowflake messageId, {String? auditReason, String? token, Snowflake? threadId}); - - /// Pins message in channel - Future pinMessage(Snowflake channelId, Snowflake messageId); - - /// Unpins message from channel - Future unpinMessage(Snowflake channelId, Snowflake messageId); - - /// Edits self user. - Future editSelfUser({String? username, AttachmentBuilder? avatarAttachment}); - - /// Deletes invite with given [code] - Future deleteInvite(String code, {String? auditReason}); - - /// Deletes webhook with given [id] using bot permissions or [token] if supplied - Future deleteWebhook(Snowflake id, {String token = "", String? auditReason}); - - Future editWebhook(Snowflake webhookId, - {String token = "", String? name, SnowflakeEntity? channel, AttachmentBuilder? avatarAttachment, String? auditReason}); - - /// Executes [Webhook] -- sends message using [Webhook] - /// To execute webhook in thread use [threadId] parameter. - /// Webhooks can have overridden [avatarUrl] and [username] per each - /// execution. - /// - /// If [wait] is set to true -- request will return resulting message. - Future executeWebhook(Snowflake webhookId, MessageBuilder builder, - {String token = "", bool wait = true, String? avatarUrl, String? username, Snowflake? threadId, String? threadName}); - - /// Fetches webhook using its [id] and optionally [token]. - /// If [token] is specified it will be used to fetch webhook data. - /// If not authenticated or missing permissions - /// for given webhook token can be used. - Future fetchWebhook(Snowflake id, {String token = ""}); - - /// Fetches invite based on specified [code] - Future fetchInvite(String code); - - /// Creates and returns [DMChannel] for user with given [userId]. - Future createDMChannel(Snowflake userId); - - /// Used to send a request including standard bot authentication. - Future sendRawRequest(IHttpRoute route, String method, - {dynamic body, - Map? headers, - List files = const [], - Map? queryParams, - bool auth = false, - bool rateLimit = true}); - - /// Fetches preview of guild - Future fetchGuildPreview(Snowflake guildId); - - /// Allows to create guild channel. - Future createGuildChannel(Snowflake guildId, ChannelBuilder channelBuilder); - - /// Deletes guild channel - Future deleteChannel(Snowflake channelId); - - /// Gets the stage instance associated with the Stage channel, if it exists. - Future getStageChannelInstance(Snowflake channelId); - - /// Deletes the Stage instance. - Future deleteStageChannelInstance(Snowflake channelId); - - /// Creates a new Stage instance associated to a Stage channel. - Future createStageChannelInstance(Snowflake channelId, String topic, {StageChannelInstancePrivacyLevel? privacyLevel}); - - /// Updates fields of an existing Stage instance. - Future updateStageChannelInstance(Snowflake channelId, String topic, {StageChannelInstancePrivacyLevel? privacyLevel}); - - /// Allows to edit guild channel. Resulting updated channel can by cast using generics - Future editGuildChannel(Snowflake channelId, ChannelBuilder builder, {String? auditReason}); - - /// Allows editing thread channel. - Future editThreadChannel(Snowflake channelId, ThreadBuilder builder, {String auditReason}); - - /// Returns single nitro sticker - Future getSticker(Snowflake id); - - /// Returns all nitro sticker packs - Stream listNitroStickerPacks(); - - /// Fetches all [GuildSticker]s in given [Guild] - Stream fetchGuildStickers(Snowflake guildId); - - /// Fetches [GuildSticker] - Future fetchGuildSticker(Snowflake guildId, Snowflake stickerId); - - /// Creates [GuildSticker] in given [Guild] - Future createGuildSticker(Snowflake guildId, StickerBuilder builder); - - /// Edits [GuildSticker]. Only allows to update sticker metadata - Future editGuildSticker(Snowflake guildId, Snowflake stickerId, StickerBuilder builder); - - /// Deletes [GuildSticker] for [Guild] - Future deleteGuildSticker(Snowflake guildId, Snowflake stickerId); - - Stream fetchGuildEvents(Snowflake guildId, {bool withUserCount = false}); - - Future createGuildEvent(Snowflake guildId, GuildEventBuilder builder); - - Future fetchGuildEvent(Snowflake guildId, Snowflake guildEventId); - - Future editGuildEvent(Snowflake guildId, Snowflake guildEventId, GuildEventBuilder builder); - - Future deleteGuildEvent(Snowflake guildId, Snowflake guildEventId); - - Stream fetchGuildEventUsers(Snowflake guildId, Snowflake guildEventId, - {int limit = 100, bool withMember = false, Snowflake? before, Snowflake? after}); - - Future startForumThread(Snowflake channelId, ForumThreadBuilder builder); - - Stream fetchAutoModerationRules(Snowflake guildId); - - Future fetchAutoModerationRule(Snowflake guildId, Snowflake ruleId); - - Future createAutoModerationRule(Snowflake guildId, AutoModerationRuleBuilder builder, {String? auditReason}); - - Future editAutoModerationRule(Snowflake guildId, Snowflake ruleId, AutoModerationRuleBuilder builder, {String? auditReason}); - - Future deleteAutoModerationRule(Snowflake guildId, Snowflake ruleId, {String? auditReason}); -} - -class HttpEndpoints implements IHttpEndpoints { - late final HttpHandler httpHandler; - final INyxx client; - - /// Creates an instance of [HttpEndpoints] - HttpEndpoints(this.client) { - httpHandler = client.httpHandler; - } - - Future executeSafe(HttpRequest request) async { - final response = await httpHandler.execute(request); - - if (response is! HttpResponseSuccess) { - return Future.error(response, StackTrace.current); - } - - return response; - } - - @override - String getApplicationInviteUrl(Snowflake applicationId, [int? permissions]) { - var baseLink = "https://${Constants.host}/oauth2/authorize?client_id=${applicationId.toString()}&scope=bot%20applications.commands"; - - if (permissions != null) { - baseLink += "&permissions=$permissions"; - } - - return baseLink; - } - - @override - String getGuildWidgetUrl(Snowflake guildId, [String style = "shield"]) => "https://cdn.${Constants.cdnHost}/guilds/$guildId/widget.png?style=$style"; - - @override - Future editGuildEmoji(Snowflake guildId, Snowflake emojiId, {String? name, List? roles, AttachmentBuilder? avatarAttachment}) async { - if (name == null && roles == null) { - return throw ArgumentError("Both name and roles fields cannot be null"); - } - - final body = { - if (name != null) "name": name, - if (roles != null) "roles": roles.map((r) => r.toString()).toList(), - if (avatarAttachment != null) "avatar": avatarAttachment.getBase64() - }; - - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..emojis(id: emojiId.toString()), - method: "PATCH", - body: body)); - - return GuildEmoji(client, response.jsonBody as RawApiMap, guildId); - } - - @override - Future deleteGuildEmoji(Snowflake guildId, Snowflake emojiId) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..emojis(id: emojiId.toString()), - method: "DELETE")); - - @override - Future editRole(Snowflake guildId, Snowflake roleId, RoleBuilder role, {String? auditReason}) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..roles(id: roleId.toString()), - method: "PATCH", - body: role.build(), - auditLog: auditReason)); - - return Role(client, response.jsonBody as RawApiMap, guildId); - } - - @override - Future startForumThread(Snowflake channelId, ForumThreadBuilder builder) async { - final response = await executeSafe( - BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threads(), - method: "POST", - body: builder.build(), - ), - ); - - return ThreadChannel(client, response.jsonBody as RawApiMap); - } - - @override - Future deleteRole(Snowflake guildId, Snowflake roleId, {String? auditReason}) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..roles(id: roleId.toString()), - method: "DELETE", - auditLog: auditReason)); - - @override - Future addRoleToUser(Snowflake guildId, Snowflake roleId, Snowflake userId, {String? auditReason}) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..members(id: userId.toString()) - ..roles(id: roleId.toString()), - method: "PUT", - auditLog: auditReason)); - - @override - Future fetchGuild(Snowflake guildId, {bool? withCounts = true}) async { - final response = - await executeSafe(BasicRequest(HttpRoute()..guilds(id: guildId.toString()), queryParams: {"with_counts": (withCounts ?? true).toString()})); - - return Guild(client, response.jsonBody as RawApiMap); - } - - @override - Future fetchChannel(Snowflake id) async { - final response = await executeSafe(BasicRequest(HttpRoute()..channels(id: id.toString()))); - - final raw = response.jsonBody as RawApiMap; - return Channel.deserialize(client, raw) as T; - } - - @override - Future fetchGuildEmoji(Snowflake guildId, Snowflake emojiId) async { - final response = await executeSafe(BasicRequest(HttpRoute() - ..guilds(id: guildId.toString()) - ..emojis(id: emojiId.toString()))); - - return GuildEmoji(client, response.jsonBody as RawApiMap, guildId); - } - - @override - Future fetchGuildWelcomeScreen(Snowflake guildId) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..welcomeScreen(), - )); - - return GuildWelcomeScreen(response.jsonBody as RawApiMap, client); - } - - @override - Future createEmoji(Snowflake guildId, String name, {List? roles, AttachmentBuilder? emojiAttachment}) async { - final body = { - "name": name, - if (roles != null) "roles": roles.map((r) => r.id.toString()).toList(), - if (emojiAttachment != null) "image": emojiAttachment.getBase64() - }; - - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..emojis(), - method: "POST", - body: body)); - - return GuildEmoji(client, response.jsonBody as RawApiMap, guildId); - } - - @override - Future fetchEmojiCreator(Snowflake guildId, Snowflake emojiId) async { - final response = await executeSafe(BasicRequest(HttpRoute() - ..guilds(id: guildId.toString()) - ..emojis(id: emojiId.toString()))); - - if (response.jsonBody["managed"] as bool) { - throw ArgumentError("Emoji is managed"); - } - - if (response.jsonBody["user"] == null) { - throw ArgumentError("Could not find user creator, make sure you have the correct permissions"); - } - - final user = User(client, response.jsonBody["user"] as RawApiMap); - - if (client.cacheOptions.userCachePolicyLocation.http) { - return client.users.putIfAbsent(user.id, () => user); - } - - return user; - } - - @override - Future guildPruneCount(Snowflake guildId, int days, {Iterable? includeRoles}) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..prune(), - queryParams: {"days": days.toString(), if (includeRoles != null) "include_roles": includeRoles.map((e) => e.id.toString())})); - - return response.jsonBody["pruned"] as int; - } - - @override - Future guildPrune(Snowflake guildId, int days, {Iterable? includeRoles, String? auditReason}) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..prune(), - method: "POST", - auditLog: auditReason, - queryParams: {"days": days.toString()}, - body: {if (includeRoles != null) "include_roles": includeRoles.map((e) => e.id.toString())})); - - return response.jsonBody["pruned"] as int; - } - - @override - Stream getGuildBans(Snowflake guildId, {int limit = 1000, Snowflake? before, Snowflake? after}) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..bans(), - queryParams: { - "limit": limit, - if (before != null) "before": before, - if (after != null) "after": after, - }, - )); - - for (final obj in response.jsonBody as RawApiList) { - yield Ban(obj as RawApiMap, client); - } - } - - @override - Future modifyCurrentMember(Snowflake guildId, {String? nick}) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..members(id: "@me") - ..nick(), - method: "PATCH", - body: {if (nick != null) "nick": nick})); - - @override - Future getGuildBan(Snowflake guildId, Snowflake bannedUserId) async { - final response = await executeSafe(BasicRequest(HttpRoute() - ..guilds(id: guildId.toString()) - ..bans(id: bannedUserId.toString()))); - - return Ban(response.jsonBody as RawApiMap, client); - } - - @override - Future changeGuildOwner(Snowflake guildId, SnowflakeEntity member, {String? auditReason}) async { - final response = await executeSafe(BasicRequest( - HttpRoute()..guilds(id: guildId.toString()), - method: "PATCH", - auditLog: auditReason, - body: { - "owner_id": member.id.toString(), - }, - )); - - return Guild(client, response.jsonBody as RawApiMap); - } - - @override - Future leaveGuild(Snowflake guildId) async => executeSafe(BasicRequest( - HttpRoute() - ..users(id: "@me") - ..guilds(id: guildId.toString()), - method: "DELETE")); - - @override - Future createGuild(GuildBuilder builder) async { - final response = await executeSafe(BasicRequest(HttpRoute()..guilds(), method: "POST", body: builder.build())); - - final guild = Guild(client, response.jsonBody as RawApiMap); - client.guilds[guild.id] = guild; - return guild; - } - - @override - Stream fetchGuildInvites(Snowflake guildId) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..invites(), - )); - - for (final raw in response.jsonBody as RawApiList) { - yield Invite(raw as RawApiMap, client); - } - } - - @override - Future createVoiceActivityInvite(Snowflake activityId, Snowflake channelId, {int? maxAge, int? maxUses}) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..invites(), - method: "POST", - body: { - "max_age": maxAge ?? 0, - "max_uses": maxUses ?? 0, - "target_application_id": activityId.toString(), - "target_type": 2, - })); - - return Invite(response.jsonBody as RawApiMap, client); - } - - @override - Future fetchAuditLogs(Snowflake guildId, {Snowflake? userId, AuditLogEntryType? auditType, Snowflake? before, int? limit}) async { - final queryParams = { - if (userId != null) "user_id": userId.toString(), - if (auditType != null) "action_type": auditType.value.toString(), - if (before != null) "before": before.toString(), - if (limit != null) "limit": limit.toString() - }; - - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..auditlogs(), - queryParams: queryParams)); - - return AuditLog(response.jsonBody as RawApiMap, client); - } - - @override - Future createGuildRole(Snowflake guildId, RoleBuilder roleBuilder, {String? auditReason}) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..roles(), - method: "POST", - auditLog: auditReason, - body: roleBuilder.build())); - - return Role(client, response.jsonBody as RawApiMap, guildId); - } - - @override - Stream fetchGuildVoiceRegions(Snowflake guildId) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..regions(), - )); - - for (final raw in response.jsonBody as RawApiList) { - yield VoiceRegion(raw as RawApiMap); - } - } - - @override - Future moveGuildChannel(Snowflake guildId, Snowflake channelId, int position, {String? auditReason}) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..channels(), - method: "PATCH", - auditLog: auditReason, - body: {"id": channelId.toString(), "position": position})); - - @override - Future guildBan(Snowflake guildId, Snowflake userId, {int deleteMessageDays = 0, String? auditReason}) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..bans(id: userId.toString()), - method: "PUT", - auditLog: auditReason, - body: {"delete-message-days": deleteMessageDays})); - - @override - Future guildKick(Snowflake guildId, Snowflake userId, {String? auditReason}) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..members(id: userId.toString()), - method: "DELETE", - auditLog: auditReason)); - - @override - Future guildUnban(Snowflake guildId, Snowflake userId) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..bans(id: userId.toString()), - method: "DELETE")); - - @override - Future editGuild(Snowflake guildId, GuildBuilder builder, {String? auditReason}) async { - final response = - await executeSafe(BasicRequest(HttpRoute()..guilds(id: guildId.toString()), method: "PATCH", auditLog: auditReason, body: builder.build())); - - return Guild(client, response.jsonBody as RawApiMap); - } - - @override - Future fetchGuildMember(Snowflake guildId, Snowflake memberId) async { - final response = await executeSafe(BasicRequest(HttpRoute() - ..guilds(id: guildId.toString()) - ..members(id: memberId.toString()))); - - final member = Member(client, response.jsonBody as RawApiMap, guildId); - - if (client.cacheOptions.memberCachePolicyLocation.http && client.cacheOptions.memberCachePolicy.canCache(member)) { - member.guild.getFromCache()?.members[member.id] = member; - } - - return member; - } - - @override - Stream fetchGuildMembers(Snowflake guildId, {int limit = 1, Snowflake? after}) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..members(), - queryParams: {"limit": limit.toString(), if (after != null) "after": after.toString()}, - )); - - for (final rawMember in response.jsonBody as RawApiList) { - final member = Member(client, rawMember as RawApiMap, guildId); - - if (client.cacheOptions.memberCachePolicyLocation.http && client.cacheOptions.memberCachePolicy.canCache(member)) { - member.guild.getFromCache()?.members[member.id] = member; - } - - yield member; - } - } - - @override - Stream searchGuildMembers(Snowflake guildId, String query, {int limit = 1}) async* { - if (query.isEmpty) { - throw ArgumentError("`query` parameter cannot be empty. If you want to request all members use `fetchGuildMembers`"); - } - - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..members() - ..search(), - queryParams: {"query": query, "limit": limit.toString()}, - )); - - for (final RawApiMap memberData in response.jsonBody as RawApiListOfMaps) { - final member = Member(client, memberData, guildId); - - if (client.cacheOptions.memberCachePolicyLocation.http && client.cacheOptions.memberCachePolicy.canCache(member)) { - member.guild.getFromCache()?.members[member.id] = member; - } - - yield member; - } - } - - @override - Stream fetchChannelWebhooks(Snowflake channelId) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..webhooks(), - )); - - for (final raw in response.jsonBody as RawApiList) { - yield Webhook(raw as RawApiMap, client); - } - } - - @override - Future deleteGuild(Snowflake guildId) async => executeSafe(BasicRequest(HttpRoute()..guilds(id: guildId.toString()), method: "DELETE")); - - @override - Stream fetchGuildRoles(Snowflake guildId) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..roles(), - )); - - for (final rawRole in response.jsonBody as RawApiList) { - yield Role(client, rawRole as RawApiMap, guildId); - } - } - - @override - Future fetchUser(Snowflake userId) async { - final response = await executeSafe(BasicRequest(HttpRoute()..users(id: userId.toString()))); - - final user = User(client, response.jsonBody as RawApiMap); - - if (client.cacheOptions.userCachePolicyLocation.http) { - client.users[user.id] = user; - } - - return user; - } - - @override - Future editGuildMember(Snowflake guildId, Snowflake memberId, {required MemberBuilder builder, String? auditReason}) { - return executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..members(id: memberId.toString()), - method: "PATCH", - auditLog: auditReason, - body: builder.build())); - } - - @override - Future removeRoleFromUser(Snowflake guildId, Snowflake roleId, Snowflake userId, {String? auditReason}) async => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..members(id: userId.toString()) - ..roles(id: roleId.toString()), - method: "DELETE", - auditLog: auditReason)); - - @override - Stream fetchChannelInvites(Snowflake channelId) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..invites(), - )); - - final bodyValues = response.jsonBody; - - for (final val in bodyValues as RawApiList) { - yield InviteWithMeta(val as RawApiMap, client); - } - } - - @override - Future editChannelPermissions(Snowflake channelId, PermissionsBuilder perms, SnowflakeEntity entity, {String? auditReason}) async { - await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..permissions(id: entity.id.toString()), - method: "PUT", - body: {"type": entity is IRole ? 0 : 1, ...perms.build()}, - auditLog: auditReason)); - } - - @override - Future editChannelPermissionOverrides(Snowflake channelId, PermissionOverrideBuilder permissionBuilder, {String? auditReason}) async { - await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..permissions(id: permissionBuilder.id.toString()), - method: "PUT", - body: permissionBuilder.build(), - auditLog: auditReason)); - } - - @override - Future deleteChannelPermission(Snowflake channelId, SnowflakeEntity id, {String? auditReason}) async => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..permissions(id: id.toString()), - method: "PUT", - auditLog: auditReason)); - - @override - Future createInvite(Snowflake channelId, {int? maxAge, int? maxUses, bool? temporary, bool? unique, String? auditReason}) async { - final body = { - if (maxAge != null) "max_age": maxAge, - if (maxUses != null) "max_uses": maxUses, - if (temporary != null) "temporary": temporary, - if (unique != null) "unique": unique, - }; - - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..invites(), - method: "POST", - body: body, - auditLog: auditReason)); - - return InviteWithMeta(response.jsonBody as RawApiMap, client); - } - - @override - Future sendMessage(Snowflake channelId, MessageBuilder builder) async { - if (!builder.canBeUsedAsNewMessage()) { - throw ArgumentError("Cannot sent message when MessageBuilder doesn't have set either content, embed or files"); - } - - HttpResponseSuccess response; - if (builder.hasFiles()) { - response = await executeSafe(MultipartRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(), - builder.getMappedFiles().toList(), - method: "POST", - fields: builder.build(client.options.allowedMentions))); - } else { - response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(), - body: builder.build(client.options.allowedMentions), - method: "POST")); - } - - return Message(client, response.jsonBody as RawApiMap); - } - - @override - Future fetchMessage(Snowflake channelId, Snowflake messageId) async { - final response = await executeSafe(BasicRequest(HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()))); - - return Message(client, response.jsonBody as RawApiMap); - } - - @override - Future bulkRemoveMessages(Snowflake channelId, Iterable messagesIds) async { - await for (final chunk in messagesIds.toList().chunk(90)) { - await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages() - ..bulkdelete(), - method: "POST", - body: {"messages": chunk.map((f) => f.id.toString()).toList()})); - } - } - - @override - Stream downloadMessages(Snowflake channelId, {int limit = 50, Snowflake? after, Snowflake? before, Snowflake? around}) async* { - final queryParams = { - "limit": limit.toString(), - if (after != null) "after": after.toString(), - if (before != null) "before": before.toString(), - if (around != null) "around": around.toString() - }; - - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(), - queryParams: queryParams, - )); - - for (final val in response.jsonBody as RawApiList) { - yield Message(client, val as RawApiMap); - } - } - - @override - Future editGuildChannel(Snowflake channelId, ChannelBuilder builder, {String? auditReason}) async { - final response = - await executeSafe(BasicRequest(HttpRoute()..channels(id: channelId.toString()), method: "PATCH", body: builder.build(), auditLog: auditReason)); - - return Channel.deserialize(client, response.jsonBody as RawApiMap) as T; - } - - @override - Future createWebhook(Snowflake channelId, String name, {AttachmentBuilder? avatarAttachment, String? auditReason}) async { - if (name.isEmpty || name.length > 80) { - throw ArgumentError("Webhook name cannot be shorter than 1 character and longer than 80 characters"); - } - - final body = { - "name": name, - if (avatarAttachment != null) "avatar": avatarAttachment.getBase64(), - }; - - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..webhooks(), - method: "POST", - body: body, - auditLog: auditReason)); - - return Webhook(response.jsonBody as RawApiMap, client); - } - - @override - Stream fetchPinnedMessages(Snowflake channelId) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..pins(), - )); - - for (final val in response.jsonBody as RawApiList) { - yield Message(client, val as RawApiMap); - } - } - - @override - Future triggerTyping(Snowflake channelId) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..typing(), - method: "POST")); - - @override - Future crossPostGuildMessage(Snowflake channelId, Snowflake messageId) async => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()) - ..crosspost(), - method: "POST")); - - @override - Future createThreadWithMessage(Snowflake channelId, Snowflake messageId, ThreadBuilder builder) async { - final response = await executeSafe( - BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()) - ..threads(), - method: "POST", - body: builder.build(), - ), - ); - - return ThreadPreviewChannel(client, response.jsonBody as RawApiMap); - } - - @override - Future createThread(Snowflake channelId, ThreadBuilder builder) async { - final response = await executeSafe( - BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threads(), - method: "POST", - body: builder.build(), - ), - ); - - return ThreadPreviewChannel(client, response.jsonBody as RawApiMap); - } - - @override - Stream fetchThreadMembers(Snowflake channelId, Snowflake guildId, {bool withMembers = false, Snowflake? after, int limit = 100}) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threadMembers(), - queryParams: {'with_member': withMembers, if (withMembers) 'limit': limit, if (withMembers && after != null) 'after': after.toString()})); - - final guild = GuildCacheable(client, guildId); - - for (final rawThreadMember in response.jsonBody as RawApiList) { - yield withMembers ? ThreadMemberWithMember(client, rawThreadMember as RawApiMap, guild) : ThreadMember(client, rawThreadMember as RawApiMap, guild); - } - } - - @override - Future suppressMessageEmbeds(Snowflake channelId, Snowflake messageId) async { - final body = {"flags": 1 << 2}; - - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()), - method: "PATCH", - body: body)); - - return Message(client, response.jsonBody as RawApiMap); - } - - @override - Future editMessage(Snowflake channelId, Snowflake messageId, MessageBuilder builder) async { - if (!builder.canBeUsedAsNewMessage()) { - throw ArgumentError("Cannot edit a message to have neither content nor embeds"); - } - - HttpResponseSuccess response; - if (builder.hasFiles()) { - response = await executeSafe(MultipartRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()), - builder.getMappedFiles().toList(), - method: "PATCH", - fields: builder.build(client.options.allowedMentions))); - } else { - response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()), - body: builder.build(client.options.allowedMentions), - method: "PATCH")); - } - - return Message(client, response.jsonBody as RawApiMap); - } - - @override - Future editWebhookMessage(Snowflake webhookId, Snowflake messageId, MessageBuilder builder, {String? token, Snowflake? threadId}) async { - HttpResponseSuccess response; - if (builder.hasFiles()) { - response = await executeSafe(MultipartRequest( - HttpRoute() - ..webhooks(id: webhookId.toString(), token: token?.toString()) - ..messages(id: messageId.toString()), - builder.getMappedFiles().toList(), - method: "PATCH", - fields: builder.build(client.options.allowedMentions), - queryParams: {if (threadId != null) 'thread_id': threadId})); - } else { - response = await executeSafe(BasicRequest( - HttpRoute() - ..webhooks(id: webhookId.toString(), token: token?.toString()) - ..messages(id: messageId.toString()), - body: builder.build(client.options.allowedMentions), - method: "PATCH", - queryParams: {if (threadId != null) 'thread_id': threadId})); - } - - return Message(client, response.jsonBody as RawApiMap); - } - - @override - Future createMessageReaction(Snowflake channelId, Snowflake messageId, IEmoji emoji) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()) - ..reactions(emoji: emoji.encodeForAPI(), userId: "@me"), - method: "PUT")); - - @override - Future deleteMessageReaction(Snowflake channelId, Snowflake messageId, IEmoji emoji) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()) - ..reactions(emoji: emoji.encodeForAPI(), userId: "@me"), - method: "DELETE")); - - @override - Future deleteMessageUserReaction(Snowflake channelId, Snowflake messageId, IEmoji emoji, Snowflake userId) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()) - ..reactions(emoji: emoji.encodeForAPI(), userId: userId.toString()), - method: "DELETE")); - - @override - Future deleteMessageAllReactions(Snowflake channelId, Snowflake messageId) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()) - ..reactions(), - method: "DELETE")); - - @override - Stream fetchMessageReactionUsers( - Snowflake channelId, - Snowflake messageId, - IEmoji emoji, { - Snowflake? after, - int? limit, - }) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()) - ..reactions(emoji: emoji.encodeForAPI()), - queryParams: { - if (after != null) "after": after.toString(), - if (limit != null) "limit": limit, - }, - )); - - for (final rawUser in (response.jsonBody as RawApiList).cast()) { - yield User(client, rawUser); - } - } - - @override - Future deleteMessageReactions(Snowflake channelId, Snowflake messageId, IEmoji emoji) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()) - ..reactions(emoji: emoji.encodeForAPI()), - method: "DELETE", - )); - - @override - Future deleteMessage(Snowflake channelId, Snowflake messageId, {String? auditReason}) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..messages(id: messageId.toString()), - method: "DELETE", - auditLog: auditReason)); - - @override - Future deleteWebhookMessage(Snowflake webhookId, Snowflake messageId, {String? auditReason, String? token, Snowflake? threadId}) => - executeSafe(BasicRequest( - HttpRoute() - ..webhooks(id: webhookId.toString(), token: token?.toString()) - ..messages(id: messageId.toString()), - method: "DELETE", - auditLog: auditReason, - queryParams: {if (threadId != null) 'thread_id': threadId})); - - @override - Future pinMessage(Snowflake channelId, Snowflake messageId) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..pins(id: messageId.toString()), - method: "PUT")); - - @override - Future unpinMessage(Snowflake channelId, Snowflake messageId) => executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..pins(id: messageId.toString()), - method: "DELETE")); - - @override - Future editSelfUser({String? username, AttachmentBuilder? avatarAttachment}) async { - final body = { - if (username != null) "username": username, - if (avatarAttachment != null) "avatar": avatarAttachment.getBase64(), - }; - - final response = await executeSafe(BasicRequest(HttpRoute()..users(id: "@me"), method: "PATCH", body: body)); - - return User(client, response.jsonBody as RawApiMap); - } - - @override - Future deleteInvite(String code, {String? auditReason}) async => - executeSafe(BasicRequest(HttpRoute()..invites(id: code.toString()), method: "DELETE", auditLog: auditReason)); - - @override - Future deleteWebhook(Snowflake id, {String token = "", String? auditReason}) => executeSafe( - BasicRequest(HttpRoute()..webhooks(id: id.toString(), token: token.toString()), method: "DELETE", auditLog: auditReason, auth: token.isEmpty)); - - @override - Future editWebhook(Snowflake webhookId, - {String token = "", String? name, SnowflakeEntity? channel, AttachmentBuilder? avatarAttachment, String? auditReason}) async { - final body = { - if (name != null) "name": name, - if (channel != null) "channel_id": channel.id.toString(), - if (avatarAttachment != null) "avatar": avatarAttachment.getBase64(), - }; - - final response = await executeSafe(BasicRequest( - HttpRoute()..webhooks(id: webhookId.toString(), token: token.toString()), - method: "PATCH", - auditLog: auditReason, - body: body, - auth: token.isEmpty, - )); - - return Webhook(response.jsonBody as RawApiMap, client); - } - - @override - Future executeWebhook(Snowflake webhookId, MessageBuilder builder, - {String token = "", bool wait = true, String? avatarUrl, String? username, Snowflake? threadId, String? threadName}) async { - final queryParams = {"wait": wait, if (threadId != null) "thread_id": threadId}; - - final body = { - ...builder.build(client.options.allowedMentions), - if (avatarUrl != null) "avatar_url": avatarUrl, - if (username != null) "username": username, - if (threadName != null) 'thread_name': threadName, - }; - - HttpResponseSuccess response; - if (builder.files != null && builder.files!.isNotEmpty) { - response = await executeSafe(MultipartRequest( - HttpRoute()..webhooks(id: webhookId.toString(), token: token.toString()), - builder.getMappedFiles().toList(), - method: "POST", - fields: body, - queryParams: queryParams, - )); - } else { - response = await executeSafe(BasicRequest( - HttpRoute()..webhooks(id: webhookId.toString(), token: token.toString()), - body: body, - method: "POST", - queryParams: queryParams, - auth: token.isEmpty, - )); - } - - if (wait == true) { - return WebhookMessage(client, response.jsonBody as RawApiMap, webhookId, token, threadId); - } - - return null; - } - - @override - Future fetchWebhook(Snowflake id, {String token = ""}) async { - final response = await executeSafe(BasicRequest(HttpRoute()..webhooks(id: id.toString(), token: token.toString()), auth: token.isEmpty)); - - return Webhook(response.jsonBody as RawApiMap, client); - } - - @override - Future fetchInvite(String code) async { - final response = await executeSafe(BasicRequest(HttpRoute()..invites(id: code))); - - return Invite(response.jsonBody as RawApiMap, client); - } - - @override - Future createDMChannel(Snowflake userId) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..users(id: "@me") - ..channels(), - method: "POST", - body: { - "recipient_id": userId.toString(), - }, - )); - - return DMChannel(client, response.jsonBody as RawApiMap); - } - - @override - Future sendRawRequest(covariant HttpRoute route, String method, - {dynamic body, - Map? headers, - List files = const [], - Map? queryParams, - bool auth = false, - bool rateLimit = true}) async { - if (files.isNotEmpty) { - return executeSafe(MultipartRequest( - route, - mapMessageBuilderAttachments(files).toList(), - method: method, - fields: body, - queryParams: queryParams, - globalRateLimit: rateLimit, - auth: auth, - )); - } else { - return executeSafe(BasicRequest( - route, - body: body, - method: method, - queryParams: queryParams, - globalRateLimit: rateLimit, - auth: auth, - )); - } - } - - Future getGatewayBot() => executeSafe(BasicRequest(HttpRoute() - ..gateway() - ..bot())); - - Future getMeApplication() => executeSafe(BasicRequest(HttpRoute() - ..oauth2() - ..applications(id: "@me"))); - - @override - Future fetchGuildPreview(Snowflake guildId) async { - final response = await executeSafe(BasicRequest(HttpRoute() - ..guilds(id: guildId.toString()) - ..preview())); - - return GuildPreview(client, response.jsonBody as RawApiMap); - } - - @override - Future createGuildChannel(Snowflake guildId, ChannelBuilder channelBuilder) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..channels(), - method: "POST", - body: channelBuilder.build())); - - return Channel.deserialize(client, response.jsonBody as RawApiMap); - } - - @override - Future deleteChannel(Snowflake channelId) async { - await executeSafe(BasicRequest(HttpRoute()..channels(id: channelId.toString()), method: "DELETE")); - } - - @override - Future createStageChannelInstance(Snowflake channelId, String topic, {StageChannelInstancePrivacyLevel? privacyLevel}) async { - final body = {"topic": topic, "channel_id": channelId.toString(), if (privacyLevel != null) "privacy_level": privacyLevel.value}; - - final response = await executeSafe(BasicRequest(HttpRoute()..stageInstances(), method: "POST", body: body)); - - return StageChannelInstance(client, response.jsonBody as RawApiMap); - } - - @override - Future deleteStageChannelInstance(Snowflake channelId) async { - await executeSafe(BasicRequest(HttpRoute()..stageInstances(id: channelId.toString()), method: "DELETE")); - } - - @override - Future getStageChannelInstance(Snowflake channelId) async { - final response = await executeSafe(BasicRequest(HttpRoute()..stageInstances(id: channelId.toString()))); - - return StageChannelInstance(client, response.jsonBody as RawApiMap); - } - - @override - Future updateStageChannelInstance(Snowflake channelId, String topic, {StageChannelInstancePrivacyLevel? privacyLevel}) async { - final body = {"topic": topic, if (privacyLevel != null) "privacy_level": privacyLevel.value}; - - final response = await executeSafe(BasicRequest(HttpRoute()..stageInstances(id: channelId.toString()), method: "POST", body: body)); - - return StageChannelInstance(client, response.jsonBody as RawApiMap); - } - - @override - Future addThreadMember(Snowflake channelId, Snowflake userId) async { - await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threadMembers(id: userId.toString()), - method: "PUT", - )); - } - - @override - Future fetchGuildActiveThreads(Snowflake guildId) async { - final response = await executeSafe(BasicRequest(HttpRoute() - ..guilds(id: guildId.toString()) - ..threads() - ..active())); - - return ThreadListResultWrapper(client, response.jsonBody as RawApiMap); - } - - @override - Future fetchJoinedPrivateArchivedThreads(Snowflake channelId, {DateTime? before, int? limit}) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..users(id: "@me") - ..threads() - ..archived() - ..private(), - queryParams: {if (before != null) "before": before.toIso8601String(), if (limit != null) "limit": limit})); - - return ThreadListResultWrapper(client, response.jsonBody as RawApiMap); - } - - @override - Future fetchPrivateArchivedThreads(Snowflake channelId, {DateTime? before, int? limit}) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threads() - ..archived() - ..private(), - queryParams: {if (before != null) "before": before.toIso8601String(), if (limit != null) "limit": limit})); - - return ThreadListResultWrapper(client, response.jsonBody as RawApiMap); - } - - @override - Future fetchPublicArchivedThreads(Snowflake channelId, {DateTime? before, int? limit}) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threads() - ..archived() - ..public(), - queryParams: {if (before != null) "before": before.toIso8601String(), if (limit != null) "limit": limit})); - - return ThreadListResultWrapper(client, response.jsonBody as RawApiMap); - } - - @override - Future joinThread(Snowflake channelId) async { - await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threadMembers(id: "@me"), - method: "PUT", - )); - } - - @override - Future leaveThread(Snowflake channelId) async { - await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threadMembers(id: "@me"), - method: "DELETE", - )); - } - - @override - Future removeThreadMember(Snowflake channelId, Snowflake userId) async { - await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threadMembers(id: userId.toString()), - method: "DELETE", - )); - } - - @override - Future createGuildSticker(Snowflake guildId, StickerBuilder builder) async { - final response = await executeSafe(MultipartRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..stickers(), - [builder.file.getMultipartFile()], - fields: builder.build(), - method: "POST")); - - return GuildSticker(response.jsonBody as RawApiMap, client); - } - - @override - Future editGuildSticker(Snowflake guildId, Snowflake stickerId, StickerBuilder builder) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..stickers(id: stickerId.toString()), - method: "PATCH")); - - return GuildSticker(response.jsonBody as RawApiMap, client); - } - - @override - Future deleteGuildSticker(Snowflake guildId, Snowflake stickerId) async { - await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..stickers(id: stickerId.toString()), - method: "DELETE", - )); - } - - @override - Future fetchGuildSticker(Snowflake guildId, Snowflake stickerId) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..stickers(id: stickerId.toString()), - )); - - return GuildSticker(response.jsonBody as RawApiMap, client); - } - - @override - Stream fetchGuildStickers(Snowflake guildId) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..stickers(), - )); - - for (final rawSticker in response.jsonBody as RawApiList) { - yield GuildSticker(rawSticker as RawApiMap, client); - } - } - - @override - Future getSticker(Snowflake id) async { - final response = await executeSafe(BasicRequest( - HttpRoute()..stickers(id: id.toString()), - )); - - return StandardSticker(response.jsonBody as RawApiMap, client); - } - - @override - Stream listNitroStickerPacks() async* { - final response = await executeSafe(BasicRequest( - HttpRoute()..stickerpacks(), - )); - - for (final rawSticker in response.jsonBody['sticker_packs'] as RawApiList) { - yield StickerPack(rawSticker as RawApiMap, client); - } - } - - @override - Future fetchThreadMember(Snowflake channelId, Snowflake guildId, Snowflake memberId, {bool withMembers = false}) async { - final result = await executeSafe(BasicRequest( - HttpRoute() - ..channels(id: channelId.toString()) - ..threadMembers(id: memberId.toString()), - queryParams: {'with_member': withMembers})); - - final guildCacheable = GuildCacheable(client, guildId); - if (withMembers) { - return ThreadMemberWithMember(client, result.jsonBody as RawApiMap, guildCacheable); - } - - return ThreadMember(client, result.jsonBody as RawApiMap, guildCacheable); - } - - @override - Future editThreadChannel(Snowflake channelId, ThreadBuilder builder, {String? auditReason}) async { - final response = - await executeSafe(BasicRequest(HttpRoute()..channels(id: channelId.toString()), method: "PATCH", body: builder.build(), auditLog: auditReason)); - - return ThreadChannel(client, response.jsonBody as RawApiMap); - } - - @override - Future createGuildEvent(Snowflake guildId, GuildEventBuilder builder) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..scheduledEvents(), - method: 'POST', - body: builder.build())); - - final event = GuildEvent(response.jsonBody as RawApiMap, client); - client.guilds[guildId]?.scheduledEvents[event.id] = event; - return event; - } - - @override - Future deleteGuildEvent(Snowflake guildId, Snowflake guildEventId) => executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..scheduledEvents(id: guildEventId.toString()), - method: 'DELETE')); - - @override - Future editGuildEvent(Snowflake guildId, Snowflake guildEventId, GuildEventBuilder builder) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..scheduledEvents(id: guildEventId.toString()), - method: 'PATCH', - body: builder.build())); - - final event = GuildEvent(response.jsonBody as RawApiMap, client); - client.guilds[guildId]?.scheduledEvents[guildEventId] = event; - return event; - } - - @override - Future fetchGuildEvent(Snowflake guildId, Snowflake guildEventId) async { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..scheduledEvents(id: guildEventId.toString()), - )); - - final event = GuildEvent(response.jsonBody as RawApiMap, client); - client.guilds[guildId]?.scheduledEvents[event.id] = event; - return event; - } - - @override - Stream fetchGuildEventUsers(Snowflake guildId, Snowflake guildEventId, - {int limit = 100, bool withMember = false, Snowflake? before, Snowflake? after}) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..scheduledEvents(id: guildEventId.toString()) - ..users(), - queryParams: { - 'limit': limit, - 'with_member': withMember, - if (before != null) 'before': before.toString(), - if (after != null) 'after': after.toString(), - }, - )); - - for (final rawGuildEventUser in response.jsonBody as RawApiList) { - yield GuildEventUser(rawGuildEventUser as RawApiMap, client, guildId); - } - } - - @override - Stream fetchGuildEvents(Snowflake guildId, {bool withUserCount = false}) async* { - final response = await executeSafe(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..scheduledEvents(), - method: 'GET', - queryParams: {'with_user_count': withUserCount.toString()})); - - for (final rawGuildEvent in response.jsonBody as RawApiList) { - final event = GuildEvent(rawGuildEvent as RawApiMap, client); - client.guilds[guildId]?.scheduledEvents[event.id] = event; - yield event; - } - } - - @override - Stream fetchAutoModerationRules(Snowflake guildId) async* { - final response = await executeSafe( - BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..autoModeration() - ..rules(), - ), - ); - - for (final rawRule in response.jsonBody as RawApiList) { - final rule = AutoModerationRule(rawRule as RawApiMap, client); - client.guilds[guildId]?.autoModerationRules[rule.id] = rule; - yield rule; - } - } - - @override - Future fetchAutoModerationRule(Snowflake guildId, Snowflake ruleId) async { - final response = await executeSafe( - BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..autoModeration() - ..rules(id: ruleId.toString()), - ), - ); - - final rule = AutoModerationRule(response.jsonBody as RawApiMap, client); - - client.guilds[guildId]?.autoModerationRules[ruleId] = rule; - - return rule; - } - - @override - Future createAutoModerationRule(Snowflake guildId, AutoModerationRuleBuilder builder, {String? auditReason}) async { - final response = await executeSafe( - BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..autoModeration() - ..rules(), - method: 'POST', - auditLog: auditReason, - body: builder.build(), - ), - ); - - final rule = AutoModerationRule(response.jsonBody as RawApiMap, client); - - client.guilds[guildId]?.autoModerationRules[rule.id] = rule; - - return rule; - } - - @override - Future editAutoModerationRule(Snowflake guildId, Snowflake ruleId, AutoModerationRuleBuilder builder, {String? auditReason}) async { - final response = await executeSafe( - BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..autoModeration() - ..rules(id: ruleId.toString()), - body: builder.build(), - auditLog: auditReason, - method: 'PATCH', - ), - ); - - final rule = AutoModerationRule(response.jsonBody as RawApiMap, client); - - client.guilds[guildId]?.autoModerationRules[ruleId] = rule; - - return rule; - } - - @override - Future deleteAutoModerationRule(Snowflake guildId, Snowflake ruleId, {String? auditReason}) async { - await executeSafe( - BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..autoModeration() - ..rules(id: ruleId.toString()), - auditLog: auditReason, - method: 'DELETE', - ), - ); - - client.guilds[guildId]?.autoModerationRules.remove(ruleId); - } -} diff --git a/lib/src/internal/interfaces/convertable.dart b/lib/src/internal/interfaces/convertable.dart deleted file mode 100644 index 0f0f56f67..000000000 --- a/lib/src/internal/interfaces/convertable.dart +++ /dev/null @@ -1,6 +0,0 @@ -/// Specifies objects which can be converted to [Builder] -// ignore: one_member_abstracts -abstract class Convertable { - /// Returns instance of [Builder] with current data - T toBuilder(); -} diff --git a/lib/src/internal/interfaces/disposable.dart b/lib/src/internal/interfaces/disposable.dart deleted file mode 100644 index e4dff53b2..000000000 --- a/lib/src/internal/interfaces/disposable.dart +++ /dev/null @@ -1,6 +0,0 @@ -/// Provides abstraction for disposing object's resources when isn't needed anymore -// ignore: one_member_abstracts -abstract class Disposable { - /// Perform cleanup - Future dispose(); -} diff --git a/lib/src/internal/interfaces/mentionable.dart b/lib/src/internal/interfaces/mentionable.dart deleted file mode 100644 index 8e2eb23b8..000000000 --- a/lib/src/internal/interfaces/mentionable.dart +++ /dev/null @@ -1,5 +0,0 @@ -/// Provides abstraction for entities which can be mentioned -abstract class Mentionable { - /// Mention string of entity - String get mention; -} diff --git a/lib/src/internal/interfaces/message_author.dart b/lib/src/internal/interfaces/message_author.dart deleted file mode 100644 index b7c9ff35e..000000000 --- a/lib/src/internal/interfaces/message_author.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:nyxx/src/core/snowflake_entity.dart'; - -/// Could be either [IUser], [IMember] or [IWebhook]. -/// [IWebhook] will have most of field missing. -abstract class IMessageAuthor implements SnowflakeEntity { - /// User name - String get username; - - /// User Discriminator. -1 if webhook - int get discriminator; - - /// True if bot or webhook - bool get bot; - - /// User tag: `l7ssha#6712` - String get tag; - - /// Whether this [IMessageAuthor] is a webhook received by an interaction. - bool get isInteractionWebhook; - - /// Formatted discriminator with leading zeros if needed - String get formattedDiscriminator; - - /// The user's avatar, represented as URL. - /// In case if user does not have avatar, default discord avatar will be returned; [format], [size] and [animated] will no longer affectng this URL. - /// If [animated] is set as `true`, if available, the url will be a gif, otherwise the [format]. - String avatarUrl({String format = 'webp', int? size, bool animated = false}); -} diff --git a/lib/src/internal/interfaces/send.dart b/lib/src/internal/interfaces/send.dart deleted file mode 100644 index a931b4b0d..000000000 --- a/lib/src/internal/interfaces/send.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; - -/// Marks entity to which message can be sent -// ignore: one_member_abstracts -abstract class ISend { - /// Sends message - Future sendMessage(MessageBuilder builder); -} diff --git a/lib/src/internal/response_wrapper/error_response_wrapper.dart b/lib/src/internal/response_wrapper/error_response_wrapper.dart deleted file mode 100644 index 68b06f63c..000000000 --- a/lib/src/internal/response_wrapper/error_response_wrapper.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; - -abstract class IHttpErrorData { - /// The error code. - /// - /// You can find a full list of these [here](https://discord.com/developers/docs/topics/opcodes-and-status-codes#json). - int get errorCode; - - /// A human-readable description of the error represented by [errorCode]. - String get errorMessage; - - /// A mapping of field path to field error. - /// - /// Will be empty if Discord did not send any errors associated with specific fields in the request. - Map get fieldErrors; -} - -class HttpErrorData implements IHttpErrorData { - @override - final int errorCode; - - @override - final String errorMessage; - - @override - final Map fieldErrors = {}; - - HttpErrorData(RawApiMap raw) - : errorCode = raw['code'] as int, - errorMessage = raw['message'] as String { - final errors = raw['errors'] as RawApiMap?; - - if (errors != null) { - _initErrors(errors); - } - } - - void _initErrors(RawApiMap fields, [List path = const []]) { - final errors = fields['_errors'] as RawApiList?; - - if (errors != null) { - for (final error in errors.cast()) { - final fieldError = FieldError( - path: path, - errorCode: error['code'] as String, - errorMessage: error['message'] as String, - ); - - fieldErrors[fieldError.name] = fieldError; - } - } - - for (final nestedElement in fields.entries) { - if (nestedElement.value is! RawApiMap) { - continue; - } - - _initErrors(nestedElement.value as RawApiMap, [...path, nestedElement.key]); - } - } -} - -abstract class IFieldError { - /// A human-readable name of this field. - String get name; - - /// The segments of the path to this field in the request. - List get path; - - /// The error code. - String get errorCode; - - /// A human-readable description of the error represented by [errorCode]. - String get errorMessage; -} - -class FieldError implements IFieldError { - @override - final String name; - - @override - final List path; - - @override - final String errorCode; - - @override - final String errorMessage; - - FieldError({ - required this.path, - required this.errorCode, - required this.errorMessage, - }) : name = pathToName(path); - - static String pathToName(List path) { - if (path.isEmpty) { - return ''; - } - - final result = StringBuffer(path.first); - - for (final part in path.skip(1)) { - final isArrayIndex = RegExp(r'^\d+$').hasMatch(part); - - if (isArrayIndex) { - result.write('[$part]'); - } else { - result.write('.$part'); - } - } - - return result.toString(); - } -} diff --git a/lib/src/internal/response_wrapper/thread_list_result_wrapper.dart b/lib/src/internal/response_wrapper/thread_list_result_wrapper.dart deleted file mode 100644 index 0950cb5db..000000000 --- a/lib/src/internal/response_wrapper/thread_list_result_wrapper.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/channel/thread_channel.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class IThreadListResultWrapper { - /// List of threads - List get threads; - - /// A thread member object for each returned thread the current user has joined - List get selfThreadMembers; - - /// Whether there are potentially additional threads that could be returned on a subsequent call - bool get hasMore; -} - -/// Wrapper of threads listing results. -class ThreadListResultWrapper implements IThreadListResultWrapper { - /// List of threads - @override - late final List threads; - - /// A thread member object for each returned thread the current user has joined - @override - late final List selfThreadMembers; - - /// Whether there are potentially additional threads that could be returned on a subsequent call - @override - late final bool hasMore; - - /// Create an instance of [ThreadListResultWrapper] - ThreadListResultWrapper(INyxx client, RawApiMap raw) { - threads = [for (final rawThread in raw["threads"] as RawApiList) ThreadChannel(client, rawThread as RawApiMap)]; - - selfThreadMembers = [ - for (final rawMember in raw["members"] as RawApiList) - ThreadMember(client, rawMember as RawApiMap, ChannelCacheable(client, threads.firstWhere((element) => element.id == rawMember["id"]).id)) - ]; - - hasMore = (raw["has_more"] as bool?) ?? false; - } -} diff --git a/lib/src/internal/shard/message.dart b/lib/src/internal/shard/message.dart deleted file mode 100644 index ecec717d8..000000000 --- a/lib/src/internal/shard/message.dart +++ /dev/null @@ -1,86 +0,0 @@ -class ShardMessage { - final T type; - final dynamic data; - - final int seq; - - const ShardMessage(this.type, {required this.seq, this.data}); -} - -enum ShardToManager { - /// Sent when the shard receives a payload from Discord. - /// - /// Data payload includes: - /// - `data`: dynamic - /// The uncompressed and JSON-decoded data received - received, - - /// Sent when the shard encounters an error. - /// - /// Errors reported include state errors and errors that occurred during the initial connection. - /// Any error causing the connection to close will send [disconnected] with a non-normal close code. - /// - /// Data payload includes: - /// - `message`: String - /// The message associated with the error - /// - `shouldReconnect`: bool? - /// Whether the shard should attempt to reconnect following this error - error, - - /// Sent when the shard is connected - connected, - - /// Send when the shard successfully reconnects - reconnected, - - /// Send when the shard is disconnected - /// - /// Data payload includes: - /// - `closeCode`: int - /// The code associated with the websocket disconnection - /// - `closeReason`: String? - /// The message associated with the disconnection - disconnected, - - /// Send when the shard is disposed - disposed, -} - -enum ManagerToShard { - /// Sent when the shard should send a payload to Discord - /// - /// Data payload includes: - /// - `opCode`: int - /// The opcode of the payload to send - /// - `d`: dynamic - /// The data to send in the payload - send, - - /// Sent to request the shard to connect and start dispatching events back to the manager - /// - /// Data payload includes: - /// - `gatewayHost`: String - /// The URL on which to connect to the gateway - /// - `useCompression`: bool - /// Whether to use compression on this gateway connection - /// - `encoding`: Encoding - /// The encoding type du use to receive/send payloads - connect, - - /// Sent to request the shard to reconnect, closing the current connection if any. - /// - /// This will disconnect with a non-normal disconnect code. - /// - /// Data payload includes: - /// - `gatewayHost`: String - /// The URL on which to connect to the gateway - /// - `useCompression`: bool - /// Whether to use compression on this gateway connection - reconnect, - - /// Send to request the shard to disconnect, with a normal disconnection code. - disconnect, - - /// Sent to dispose the shard - dispose, -} diff --git a/lib/src/internal/shard/shard.dart b/lib/src/internal/shard/shard.dart deleted file mode 100644 index b6a6c1b9d..000000000 --- a/lib/src/internal/shard/shard.dart +++ /dev/null @@ -1,780 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:math'; - -import 'package:logging/logging.dart'; -import 'package:nyxx/src/core/guild/client_user.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/events/channel_events.dart'; -import 'package:nyxx/src/events/guild_events.dart'; -import 'package:nyxx/src/events/invite_events.dart'; -import 'package:nyxx/src/events/member_chunk_event.dart'; -import 'package:nyxx/src/events/message_events.dart'; -import 'package:nyxx/src/events/presence_update_event.dart'; -import 'package:nyxx/src/events/raw_event.dart'; -import 'package:nyxx/src/events/thread_create_event.dart'; -import 'package:nyxx/src/events/thread_deleted_event.dart'; -import 'package:nyxx/src/events/thread_list_sync_event.dart'; -import 'package:nyxx/src/events/thread_members_update_event.dart'; -import 'package:nyxx/src/events/typing_event.dart'; -import 'package:nyxx/src/events/user_update_event.dart'; -import 'package:nyxx/src/events/voice_server_update_event.dart'; -import 'package:nyxx/src/events/voice_state_update_event.dart'; -import 'package:nyxx/src/internal/constants.dart'; -import 'package:nyxx/src/internal/event_controller.dart'; -import 'package:nyxx/src/internal/exceptions/invalid_shard_exception.dart'; -import 'package:nyxx/src/internal/exceptions/unrecoverable_nyxx_error.dart'; -import 'package:nyxx/src/internal/interfaces/disposable.dart'; -import 'package:nyxx/src/internal/shard/message.dart'; -import 'package:nyxx/src/internal/shard/shard_handler.dart'; -import 'package:nyxx/src/internal/shard/shard_manager.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/presence_builder.dart'; - -/// A connection to the [Discord Gateway](https://discord.com/developers/docs/topics/gateway). -/// -/// One client can have multiple shards. Each shard moves the decompressing and decoding steps of the Gateway connection to their own thread which can lessen -/// the load on the main thread for large bots which might receive thousands of events per minute. -abstract class IShard implements Disposable { - /// The ID of this shard. - int get id; - - /// Reference to [ShardManager] - IShardManager get manager; - - /// Emitted when the shard encounters a connection error - Stream get onDisconnect; - - /// Emitted when shard receives member chunk. - Stream get onMemberChunk; - - /// Emitted when the shard resumed its connection - Stream get onResume; - - /// List of handled guild ids - List get guilds; - - /// Gets the latest gateway latency. - /// - /// To calculate the gateway latency, nyxx measures the time it takes for Discord to answer the gateway - /// heartbeat packet with a heartbeat ack packet. Note this value is updated each time gateway responses to ack. - Duration get gatewayLatency; - - /// Returns true if shard is connected to websocket - bool get connected; - - /// Sends WS data. - void send(int opCode, dynamic d); - - /// Updates clients voice state for [IGuild] with given [guildId] - void changeVoiceState(Snowflake? guildId, Snowflake? channelId, {bool selfMute = false, bool selfDeafen = false}); - - /// Allows to set presence for current shard. - void setPresence(PresenceBuilder presenceBuilder); - - /// Syncs all guilds - void guildSync(); - - /// Allows to request members objects from gateway - /// [guild] can be either Snowflake or Iterable - void requestMembers(/* Snowflake|Iterable */ dynamic guild, - {String? query, Iterable? userIds, int limit = 0, bool presences = false, String? nonce}); -} - -class Shard implements IShard { - @override - final int id; - - @override - final ShardManager manager; - - @override - final List guilds = []; - - @override - Duration gatewayLatency = Duration.zero; - - @override - bool connected = false; - - /// The receive port on which events from the isolate will be received. - final ReceivePort receivePort = ReceivePort(); - - /// A stream on which events from the shard will be received. - /// - /// Should only be accessed after [readyFuture] has completed. - late final Stream> shardMessages; - - /// The send port on which messages to the isolate should be added. - /// - /// Should only be accessed after [readyFuture] has completed. - late final SendPort sendPort; - - /// A future that completes once the handler isolate is running. - late final Future readyFuture; - - /// The URL to which this shard should make the initial connection. - final String gatewayHost; - - late final Logger logger = Logger('Shard $id'); - - /// The last sequence number - // Start at 0 and count up to avoid collisions with seq from the shard handler - int seq = 0; - - Shard(this.id, this.manager, this.gatewayHost) { - readyFuture = spawn(); - - // Automatically connect once the shard runner is ready. - readyFuture.then((_) => connect()); - - // Start handling messages from the shard. - readyFuture.then((_) => shardMessages.listen(handle)); - } - - /// Spawns the handler isolate and initializes [sendPort] and [shardMessages]; - Future spawn() async { - logger.fine("Starting shard runner..."); - - await Isolate.spawn(shardHandler, receivePort.sendPort, debugName: "Shard Runner #$id"); - - final rawShardMessages = receivePort.asBroadcastStream(); - - sendPort = await rawShardMessages.first as SendPort; - shardMessages = rawShardMessages.cast>(); - - logger.fine("Shard runner ready"); - } - - /// Sends a message to the shard isolate. - void execute(ShardMessage message) async { - await readyFuture; - - logger.fine('Sending ${message.type.name} message to runner'); - logger.finer([ - 'Sequence: ${message.seq}', - if (message.data != null) 'Data: ${message.data}', - ].join('\n')); - - sendPort.send(message); - } - - Future _connectReconnectHelper(int seq, {required bool isReconnect}) async { - // These need to be accessible both in the main callback, in retryIf and in the catch block below - bool shouldReconnect = false; - late String errorMessage; - - try { - await manager.connectionManager.client.options.shardReconnectOptions.retry( - retryIf: (_) => shouldReconnect, - () async { - execute(ShardMessage( - isReconnect ? ManagerToShard.reconnect : ManagerToShard.connect, - seq: seq, - data: { - 'gatewayHost': shouldResume && canResume ? resumeGatewayUrl : gatewayHost, - 'useCompression': manager.connectionManager.client.options.compressedGatewayPayloads, - 'encoding': manager.connectionManager.client.options.payloadEncoding, - 'usePayloadCompression': manager.connectionManager.client.options.payloadCompression && - manager.connectionManager.client.options.payloadEncoding == Encoding.json && - !manager.connectionManager.client.options.compressedGatewayPayloads, - }, - )); - - final message = await shardMessages.firstWhere((element) => element.seq == seq); - - switch (message.type) { - case ShardToManager.connected: - case ShardToManager.reconnected: - return; - case ShardToManager.error: - shouldReconnect = message.data['shouldReconnect'] as bool? ?? false; - errorMessage = message.data['message'] as String; - throw Exception(); - default: - assert(false, 'Unreachable'); - return; - } - }, - ); - } on Exception { - // Callback failed too many times, throw an unrecoverable error with the message we were given - throw UnrecoverableNyxxError(errorMessage); - } - } - - Future connect() => _connectReconnectHelper(seq, isReconnect: false); - - /// Triggers a reconnection to the shard. - /// - /// If the connection is to be resumed, [resumeGatewayUrl] is used as the connection. Otherwise, [gatewayHost] is used. - Future reconnect([int? seq]) async { - logger.info('Reconnecting to gateway on shard $id'); - resetConnectionProperties(); - - int realSeq = seq ?? (this.seq++); - - await _connectReconnectHelper(realSeq, isReconnect: true); - } - - void resetConnectionProperties() { - connected = false; - heartbeatTimer?.cancel(); - lastHeartbeatSent = null; - } - - /// Handler for incoming messages from the isolate. - /// - /// These messages are not raw messages from the websocket! Those are handled in [handlePayload]. - Future handle(ShardMessage message) async { - logger.fine('Handling ${message.type.name} message from runner'); - logger.finer([ - 'Sequence: ${message.seq}', - if (message.data != null) 'Data: ${message.data}', - ].join('\n')); - - switch (message.type) { - case ShardToManager.received: - return handlePayload(message.data as RawApiMap); - case ShardToManager.connected: - case ShardToManager.reconnected: - return handleConnected(); - case ShardToManager.disconnected: - return handleDisconnect(message.data['closeCode'] as int, message.data['closeReason'] as String?, message.seq); - case ShardToManager.error: - return handleError(message.data['message'] as String, message.seq); - case ShardToManager.disposed: - logger.info("Shard $id disposed."); - break; - } - } - - /// A handler for when the shard connection disconnects. - Future handleDisconnect(int closeCode, String? closeReason, int seq) async { - resetConnectionProperties(); - - manager.onDisconnectController.add(this); - - for (final element in manager.connectionManager.client.plugins) { - element.onConnectionClose(manager.connectionManager.client, element.logger, closeCode, closeReason); - } - - // https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes - const warnings = { - 4000: 'Unknown error', - 4001: 'Unknown opcode', - 4002: 'Decode error (invalid payload)', - 4003: 'Payload sent before authentication', - 4005: 'Already authenticated', - 4007: 'Invalid seq', - 4008: 'Rate limited', - 4009: 'Session timed out', - }; - - const errors = { - 4004: 'Invalid authentication', - 4010: 'Invalid shard', - 4011: 'Sharding required', - 4012: 'Invalid API version', - 4013: 'Invalid intents', - 4014: 'Disallowed intent', - }; - - if (errors.containsKey(closeCode)) { - throw UnrecoverableNyxxError('Shard $id disconnected: ${errors[closeCode]!}'); - } else if (warnings.containsKey(closeCode)) { - logger.warning('Shard disconnected: ${warnings[closeCode]!}'); - - // Try to resume on all warnings apart from invalid sequence, which prevents us from resuming - shouldResume = closeCode != 4007; - } else { - // If we get an unknown error, try to resume. - shouldResume = true; - } - - // Reconnect by default - reconnect(seq); - } - - /// A handler for when the shard establishes a connection to the Gateway. - Future handleConnected() async { - logger.info('Shard connected to gateway'); - connected = true; - manager.onConnectController.add(this); - - // There was no previous heartbeat on a new connection. - // Setting this to true prevents us from reconnecting upon receiving the first heartbeat due to the previous heartbeat "not being acked". - lastHeartbeatAcked = true; - } - - /// A handler for when the shard encounters an error. These can occur if the runner is in an invalid state or fails to open the websocket connection. - Future handleError(String message, int seq) async { - logger.shout('Shard reported error', message); - - for (final element in manager.connectionManager.client.plugins) { - element.onConnectionError(manager.connectionManager.client, element.logger, message); - } - } - - /// A handler for when a payload from the gateway is received. - Future handlePayload(RawApiMap data) async { - final opcode = data['op'] as int; - final d = data['d']; - - switch (opcode) { - case OPCodes.dispatch: - dispatch(data['s'] as int, data['t'] as String, data); - break; - - case OPCodes.heartbeat: - heartbeat(); - break; - - case OPCodes.hello: - hello(d['heartbeat_interval'] as int); - break; - - case OPCodes.heartbeatAck: - heartbeatAck(); - break; - - case OPCodes.invalidSession: - // https://discord.com/developers/docs/topics/gateway#invalid-session - shouldResume = d as bool; - - if (shouldResume) { - reconnect(); - } else { - // https://discord.com/developers/docs/topics/gateway#resuming - Future.delayed( - Duration(seconds: 1) + Duration(seconds: 4) * Random().nextDouble(), - identify, - ); - } - break; - - case OPCodes.reconnect: - shouldResume = true; - reconnect(); - break; - - default: - logger.severe('Unhandled opcode $opcode'); - break; - } - } - - /// The timer than handles sending regular heartbeats to the gateway. - Timer? heartbeatTimer; - - /// Whether this shard should attempt to resume upon connecting. - /// - /// Note that a result will only be sent if this shard [shouldResume] and [canResume]. - bool shouldResume = false; - - /// Whether this shard can resume upon connecting. - bool get canResume => seqNum != null && sessionId != null && resumeGatewayUrl != null; - - /// A handler for [OPCodes.hello]. - void hello(int heartbeatInterval) { - // https://discord.com/developers/docs/topics/gateway#heartbeating - final heartbeatDuration = Duration(milliseconds: heartbeatInterval); - - final jitter = Random().nextDouble(); - - heartbeatTimer = Timer(heartbeatDuration * jitter, () { - heartbeat(); - - heartbeatTimer = Timer.periodic(heartbeatDuration, (timer) => heartbeat()); - }); - - if (shouldResume && canResume) { - resume(); - } else { - identify(); - } - } - - /// Sends the identify payload to the gateway. - // https://discord.com/developers/docs/topics/gateway#identifying - void identify() => send(OPCodes.identify, { - "token": manager.connectionManager.client.token, - "properties": { - "os": Platform.operatingSystem, - "browser": "nyxx", - "device": "nyxx", - }, - "large_threshold": manager.connectionManager.client.options.largeThreshold, - "intents": manager.connectionManager.client.intents, - if (manager.connectionManager.client.options.initialPresence != null) "presence": manager.connectionManager.client.options.initialPresence!.build(), - "shard": [id, manager.totalNumShards], - if (manager.connectionManager.client.options.payloadEncoding == Encoding.json && !manager.connectionManager.client.options.compressedGatewayPayloads) - "compress": manager.connectionManager.client.options.payloadCompression, - }); - - /// Sends the resume payload to the gateway. - /// - /// Will throw if [canResume] is false. - // https://discord.com/developers/docs/topics/gateway#resuming - void resume() => send(OPCodes.resume, { - "token": manager.connectionManager.client.token, - "session_id": sessionId!, - "seq": seqNum!, - }); - - /// The time at which the last heartbeat was sent. - /// - /// Used for calculating gateway latency. - DateTime? lastHeartbeatSent; - - /// Whether the last heartbeat sent has been acknowledged. - bool lastHeartbeatAcked = true; - - /// A handler for [OPCodes.heartbeat]. - /// - /// Also called regularly in the callback of [heartbeatTimer]. - /// - /// Triggers a reconnect if it is invoked before the last heartbeat was acked. See - /// https://discord.com/developers/docs/topics/gateway#heartbeating-example-gateway-heartbeat-ack. - void heartbeat() { - send(OPCodes.heartbeat, seqNum); - - if (!lastHeartbeatAcked) { - shouldResume = true; - reconnect(); - return; - } - - lastHeartbeatSent = DateTime.now(); - lastHeartbeatAcked = false; - } - - /// A handler for [OPCodes.heartbeatAck]. - /// - /// Updates the gateway latency. - void heartbeatAck() { - gatewayLatency = DateTime.now().difference(lastHeartbeatSent!); - lastHeartbeatAcked = true; - } - - /// The session ID found in the READY event. - String? sessionId; - - /// The URL to use for resuming gateway connections, found in the READY event. - String? resumeGatewayUrl; - - /// The last known sequence number. - int? seqNum; - - /// A handler for [OPCodes.dispatch]. - void dispatch(int seqNum, String type, RawApiMap data) async { - final eventController = manager.connectionManager.client.eventsWs as WebsocketEventController; - - this.seqNum = seqNum; - - switch (type) { - case "READY": - sessionId = data["d"]["session_id"] as String; - resumeGatewayUrl = data["d"]["resume_gateway_url"] as String; - - manager.connectionManager.client.self = ClientUser(manager.connectionManager.client, data["d"]["user"] as RawApiMap); - - logger.info("Shard ready!"); - - if (!shouldResume) { - await manager.connectionManager.propagateReady(); - } - - break; - case "RESUMED": - shouldResume = false; - manager.onResumeController.add(this); - break; - - case "GUILD_MEMBERS_CHUNK": - manager.onMemberChunkController.add(MemberChunkEvent(data, manager.connectionManager.client, id)); - break; - - case "MESSAGE_REACTION_REMOVE_ALL": - eventController.onMessageReactionsRemovedController.add(MessageReactionsRemovedEvent(data, manager.connectionManager.client)); - break; - - case "MESSAGE_REACTION_ADD": - eventController.onMessageReactionAddedController.add(MessageReactionAddedEvent(data, manager.connectionManager.client)); - break; - - case "MESSAGE_REACTION_REMOVE": - eventController.onMessageReactionRemoveController.add(MessageReactionRemovedEvent(data, manager.connectionManager.client)); - break; - - case "MESSAGE_DELETE_BULK": - eventController.onMessageDeleteBulkController.add(MessageDeleteBulkEvent(data, manager.connectionManager.client)); - break; - - case "CHANNEL_PINS_UPDATE": - eventController.onChannelPinsUpdateController.add(ChannelPinsUpdateEvent(data, manager.connectionManager.client)); - break; - - case "VOICE_STATE_UPDATE": - eventController.onVoiceStateUpdateController.add(VoiceStateUpdateEvent(data, manager.connectionManager.client)); - break; - - case "VOICE_SERVER_UPDATE": - eventController.onVoiceServerUpdateController.add(VoiceServerUpdateEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_EMOJIS_UPDATE": - eventController.onGuildEmojisUpdateController.add(GuildEmojisUpdateEvent(data, manager.connectionManager.client)); - break; - - case "MESSAGE_CREATE": - eventController.onMessageReceivedController.add(MessageReceivedEvent(data, manager.connectionManager.client)); - break; - - case "MESSAGE_DELETE": - eventController.onMessageDeleteController.add(MessageDeleteEvent(data, manager.connectionManager.client)); - break; - - case "MESSAGE_UPDATE": - eventController.onMessageUpdateController.add(MessageUpdateEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_CREATE": - final event = GuildCreateEvent(data, manager.connectionManager.client); - guilds.add(event.guild.id); - eventController.onGuildCreateController.add(event); - break; - - case "GUILD_UPDATE": - eventController.onGuildUpdateController.add(GuildUpdateEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_DELETE": - eventController.onGuildDeleteController.add(GuildDeleteEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_BAN_ADD": - eventController.onGuildBanAddController.add(GuildBanAddEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_BAN_REMOVE": - eventController.onGuildBanRemoveController.add(GuildBanRemoveEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_MEMBER_ADD": - eventController.onGuildMemberAddController.add(GuildMemberAddEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_MEMBER_REMOVE": - eventController.onGuildMemberRemoveController.add(GuildMemberRemoveEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_MEMBER_UPDATE": - eventController.onGuildMemberUpdateController.add(GuildMemberUpdateEvent(data, manager.connectionManager.client)); - break; - - case "CHANNEL_CREATE": - eventController.onChannelCreateController.add(ChannelCreateEvent(data, manager.connectionManager.client)); - break; - - case "CHANNEL_UPDATE": - eventController.onChannelUpdateController.add(ChannelUpdateEvent(data, manager.connectionManager.client)); - break; - - case "CHANNEL_DELETE": - eventController.onChannelDeleteController.add(ChannelDeleteEvent(data, manager.connectionManager.client)); - break; - - case "TYPING_START": - eventController.onTypingController.add(TypingEvent(data, manager.connectionManager.client)); - break; - - case "PRESENCE_UPDATE": - eventController.onPresenceUpdateController.add(PresenceUpdateEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_ROLE_CREATE": - eventController.onRoleCreateController.add(RoleCreateEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_ROLE_UPDATE": - eventController.onRoleUpdateController.add(RoleUpdateEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_ROLE_DELETE": - eventController.onRoleDeleteController.add(RoleDeleteEvent(data, manager.connectionManager.client)); - break; - - case "USER_UPDATE": - eventController.onUserUpdateController.add(UserUpdateEvent(data, manager.connectionManager.client)); - break; - - case "INVITE_CREATE": - eventController.onInviteCreatedController.add(InviteCreatedEvent(data, manager.connectionManager.client)); - break; - - case "INVITE_DELETE": - eventController.onInviteDeleteController.add(InviteDeletedEvent(data, manager.connectionManager.client)); - break; - - case "MESSAGE_REACTION_REMOVE_EMOJI": - eventController.onMessageReactionRemoveEmojiController.add(MessageReactionRemoveEmojiEvent(data, manager.connectionManager.client)); - break; - - case "THREAD_CREATE": - eventController.onThreadCreatedController.add(ThreadCreateEvent(data, manager.connectionManager.client)); - break; - - case "THREAD_MEMBERS_UPDATE": - eventController.onThreadMembersUpdateController.add(ThreadMembersUpdateEvent(data, manager.connectionManager.client)); - break; - - case "THREAD_DELETE": - eventController.onThreadDeleteController.add(ThreadDeletedEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_SCHEDULED_EVENT_CREATE": - eventController.onGuildEventCreateController.add(GuildEventCreateEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_SCHEDULED_EVENT_UPDATE": - eventController.onGuildEventUpdateController.add(GuildEventUpdateEvent(data, manager.connectionManager.client)); - break; - - case "GUILD_SCHEDULED_EVENT_DELETE": - eventController.onGuildEventDeleteController.add(GuildEventDeleteEvent(data, manager.connectionManager.client)); - break; - - case 'WEBHOOKS_UPDATE': - eventController.onWebhookUpdateController.add(WebhookUpdateEvent(data, manager.connectionManager.client)); - break; - - case 'AUTO_MODERATION_RULE_CREATE': - eventController.onAutoModerationRuleCreateController.add(AutoModerationRuleCreateEvent(data, manager.connectionManager.client)); - break; - - case 'AUTO_MODERATION_RULE_UPDATE': - eventController.onAutoModerationRuleUpdateController.add(AutoModerationRuleUpdateEvent(data, manager.connectionManager.client)); - break; - - case 'AUTO_MODERATION_RULE_DELETE': - eventController.onAutoModerationRuleDeleteController.add(AutoModerationRuleDeleteEvent(data, manager.connectionManager.client)); - break; - - case 'AUTO_MODERATION_ACTION_EXECUTION': - eventController.onAutoModerationActionExecutionController.add(AutoModeratioActionExecutionEvent(data, manager.connectionManager.client)); - break; - - case 'GUILD_AUDIT_LOG_ENTRY_CREATE': - eventController.onAuditLogEntryCreateController.add(AuditLogEntryCreateEvent(data, manager.connectionManager.client)); - break; - - case 'THREAD_MEMBER_UPDATE': - eventController.onThreadMemberUpdateController.add(ThreadMemberUpdateEvent(data, manager.connectionManager.client)); - break; - - case 'THREAD_UPDATE': - eventController.onThreadUpdateController.add(ThreadUpdateEvent(data, manager.connectionManager.client)); - break; - - case 'THREAD_LIST_SYNC': - eventController.onThreadListSyncController.add(ThreadListSyncEvent(data, manager.connectionManager.client)); - break; - - default: - if (!manager.connectionManager.client.options.dispatchRawShardEvent) { - logger.severe("UNKNOWN GATEWAY EVENT: $data"); - } - } - - if (manager.connectionManager.client.options.dispatchRawShardEvent) { - manager.onRawEventController.add(RawEvent(this, data)); - } - } - - @override - void send(int opCode, dynamic d) => execute(ShardMessage( - ManagerToShard.send, - seq: seq++, - data: { - "op": opCode, - "d": d, - }, - )); - - @override - Stream get onDisconnect => manager.onDisconnect.where((event) => event.id == id); - - @override - Stream get onMemberChunk => manager.onMemberChunk.where((event) => event.shardId == id); - - @override - Stream get onResume => manager.onResume.where((event) => event.id == id); - - @override - void guildSync() => send(OPCodes.guildSync, guilds.map((e) => e.toString())); - - @override - void setPresence(PresenceBuilder presenceBuilder) => send(OPCodes.statusUpdate, presenceBuilder.build()); - - @override - void changeVoiceState(Snowflake? guildId, Snowflake? channelId, {bool selfMute = false, bool selfDeafen = false}) => send( - OPCodes.voiceStateUpdate, - { - "guild_id": guildId?.toString(), - "channel_id": channelId?.toString(), - "self_mute": selfMute, - "self_deaf": selfDeafen, - }, - ); - - @override - void requestMembers( - /* Snowflake|Iterable */ dynamic guild, { - String? query, - Iterable? userIds, - int limit = 0, - bool presences = false, - String? nonce, - }) { - if (query != null && userIds != null) { - throw ArgumentError("At most one of `query` and `userIds` may be set"); - } - - if (guild is! Iterable) { - if (guild is! Snowflake) { - throw ArgumentError("`guild` must be a Snowflake or an Iterable"); - } - - guild = [guild]; - } - - for (final id in guild) { - if (!guilds.contains(id)) { - throw InvalidShardException("Cannot request guild $id on shard ${this.id} because it does not exist on this shard"); - } - } - - final payload = { - "guild_id": guild.map((id) => id.toString()).toList(), - "limit": limit, - "presences": presences, - if (query != null) "query": query, - if (userIds != null) "user_ids": userIds.map((e) => e.toString()).toList(), - if (nonce != null) "nonce": nonce - }; - - send(OPCodes.requestGuildMember, payload); - } - - @override - Future dispose() async { - execute(ShardMessage(ManagerToShard.dispose, seq: seq++)); - - // Wait for shard to dispose correctly - await shardMessages.firstWhere((message) => message.type == ShardToManager.disposed); - - receivePort.close(); - } -} diff --git a/lib/src/internal/shard/shard_handler.dart b/lib/src/internal/shard/shard_handler.dart deleted file mode 100644 index c88a32732..000000000 --- a/lib/src/internal/shard/shard_handler.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:eterl/eterl.dart'; -import 'package:nyxx/src/internal/constants.dart'; -import 'package:nyxx/src/internal/interfaces/disposable.dart'; -import 'package:nyxx/src/internal/shard/message.dart'; -import 'package:nyxx/src/typedefs.dart'; - -void shardHandler(SendPort sendPort) { - final runner = ShardRunner(sendPort); - - sendPort.send(runner.receivePort.sendPort); -} - -class ShardRunner implements Disposable { - /// The receive port on which messages from the manager will be received. - final ReceivePort receivePort = ReceivePort(); - - /// A stream on which messages from the manager will be received. - Stream> get managerMessages => receivePort.cast>(); - - /// The send port on which messages to the manager should be added; - final SendPort sendPort; - - /// The current active connection. - WebSocket? connection; - - /// The subscription to the current active connection. - StreamSubscription? connectionSubscription; - - /// Whether this shard is currently connected to Discord. - bool get connected => connection?.readyState == WebSocket.open; - - /// Whether this shard is currently reconnecting. - /// - /// [ShardToManager.disconnected] will not be dispatched if this is true. - bool reconnecting = false; - - /// Whether this shard is currently disposing itself. - /// - /// [ShardToManager.disconnected] will not be dispatched if this is true. - bool disposing = false; - - /// Whether this shard is currently connecting to the gateway. - bool connecting = false; - - /// The last sequence number - // Start at -1 and count down to avoid collisions with seq from the shard handler - int seq = -1; - - late Encoding _encoding; - - ShardRunner(this.sendPort) { - managerMessages.listen(handle); - } - - /// Sends a message back to the manager. - void execute(ShardMessage message) => sendPort.send(message); - - /// Handler for uncompressed messages received from Discord. - /// - /// Calls jsonDecode and sends the data back to the manager. - void receive(/* List|RawApiMap|String */ dynamic payload) { - if (payload is List) { - payload = utf8.decode(zlib.decode(payload)); - } - - return execute(ShardMessage( - ShardToManager.received, - seq: seq--, - data: payload is String ? jsonDecode(payload) : payload, - )); - } - - /// Handler for incoming messages from the manager. - Future handle(ShardMessage message) async { - switch (message.type) { - case ManagerToShard.send: - return send(message.data, message.seq); - case ManagerToShard.connect: - return connect(message.data['gatewayHost'] as String, message.data['useCompression'] as bool, message.seq, message.data['encoding'] as Encoding, - message.data['usePayloadCompression'] as bool); - case ManagerToShard.reconnect: - return reconnect(message.data['gatewayHost'] as String, message.data['useCompression'] as bool, message.seq, message.data['encoding'] as Encoding, - message.data['usePayloadCompression'] as bool); - case ManagerToShard.disconnect: - return disconnect(message.seq); - case ManagerToShard.dispose: - return dispose(message.seq); - } - } - - /// Initiate the connection on this shard. - /// - /// Sends [ShardToManager.connected] upon completion. - Future connect(String gatewayHost, bool useCompression, int seq, Encoding encoding, bool usePayloadCompression) async { - _encoding = encoding; - if (connected) { - execute(ShardMessage( - ShardToManager.error, - seq: seq, - data: {'message': 'Shard is already connected'}, - )); - return; - } - - if (connecting) { - execute(ShardMessage( - ShardToManager.error, - seq: seq, - data: {'message': 'Shard is already connecting'}, - )); - return; - } - - connecting = true; - - try { - final gatewayUri = Constants.gatewayUri(gatewayHost, useCompression, encoding); - - connection = await WebSocket.connect(gatewayUri.toString()); - connection!.pingInterval = const Duration(seconds: 20); - - connection!.done.then((_) { - if (reconnecting || disposing) { - return; - } - - execute(ShardMessage( - ShardToManager.disconnected, - seq: seq, - data: { - 'closeCode': connection!.closeCode!, - 'closeReason': connection!.closeReason, - }, - )); - }); - - if (useCompression) { - final filter = RawZLibFilter.inflateFilter(); - - final mappedConnection = connection!.cast>().map((rawPayload) { - filter.process(rawPayload, 0, rawPayload.length); - - final buffer = []; - for (List? decoded = []; decoded != null; decoded = filter.processed()) { - buffer.addAll(decoded); - } - - return buffer; - }); - - Stream< /* RawApiMap|String */ dynamic> stream; - - if (encoding == Encoding.etf) { - stream = mappedConnection.transform(eterl.unpacker()); - } else { - stream = mappedConnection.transform(utf8.decoder); - } - - connectionSubscription = stream.listen(receive); - } else { - if (usePayloadCompression) { - connectionSubscription = connection!.listen(receive); - } else { - connectionSubscription = - (encoding == Encoding.json ? connection!.cast() : connection!.cast>().transform(eterl.unpacker())).listen(receive); - } - } - - execute(ShardMessage(reconnecting ? ShardToManager.reconnected : ShardToManager.connected, seq: seq)); - } on WebSocketException catch (err) { - execute(ShardMessage(ShardToManager.error, seq: seq, data: {'message': err.message, 'shouldReconnect': true})); - } on SocketException catch (err) { - execute(ShardMessage(ShardToManager.error, seq: seq, data: {'message': err.message, 'shouldReconnect': true})); - } catch (err) { - execute(ShardMessage(ShardToManager.error, seq: seq, data: {'message': 'Unhanded exception $err'})); - } - - connecting = false; - } - - /// Reconnect to the server, closing the connection if necessary. - Future reconnect(String gatewayHost, bool useCompression, int seq, Encoding encoding, bool usePayloadCompression) async { - if (reconnecting) { - execute(ShardMessage( - ShardToManager.error, - seq: seq, - data: {'message': 'Shard is already reconnecting'}, - )); - } - - reconnecting = true; - if (connected) { - // Don't send a normal close code so that the bot doesn't appear offline during the reconnect. - await disconnect(seq, 3001); - } - - // Sends reconnected instead of connected so we don't have to send it here - await connect(gatewayHost, useCompression, seq, encoding, usePayloadCompression); - reconnecting = false; - } - - /// Terminate the connection on this shard. - /// - /// Sends [ShardToManager.disconnected]. - Future disconnect(int seq, [int closeCode = 1000]) async { - if (!connected) { - execute(ShardMessage( - ShardToManager.error, - seq: seq, - data: {'message': 'Cannot disconnect shard if no connection is active'}, - )); - } - - // Closing the connection will trigger the `connection.done` future we listened to when connecting, which will execute the [ShardToManager.disconnected] - // message. - await connection!.close(closeCode); - await connectionSubscription!.cancel(); - - connection = null; - connectionSubscription = null; - } - - /// Sends data on this shard. - Future send(dynamic data, int seq) async { - if (!connected) { - execute(ShardMessage( - ShardToManager.error, - seq: seq, - data: {'message': 'Cannot send data when connection is closed'}, - )); - } - - connection!.add(_encoding == Encoding.json ? jsonEncode(data) : eterl.pack(data)); - } - - /// Disposes of this shard. - /// - /// Sends [ShardToManager.disposed] upon completion. - @override - Future dispose([int? seq]) async { - disposing = true; - seq ??= (this.seq--); - - if (connected) { - await disconnect(seq); - } - - receivePort.close(); - execute(ShardMessage(ShardToManager.disposed, seq: seq)); - } -} diff --git a/lib/src/internal/shard/shard_manager.dart b/lib/src/internal/shard/shard_manager.dart deleted file mode 100644 index c3cb0e7a8..000000000 --- a/lib/src/internal/shard/shard_manager.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; - -import 'package:logging/logging.dart'; -import 'package:nyxx/src/events/member_chunk_event.dart'; -import 'package:nyxx/src/events/raw_event.dart'; -import 'package:nyxx/src/internal/connection_manager.dart'; -import 'package:nyxx/src/internal/exceptions/unrecoverable_nyxx_error.dart'; -import 'package:nyxx/src/internal/interfaces/disposable.dart'; -import 'package:nyxx/src/internal/shard/shard.dart'; -import 'package:nyxx/src/utils/builders/presence_builder.dart'; - -/// Spawns, connects, monitors, manages and terminates shards. -/// Sharding will be automatic if no user settings are supplied in -/// [ClientOptions] when instantiating [Nyxx] client instance. -/// -/// Discord gateways implement a method of user-controlled guild sharding which -/// allows for splitting events across a number of gateway connections. -/// Guild sharding is entirely user controlled, and requires no state-sharing -/// between separate connections to operate. -abstract class IShardManager implements Disposable { - /// Emitted when the shard is ready. - Stream get onConnected; - - /// Emitted when the shard encounters a connection error. - Stream get onDisconnect; - - /// Emitted when the shard resumed its connection - Stream get onResume; - - /// Emitted when shard receives member chunk. - Stream get onMemberChunk; - - /// Raw gateway payloads. You have set `dispatchRawShardEvent` in [ClientOptions] to true otherwise stream won't receive any events. - /// Also rawEvent is dispatched ONLY for payload that doesn't match any event built in into Nyxx. - Stream get rawEvent; - - /// List of shards - Iterable get shards; - - /// Average gateway latency across all shards - Duration get gatewayLatency; - - /// The number of identify requests allowed per 5 seconds - int get maxConcurrency; - - /// Number of shards spawned - int get numShards; - - /// Total number of shards for this client - int get totalNumShards; - - /// Sets presences on every shard - void setPresence(PresenceBuilder presenceBuilder); -} - -/// Spawns, connects, monitors, manages and terminates shards. -/// Sharding will be automatic if no user settings are supplied in -/// [ClientOptions] when instantiating [Nyxx] client instance. -/// -/// Discord gateways implement a method of user-controlled guild sharding which -/// allows for splitting events across a number of gateway connections. -/// Guild sharding is entirely user controlled, and requires no state-sharing -/// between separate connections to operate. -class ShardManager implements IShardManager { - /// Emitted when the shard is ready. - @override - late final Stream onConnected = onConnectController.stream; - - /// Emitted when the shard encounters a connection error. - @override - late final Stream onDisconnect = onDisconnectController.stream; - - /// Emitted when the shard resumed its connection - @override - late final Stream onResume = onDisconnectController.stream; - - /// Emitted when shard receives member chunk. - @override - late final Stream onMemberChunk = onMemberChunkController.stream; - - /// Raw gateway payloads. You have set `dispatchRawShardEvent` in [ClientOptions] to true otherwise stream won't receive any events. - /// Also rawEvent is dispatched ONLY for payload that doesn't match any event built in into Nyxx. - @override - late final Stream rawEvent = onRawEventController.stream; - - final StreamController onConnectController = StreamController.broadcast(); - final StreamController onDisconnectController = StreamController.broadcast(); - final StreamController onResumeController = StreamController.broadcast(); - final StreamController onMemberChunkController = StreamController.broadcast(); - final StreamController onRawEventController = StreamController.broadcast(); - - final Logger logger = Logger("Shard Manager"); - - /// List of shards - @override - Iterable get shards => UnmodifiableListView(_shards.values); - - /// Average gateway latency across all shards - @override - Duration get gatewayLatency => - Duration(milliseconds: (shards.map((e) => e.gatewayLatency.inMilliseconds).fold(0, (first, second) => first + second)) ~/ shards.length); - - /// The number of identify requests allowed per 5 seconds - @override - final int maxConcurrency; - - /// Reference to [ConnectionManager] - final ConnectionManager connectionManager; - - /// Number of shards spawned - @override - late final int numShards; - - /// Total number of shards for this client - @override - late final int totalNumShards; - - final Map _shards = {}; - - Duration get _identifyDelay { - /// 5s * 1000 / maxConcurrency + 250ms - final delay = (5 * 1000) ~/ maxConcurrency + 300; - return Duration(milliseconds: delay); - } - - /// Starts shard manager - ShardManager(this.connectionManager, this.maxConcurrency) { - totalNumShards = connectionManager.client.options.shardCount ?? connectionManager.recommendedShardsNum; - numShards = connectionManager.client.options.shardIds?.length ?? totalNumShards; - - if (totalNumShards < 1) { - throw UnrecoverableNyxxError("Number of shards cannot be lower than 1."); - } - - List toSpawn = _getShardsToSpawn(); - - logger.fine("Starting shard manager. Number of shards to spawn: $numShards"); - _connect(toSpawn); - } - - List _getShardsToSpawn() { - if (connectionManager.client.options.shardIds != null) { - if (connectionManager.client.options.shardCount == null) { - throw UnrecoverableNyxxError('Cannot specify shards to spawn without specifying total number of shards'); - } - - for (final id in connectionManager.client.options.shardIds!) { - if (id < 0 || id >= totalNumShards) { - throw UnrecoverableNyxxError('Invalid shard ID: $id'); - } - } - - // Clone list to prevent original list from being modified with removeLast() - return List.of(connectionManager.client.options.shardIds!); - } else { - return List.generate(totalNumShards, (id) => id); - } - } - - /// Sets presences on every shard - @override - void setPresence(PresenceBuilder presenceBuilder) { - for (final shard in shards) { - shard.setPresence(presenceBuilder); - } - } - - void _connect(List toSpawn) { - if (toSpawn.isEmpty) { - return; - } - - int shardId = toSpawn.removeLast(); - - logger.fine("Setting up shard with id: $shardId"); - - _shards[shardId] = Shard(shardId, this, connectionManager.gateway); - - Future.delayed(_identifyDelay, () => _connect(toSpawn)); - } - - @override - Future dispose() async { - logger.info("Closing gateway connections..."); - - for (final shard in _shards.values) { - if (connectionManager.client.options.shutdownShardHook != null) { - await connectionManager.client.options.shutdownShardHook!(connectionManager.client, shard); - } - await shard.dispose(); - } - - await onConnectController.close(); - await onDisconnectController.close(); - await onMemberChunkController.close(); - } -} diff --git a/lib/src/manager_mixin.dart b/lib/src/manager_mixin.dart new file mode 100644 index 000000000..7ccec1de0 --- /dev/null +++ b/lib/src/manager_mixin.dart @@ -0,0 +1,52 @@ +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/client_options.dart'; +import 'package:nyxx/src/http/managers/application_command_manager.dart'; +import 'package:nyxx/src/http/managers/channel_manager.dart'; +import 'package:nyxx/src/http/managers/interaction_manager.dart'; +import 'package:nyxx/src/http/managers/invite_manager.dart'; +import 'package:nyxx/src/http/managers/gateway_manager.dart'; +import 'package:nyxx/src/http/managers/guild_manager.dart'; +import 'package:nyxx/src/http/managers/sticker_manager.dart'; +import 'package:nyxx/src/http/managers/user_manager.dart'; +import 'package:nyxx/src/http/managers/webhook_manager.dart'; +import 'package:nyxx/src/http/managers/application_manager.dart'; +import 'package:nyxx/src/http/managers/voice_manager.dart'; + +/// An internal mixin to add managers to a [Nyxx] instance. +mixin ManagerMixin implements Nyxx { + @override + RestClientOptions get options; + + /// A [UserManager] that manages users for this client. + UserManager get users => UserManager(options.userCacheConfig, this as NyxxRest); + + /// A [ChannelManager] that manages channels for this client. + ChannelManager get channels => ChannelManager(options.channelCacheConfig, this as NyxxRest, stageInstanceConfig: options.stageInstanceCacheConfig); + + /// A [WebhookManager] that manages webhooks for this client. + WebhookManager get webhooks => WebhookManager(options.webhookCacheConfig, this as NyxxRest); + + /// A [GuildManager] that manages guilds for this client. + GuildManager get guilds => GuildManager(options.guildCacheConfig, this as NyxxRest); + + /// An [ApplicationManager] that manages applications for this client. + ApplicationManager get applications => ApplicationManager(this as NyxxRest); + + /// A [VoiceManager] that manages voice states for this client. + VoiceManager get voice => VoiceManager(this as NyxxRest); + + /// An [InviteManager] that manages invites for this client. + InviteManager get invites => InviteManager(this as NyxxRest); + + /// A [GatewayManager] that manages gateway metadata for this client. + GatewayManager get gateway => GatewayManager(this as NyxxRest); + + /// A [GlobalStickerManager] that manages global stickers. + GlobalStickerManager get stickers => GlobalStickerManager(options.globalStickerCacheConfig, this as NyxxRest); + + /// A [GlobalApplicationCommandManager] that manages global application commands. + GlobalApplicationCommandManager get commands => + GlobalApplicationCommandManager(options.applicationCommandConfig, this as NyxxRest, applicationId: (this as NyxxRest).application.id); + + InteractionManager get interactions => InteractionManager(this as NyxxRest, applicationId: (this as NyxxRest).application.id); +} diff --git a/lib/src/models/application.dart b/lib/src/models/application.dart new file mode 100644 index 000000000..c10f2142d --- /dev/null +++ b/lib/src/models/application.dart @@ -0,0 +1,289 @@ +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/managers/application_manager.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/team.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/flags.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// A partial [Application] object. +// We intentionally do not use SnowflakeEntity as applications do not have the same access in the API as other entities with IDs, so they cannot be thought of +// as being in a "group". +class PartialApplication with ToStringHelper { + /// The ID of this application. + final Snowflake id; + + /// The manager for this application. + final ApplicationManager manager; + + /// Create a new [PartialApplication]. + PartialApplication({required this.id, required this.manager}); + + /// Fetch this application's role connection metadata. + Future> fetchRoleConnectionMetadata() => manager.fetchApplicationRoleConnectionMetadata(id); + + /// Update and fetch this application's role connection metadata. + Future> updateRoleConnectionMetadata() => manager.updateApplicationRoleConnectionMetadata(id); +} + +/// {@template application} +/// An OAuth2 application. +/// {@endtemplate} +class Application extends PartialApplication { + /// The name of this application. + final String name; + + /// The hash of this application's icon. + final String? iconHash; + + /// This application's description. + final String description; + + /// A list of rpc origin urls, if rpc is enabled. + final List? rpcOrigins; + + /// Whether the bot account associated with this application can be added to guilds by anyone. + final bool isBotPublic; + + /// Whether the bot account associated with this application requires the OAuth2 code grant to be completed before joining a guild. + final bool botRequiresCodeGrant; + + /// The URL of this application's Terms of Service. + final Uri? termsOfServiceUrl; + + /// The URL of this application's Privacy Policy. + final Uri? privacyPolicyUrl; + + /// The owner of this application. + final PartialUser? owner; + + /// A hex string used to verify interactions. + final String verifyKey; + + /// If this application belongs to a team, the team which owns this app. + final Team? team; + + /// If this application is a game sold on Discord, the ID of the guild it was linked to. + final Snowflake? guildId; + + /// If this application is a game sold on Discord, the ID of the "Game SKU" that is created, if it exists. + final Snowflake? primarySkuId; + + /// If this application is a game sold on Discord, the URL slug that links to the store page. + final String? slug; + + /// The hash of this application's rich presence invite cover image. + final String? coverImageHash; + + /// The public flags for this application. + final ApplicationFlags flags; + + /// Up to 5 tags describing this application. + final List? tags; + + /// Settings for this application's default authorization link. + final InstallationParameters? installationParameters; + + /// The custom authorization link for this application. + final Uri? customInstallUrl; + + /// This application's role connection verification entry point. + /// + /// When configured, this will render the app as a verification method in the guild role verification configuration. + final Uri? roleConnectionsVerificationUrl; + + /// {@macro application} + Application({ + required super.id, + required super.manager, + required this.name, + required this.iconHash, + required this.description, + required this.rpcOrigins, + required this.isBotPublic, + required this.botRequiresCodeGrant, + required this.termsOfServiceUrl, + required this.privacyPolicyUrl, + required this.owner, + required this.verifyKey, + required this.team, + required this.guildId, + required this.primarySkuId, + required this.slug, + required this.coverImageHash, + required this.flags, + required this.tags, + required this.installationParameters, + required this.customInstallUrl, + required this.roleConnectionsVerificationUrl, + }); + + /// If this application is a game sold on Discord, the guild it was linked to. + PartialGuild? get guild => guildId == null ? null : manager.client.guilds[guildId!]; + + /// This application's icon. + CdnAsset? get icon => iconHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..appIcons(id: id.toString()), + hash: iconHash!, + ); + + /// This application's cover image. + CdnAsset? get coverImage => coverImageHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..appIcons(id: id.toString()), + hash: coverImageHash!, + ); +} + +/// Flags for an [Application]. +class ApplicationFlags extends Flags { + /// Indicates if an app uses the Auto Moderation API. + static const applicationAutoModerationRuleCreateBadge = Flag.fromOffset(6); + + /// Intent required for bots in 100 or more servers to receive presence_update events. + static const gatewayPresence = Flag.fromOffset(12); + + /// Intent required for bots in under 100 servers to receive presence_update events, found on the Bot page in your app's settings. + static const gatewayPresenceLimited = Flag.fromOffset(13); + + /// Intent required for bots in 100 or more servers to receive member-related events like guild_member_add. See the list of member-related events under GUILD_MEMBERS. + static const gatewayGuildMembers = Flag.fromOffset(14); + + /// Intent required for bots in under 100 servers to receive member-related events like guild_member_add, found on the Bot page in your app's settings. See the list of member-related events under GUILD_MEMBERS. + static const gatewayGuildMembersLimited = Flag.fromOffset(15); + + /// Indicates unusual growth of an app that prevents verification. + static const verificationPendingGuildLimit = Flag.fromOffset(16); + + /// Indicates if an app is embedded within the Discord client (currently unavailable publicly). + static const embedded = Flag.fromOffset(17); + + /// Intent required for bots in 100 or more servers to receive message content. + static const gatewayMessageContent = Flag.fromOffset(18); + + /// Intent required for bots in under 100 servers to receive message content, found on the Bot page in your app's settings. + static const gatewayMessageContentLimited = Flag.fromOffset(19); + + /// Indicates if an app has registered global application commands. + static const applicationCommandBadge = Flag.fromOffset(23); + + /// Whether this application has the [applicationAutoModerationRuleCreateBadge] flag set. + bool get usesApplicationAutoModerationRuleCreateBadge => has(applicationAutoModerationRuleCreateBadge); + + /// Whether this application has the [gatewayPresence] flag set. + bool get hasGatewayPresence => has(gatewayPresence); + + /// Whether this application has the [gatewayPresenceLimited] flag set. + bool get hasGatewayPresenceLimited => has(gatewayPresenceLimited); + + /// Whether this application has the [gatewayGuildMembers] flag set. + bool get hasGatewayGuildMembers => has(gatewayGuildMembers); + + /// Whether this application has the [gatewayGuildMembersLimited] flag set. + bool get hasGatewayGuildMembersLimited => has(gatewayGuildMembersLimited); + + /// Whether this application has the [verificationPendingGuildLimit] flag set. + bool get isVerificationPendingGuildLimit => has(verificationPendingGuildLimit); + + /// Whether this application has the [embedded] flag set. + bool get isEmbedded => has(embedded); + + /// Whether this application has the [gatewayMessageContent] flag set. + bool get hasGatewayMessageContent => has(gatewayMessageContent); + + /// Whether this application has the [gatewayMessageContentLimited] flag set. + bool get hasGatewayMessageContentLimited => has(gatewayMessageContentLimited); + + /// Whether this application has the [applicationCommandBadge] flag set. + bool get hasApplicationCommandBadge => has(applicationCommandBadge); + + /// Create a new [ApplicationFlags]. + ApplicationFlags(super.value); +} + +/// {@template installation_parameters} +/// Configuration for an [Application]'s authorization link. +/// {@endtemplate} +class InstallationParameters with ToStringHelper { + /// The OAuth2 scopes to add the application to the guild with. + final List scopes; + + /// The [Permissions] to add the application to he guild with. + final Permissions permissions; + + /// {@macro installation_parameters} + InstallationParameters({ + required this.scopes, + required this.permissions, + }); +} + +/// {@template application_role_connection_metadata} +/// Metadata for an app's role connections. +/// {@endtemplate} +class ApplicationRoleConnectionMetadata with ToStringHelper { + /// The type of connection. + final ConnectionMetadataType type; + + /// The key of the entry in the metadata to check. + final String key; + + /// The name of this role connection. + final String name; + + /// A localized map of names for this role connection. + final Map? localizedNames; + + /// A description of this role connection. + final String description; + + /// A localized map of descriptions for this role connection. + final Map? localizedDescriptions; + + /// {@macro application_role_connection_metadata} + ApplicationRoleConnectionMetadata({ + required this.type, + required this.key, + required this.name, + required this.localizedNames, + required this.description, + required this.localizedDescriptions, + }); +} + +/// The type of an [ApplicationRoleConnectionMetadata]. +enum ConnectionMetadataType { + integerLessThanOrEqual._(1), + integerGreaterThanOrEqual._(2), + integerEqual._(3), + integerNotEqual._(4), + dateTimeLessThanOrEqual._(5), + dateTimeGreaterThanOrEqual._(6), + booleanEqual._(7), + booleanNotEqual._(8); + + /// The value of this [ConnectionMetadataType]. + final int value; + + const ConnectionMetadataType._(this.value); + + /// Parse a [ConnectionMetadataType] from an [int]. + /// + /// The [value] must be a valid connection metadata type. + factory ConnectionMetadataType.parse(int value) => ConnectionMetadataType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown connection metadata type', value), + ); + + @override + String toString() => 'ConnectionMetadataType($value)'; +} diff --git a/lib/src/models/channel/channel.dart b/lib/src/models/channel/channel.dart new file mode 100644 index 000000000..142de5f73 --- /dev/null +++ b/lib/src/models/channel/channel.dart @@ -0,0 +1,120 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/http/managers/channel_manager.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +/// A partial [Channel] object. +class PartialChannel extends ManagedSnowflakeEntity { + @override + final ChannelManager manager; + + /// Create a new [PartialChannel]. + PartialChannel({required super.id, required this.manager}); + + /// Update this channel. + /// + /// External references: + /// * [ChannelManager.update] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#modify-channel + Future update(UpdateBuilder builder) => manager.update(id, builder); + + /// Delete this channel. + /// + /// External references: + /// * [ChannelManager.delete] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#deleteclose-channel + Future delete({String? auditLogReason}) => manager.delete(id, auditLogReason: auditLogReason); + + /// Follow another channel's announcement messages in this channel. + /// + /// External references: + /// * [ChannelManager.followChannel] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#follow-announcement-channel + Future follow(Snowflake id) => manager.followChannel(this.id, id); +} + +/// {@template channel} +/// A channel of any type. +/// {@endtemplate} +abstract class Channel extends PartialChannel { + /// The type of this channel. + ChannelType get type; + + /// {@macro channel} + Channel({required super.id, required super.manager}); +} + +/// The type of a channel. +enum ChannelType { + /// A text channel in a [Guild]. + guildText._(0), + + /// A DM channel with a single other recipient. + dm._(1), + + /// A voice channel in a [Guild]. + guildVoice._(2), + + /// A DM channel with multiple recipients. + groupDm._(3), + + /// A category in a [Guild]. + guildCategory._(4), + + /// An announcement channel in a [Guild]. + guildAnnouncement._(5), + + /// A [Thread] in an announcement channel. + announcementThread._(10), + + /// A public thread. + publicThread._(11), + + /// A private thread. + privateThread._(12), + + /// A stage channel in a [Guild]. + guildStageVoice._(13), + + /// A [Guild] directory. + guildDirectory._(14), + + /// A forum channel in a [Guild]. + guildForum._(15); + + /// The value of this [ChannelType]. + final int value; + + const ChannelType._(this.value); + + /// Parse a [ChannelType] from a [value]. + /// + /// The [value] must be a valid channel type. + factory ChannelType.parse(int value) => ChannelType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown channel type', value), + ); + + @override + String toString() => 'ChannelType($value)'; +} + +/// A set of flags applied to channels. +// Currently only used in forum channels and threads +class ChannelFlags extends Flags { + /// The channel is pinned in a forum channel. + static const pinned = Flag.fromOffset(1); + + /// The forum channel requires threads to have tags. + static const requireTag = Flag.fromOffset(4); + + /// Whether this channel has the [pinned] flag set. + bool get isPinned => has(pinned); + + /// Whether this channel has the [requireTag] flag set. + bool get requiresTag => has(requireTag); + + /// Create a new [ChannelFlags]. + const ChannelFlags(super.value); +} diff --git a/lib/src/models/channel/followed_channel.dart b/lib/src/models/channel/followed_channel.dart new file mode 100644 index 000000000..55228001a --- /dev/null +++ b/lib/src/models/channel/followed_channel.dart @@ -0,0 +1,28 @@ +import 'package:nyxx/src/http/managers/channel_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template followed_channel} +/// Information about a channel which has been followed. +/// {@endtemplate} +class FollowedChannel with ToStringHelper { + /// The manager for this [FollowedChannel]. + final ChannelManager manager; + + /// The ID of the channel that has been followed. + final Snowflake channelId; + + /// The ID of the webhook created in the subscriber channel. + final Snowflake webhookId; + + /// {@macro followed_channel} + FollowedChannel({required this.manager, required this.channelId, required this.webhookId}); + + /// The followed channel. + PartialChannel get channel => manager.client.channels[channelId]; + + /// The webhook created in the subscriber channel. + PartialWebhook get webhook => manager.client.webhooks[webhookId]; +} diff --git a/lib/src/models/channel/guild_channel.dart b/lib/src/models/channel/guild_channel.dart new file mode 100644 index 000000000..ada36bcde --- /dev/null +++ b/lib/src/models/channel/guild_channel.dart @@ -0,0 +1,61 @@ +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +/// A channel in a [Guild]. +abstract class GuildChannel implements Channel { + /// The ID of the [Guild] this channel is in. + Snowflake get guildId; + + /// The positing on this channel in the guild's channel list. + int get position; + + /// The permission overwrites for members and roles in this channel. + List get permissionOverwrites; + + /// The name of this channel. + String get name; + + /// Whether this channel is marked as NSFW. + bool get isNsfw; + + /// The ID of this channel's parent. + /// + /// This will be the ID of a [GuildCategory] for non-thread channels, and the ID of a [HasThreadsChannel] for [Thread]s. + Snowflake? get parentId; + + /// The guild this channel is in. + PartialGuild get guild; + + /// This channel's parent. + PartialChannel? get parent; + + /// Update or create a permission overwrite in this channel. + /// + /// External references: + /// * [ChannelManager.updatePermissionOverwrite] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#edit-channel-permissions + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder); + + /// Remove a permission overwrite from this channel. + /// + /// External references: + /// * [ChannelManager.deletePermissionOverwrite] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#delete-channel-permission + Future deletePermissionOverwrite(Snowflake id); + + /// List the webhooks in this channel. + Future> fetchWebhooks(); + + /// List the invites in this channel. + Future> listInvites(); + + /// Create an invite to this channel. + Future createInvite(InviteBuilder builder, {String? auditLogReason}); +} diff --git a/lib/src/models/channel/has_threads_channel.dart b/lib/src/models/channel/has_threads_channel.dart new file mode 100644 index 000000000..1e3a1be03 --- /dev/null +++ b/lib/src/models/channel/has_threads_channel.dart @@ -0,0 +1,60 @@ +import 'package:nyxx/src/builders/channel/thread.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/channel/thread_list.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// A channel which can have threads. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/topics/threads +abstract class HasThreadsChannel implements GuildChannel { + /// The default [Thread.autoArchiveDuration] for [Thread]s created in this channel. + /// + /// External references: + /// * [ThreadBuilder.archiveOneHour], [ThreadBuilder.archiveOneDay], [ThreadBuilder.archiveThreeDays] and [ThreadBuilder.archiveOneWeek], the values this + /// field can take. + Duration get defaultAutoArchiveDuration; + + /// The default [Thread.rateLimitPerUser] for [Thread]s created in this channel. + Duration? get defaultThreadRateLimitPerUser; + + /// Create a [Thread] from a [Message] in this channel. + /// + /// Cannot be used in [ForumChannel]s. + /// + /// External references: + /// * [ChannelManager.createThreadFromMessage] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#start-thread-from-message + Future createThreadFromMessage(Snowflake messageId, ThreadFromMessageBuilder builder); + + /// Create a [Thread] in this channel. + /// + /// Cannot be used in [ForumChannel]s. + /// + /// External references: + /// * [ChannelManager.createThread] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#start-thread-without-message + Future createThread(ThreadBuilder builder); + + /// List the public archived [Thread]s in this channel. + /// + /// External references: + /// * [ChannelManager.listPublicArchivedThreads] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#list-public-archived-threads + Future listPublicArchivedThreads({DateTime? before, int? limit}); + + /// List the private archived [Thread]s in this channel. + /// + /// External references: + /// * [ChannelManager.listPrivateArchivedThreads] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#list-private-archived-threads + Future listPrivateArchivedThreads({DateTime? before, int? limit}); + + /// List the private archived [Thread]s in this channel which the current user has joined. + /// + /// External references: + /// * [ChannelManager.listJoinedPrivateArchivedThreads] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#list-joined-private-archived-threads + Future listJoinedPrivateArchivedThreads({DateTime? before, int? limit}); +} diff --git a/lib/src/models/channel/stage_instance.dart b/lib/src/models/channel/stage_instance.dart new file mode 100644 index 000000000..10a1c7706 --- /dev/null +++ b/lib/src/models/channel/stage_instance.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:nyxx/src/http/managers/channel_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/scheduled_event.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; + +/// {@template stage_instance} +/// Information about a live stage. +/// {@endtemplate} +class StageInstance extends SnowflakeEntity { + /// The manager this [StageInstance] is associated with. + final ChannelManager manager; + + /// The ID of the guild this instance is in. + final Snowflake guildId; + + /// The ID of the channel this instance is for. + final Snowflake channelId; + + /// The topic of this instance. + final String topic; + + /// The privacy level of this instance. + final PrivacyLevel privacyLevel; + + /// The ID of the scheduled event associated with this instance. + final Snowflake? scheduledEventId; + + /// {@macro stage_instance} + StageInstance({ + required super.id, + required this.manager, + required this.guildId, + required this.channelId, + required this.topic, + required this.privacyLevel, + required this.scheduledEventId, + }); + + /// The guild this instance is in. + PartialGuild get guild => manager.client.guilds[guildId]; + + /// The channel this instance is in. + PartialChannel get channel => manager.client.channels[channelId]; + + /// The scheduled event associated with this instance. + PartialScheduledEvent? get scheduledEvent => scheduledEventId == null ? null : guild.scheduledEvents[scheduledEventId!]; + + @override + Future fetch() => manager.fetchStageInstance(channelId); + + @override + Future get() async => manager.stageInstanceCache[channelId] ?? await fetch(); +} + +/// The privacy level of a [StageInstance]. +enum PrivacyLevel { + public._(1), + guildOnly._(2); + + final int value; + + const PrivacyLevel._(this.value); + + /// Parse a [PrivacyLevel] from an [int]. + /// + /// The [value] must be a valid privacy level. + factory PrivacyLevel.parse(int value) => PrivacyLevel.values.firstWhere( + (level) => level.value == value, + orElse: () => throw FormatException('Unknown privacy level', value), + ); +} diff --git a/lib/src/models/channel/text_channel.dart b/lib/src/models/channel/text_channel.dart new file mode 100644 index 000000000..a9afa3f18 --- /dev/null +++ b/lib/src/models/channel/text_channel.dart @@ -0,0 +1,53 @@ +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/http/managers/message_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// A partial [TextChannel]. +class PartialTextChannel extends PartialChannel { + /// A [Manager] for the [Message]s of this channel. + MessageManager get messages => MessageManager(manager.client.options.messageCacheConfig, manager.client, channelId: id); + + /// Create a new [PartialTextChannel]. + PartialTextChannel({required super.id, required super.manager}); + + /// Send a message to this channel. + /// + /// Returns the created message. + /// + /// External references: + /// * [MessageManager.create] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#create-message + Future sendMessage(MessageBuilder builder) => messages.create(builder); + + /// Trigger a typing indicator in this channel from the current user. + /// + /// External references: + /// * [ChannelManager.triggerTyping] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#trigger-typing-indicator + Future triggerTyping() => manager.triggerTyping(id); + + // DO NOT override get() and fetch() to return TextChannels + // Although this improves the API, all PartialChannels returned + // by ChannelManager.[] are PartialTextChannels, so the overrides + // added in this class would be used by *all* PartialChannels, + // even non-text ones. +} + +//// A text channel +abstract class TextChannel extends PartialTextChannel implements Channel { + /// The ID of the last [Message] snt in this channel, or `null` if no messages have been sent. + Snowflake? get lastMessageId; + + /// The rate limit duration per user. + Duration? get rateLimitPerUser; + + /// The time at which the last message was pinned, or `null` if no messages have been pinned. + DateTime? get lastPinTimestamp; + + TextChannel({required super.id, required super.manager}); + + /// The last message sent in this channel, or `null` if no messages have been sent. + PartialMessage? get lastMessage; +} diff --git a/lib/src/models/channel/thread.dart b/lib/src/models/channel/thread.dart new file mode 100644 index 000000000..f26f953e4 --- /dev/null +++ b/lib/src/models/channel/thread.dart @@ -0,0 +1,128 @@ +import 'package:nyxx/src/http/managers/channel_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +/// A thread channel. +abstract class Thread implements TextChannel, GuildChannel { + /// The ID of the user that created this thread. + Snowflake get ownerId; + + /// An approximate count of messages in this channel. + /// + /// Stops counting after 50. + int get messageCount; + + /// An approximate number of members in this thread. + int get approximateMemberCount; + + /// Whether this thread is archived. + bool get isArchived; + + /// The time after which this thread will be archived. + Duration get autoArchiveDuration; + + /// The time at which this thread's archive status was last updated. + /// + /// Will be the creation time if [isArchived] is `false`. + DateTime get archiveTimestamp; + + /// Whether this thread is locked. + bool get isLocked; + + /// The time at which this thread was created. + DateTime get createdAt; + + /// The total number of messages sent in this thread, including deleted messages. + int get totalMessagesSent; + + /// If this thread is in a [ForumChannel], the IDs of the [ForumTag]s applied to this thread. + List? get appliedTags; + + /// The flags this thread has applied. + ChannelFlags? get flags; + + /// The user that created this thread. + PartialUser get owner; + + /// The member for the user that created this thread. + PartialMember get ownerMember; + + /// Add a member to this thread. + /// + /// External references: + /// * [ChannelManager.addThreadMember] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#add-thread-member + Future addThreadMember(Snowflake memberId); + + /// Remove a member from this thread. + /// + /// External references: + /// * [ChannelManager.removeThreadMember] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#remove-thread-member + Future removeThreadMember(Snowflake memberId); + + /// Fetch the [ThreadMember] for a given member. + /// + /// External reference: + /// * [ChannelManager.fetchThreadMember] + /// * Discord API References: https://discord.com/developers/docs/resources/channel#remove-thread-member + Future fetchThreadMember(Snowflake memberId); + + /// List the members of this thread. + /// + /// External references: + /// * [ChannelManager.listThreadMembers] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#list-thread-members + Future> listThreadMembers({bool? withMembers, Snowflake? after, int? limit}); +} + +/// {@template partial_thread_member} +/// A partial [ThreadMember]. +/// {@endtemplate} +class PartialThreadMember { + /// The time at which this member joined the thread. + final DateTime joinTimestamp; + + /// Internal flags used by Discord for notification purposes. + final Flags flags; + + /// {@macro partial_thread_member} + PartialThreadMember({required this.joinTimestamp, required this.flags}); +} + +/// {@template thread_member} +/// Additional information associated with a [Member] in a [Thread]. +/// {@endtemplate thread_member} +class ThreadMember extends PartialThreadMember { + /// The manager for this [ThreadMember]. + final ChannelManager manager; + + /// The ID of the thread this member is in. + final Snowflake threadId; + + /// The ID of the user associated with this thread member. + final Snowflake userId; + + final Member? member; + + /// {@macro thread_member} + ThreadMember({ + required super.joinTimestamp, + required super.flags, + required this.manager, + required this.threadId, + required this.userId, + required this.member, + }); + + /// The thread this member is in. + PartialTextChannel get thread => manager.client.channels[threadId] as PartialTextChannel; + + /// The user associated with this thread member. + PartialUser get user => manager.client.users[userId]; +} diff --git a/lib/src/models/channel/thread_list.dart b/lib/src/models/channel/thread_list.dart new file mode 100644 index 000000000..6ccda21ee --- /dev/null +++ b/lib/src/models/channel/thread_list.dart @@ -0,0 +1,23 @@ +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template thread_list} +/// A list of threads and thread members. +/// {@endtemplate} +class ThreadList with ToStringHelper { + /// A list of threads. + final List threads; + + /// A list of thread members associated with [threads]. + final List members; + + /// Whether more results can be queried. + final bool hasMore; + + /// {@macro thread_list} + ThreadList({ + required this.threads, + required this.members, + required this.hasMore, + }); +} diff --git a/lib/src/models/channel/types/announcement_thread.dart b/lib/src/models/channel/types/announcement_thread.dart new file mode 100644 index 000000000..4664fb52a --- /dev/null +++ b/lib/src/models/channel/types/announcement_thread.dart @@ -0,0 +1,147 @@ +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +class AnnouncementThread extends TextChannel implements Thread { + @override + final List? appliedTags; + + @override + final int approximateMemberCount; + + @override + final DateTime archiveTimestamp; + + @override + final Duration autoArchiveDuration; + + @override + final DateTime createdAt; + + @override + final Snowflake guildId; + + @override + final bool isArchived; + + @override + final bool isLocked; + + @override + final bool isNsfw; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final int messageCount; + + @override + final String name; + + @override + final Snowflake ownerId; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + final Duration? rateLimitPerUser; + + @override + final int totalMessagesSent; + + @override + final ChannelFlags? flags; + + @override + ChannelType get type => ChannelType.announcementThread; + + AnnouncementThread({ + required super.id, + required super.manager, + required this.appliedTags, + required this.approximateMemberCount, + required this.archiveTimestamp, + required this.autoArchiveDuration, + required this.createdAt, + required this.guildId, + required this.isArchived, + required this.isLocked, + required this.isNsfw, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.messageCount, + required this.name, + required this.ownerId, + required this.parentId, + required this.permissionOverwrites, + required this.position, + required this.rateLimitPerUser, + required this.totalMessagesSent, + required this.flags, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; + + @override + PartialUser get owner => manager.client.users[ownerId]; + + @override + PartialMember get ownerMember => guild.members[ownerId]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + @override + Future addThreadMember(Snowflake memberId) => manager.addThreadMember(id, memberId); + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future fetchThreadMember(Snowflake memberId) => manager.fetchThreadMember(id, memberId); + + @override + Future> listThreadMembers({bool? withMembers, Snowflake? after, int? limit}) => + manager.listThreadMembers(id, after: after, limit: limit, withMembers: withMembers); + + @override + Future removeThreadMember(Snowflake memberId) => manager.removeThreadMember(id, memberId); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => manager.client.webhooks.fetchChannelWebhooks(id); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} diff --git a/lib/src/models/channel/types/directory.dart b/lib/src/models/channel/types/directory.dart new file mode 100644 index 000000000..118aa1e0a --- /dev/null +++ b/lib/src/models/channel/types/directory.dart @@ -0,0 +1,12 @@ +import 'package:nyxx/src/models/channel/channel.dart'; + +/// {@template directory_channel} +/// A directory channel. +/// {@endtemplate} +class DirectoryChannel extends Channel { + @override + ChannelType get type => ChannelType.guildDirectory; + + /// {@macro directory_channel} + DirectoryChannel({required super.id, required super.manager}); +} diff --git a/lib/src/models/channel/types/dm.dart b/lib/src/models/channel/types/dm.dart new file mode 100644 index 000000000..514b55e3c --- /dev/null +++ b/lib/src/models/channel/types/dm.dart @@ -0,0 +1,38 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +/// {@template dm_channel} +/// A DM channel. +/// {@endtemplate} +class DmChannel extends TextChannel { + /// The recipient of this channel. + final User recipient; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final Duration? rateLimitPerUser; + + @override + ChannelType get type => ChannelType.dm; + + /// {@macro dm_channel} + DmChannel({ + required super.id, + required super.manager, + required this.recipient, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.rateLimitPerUser, + }); + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; +} diff --git a/lib/src/models/channel/types/forum.dart b/lib/src/models/channel/types/forum.dart new file mode 100644 index 000000000..daf87de97 --- /dev/null +++ b/lib/src/models/channel/types/forum.dart @@ -0,0 +1,202 @@ +import 'package:nyxx/src/builders/channel/thread.dart'; +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/has_threads_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/channel/thread_list.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template forum_channel} +/// A forum channel. +/// {@endtemplate} +class ForumChannel extends Channel implements GuildChannel, HasThreadsChannel { + /// The topic of this channel. + final String? topic; + + /// The ID of the last [Thread] created. + final Snowflake? lastThreadId; + + /// The time at which the last message was pinned. + final DateTime? lastPinTimestamp; + + /// Any flags applied to this channel. + final ChannelFlags flags; + + /// A list of tags available in this channel. + final List availableTags; + + /// The default reaction for this channel. + final DefaultReaction? defaultReaction; + + @override + final Duration defaultAutoArchiveDuration; + + @override + final Duration? defaultThreadRateLimitPerUser; + + @override + final Snowflake guildId; + + @override + final bool isNsfw; + + @override + final String name; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + ChannelType get type => ChannelType.guildForum; + + /// {@macro forum_channel} + ForumChannel({ + required super.id, + required super.manager, + required this.topic, + required this.lastThreadId, + required this.lastPinTimestamp, + required this.flags, + required this.availableTags, + required this.defaultReaction, + required this.defaultAutoArchiveDuration, + required this.defaultThreadRateLimitPerUser, + required this.guildId, + required this.isNsfw, + required this.name, + required this.parentId, + required this.permissionOverwrites, + required this.position, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + /// Create a thread in this forum channel. + /// + /// External references: + /// * [ChannelManager.createForumThread] + /// * Discord API Reference: https://discord.com/developers/docs/resources/channel#start-thread-in-forum-channel + Future createForumThread(ForumThreadBuilder builder) => manager.createForumThread(id, builder); + + @override + Future createThread(ThreadBuilder builder) => throw UnsupportedError('Cannot create a non forum thread in a forum channel'); + + @override + Future createThreadFromMessage(Snowflake messageId, ThreadFromMessageBuilder builder) => + throw UnsupportedError('Cannot create a non forum thread in a forum channel'); + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future listPrivateArchivedThreads({DateTime? before, int? limit}) => manager.listPrivateArchivedThreads(id, before: before, limit: limit); + + @override + Future listPublicArchivedThreads({DateTime? before, int? limit}) => manager.listPublicArchivedThreads(id, before: before, limit: limit); + + @override + Future listJoinedPrivateArchivedThreads({DateTime? before, int? limit}) => + manager.listJoinedPrivateArchivedThreads(id, before: before, limit: limit); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => manager.client.webhooks.fetchChannelWebhooks(id); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} + +/// {@template forum_tag} +/// A tag in a forum channel. +/// {@endtemplate} +class ForumTag with ToStringHelper { + /// The ID of this tag. + final Snowflake id; + + /// The name of this tag. + final String name; + + /// Whether this tag is moderated. + final bool isModerated; + + /// The ID of the emoji for this tag. + final Snowflake? emojiId; + + /// The name of the emoji for this tag. + final String? emojiName; + + /// {@macro forum_tag} + ForumTag({ + required this.id, + required this.name, + required this.isModerated, + required this.emojiId, + required this.emojiName, + }); +} + +/// {@template default_reaction} +/// A default reaction in a [ForumChannel]. +/// {@endtemplate} +class DefaultReaction with ToStringHelper { + /// The ID of the emoji. + final Snowflake? emojiId; + + /// The name of the emoji. + final String? emojiName; + + /// {@macro default_reaction} + DefaultReaction({required this.emojiId, required this.emojiName}); +} + +/// The sorting order in a [ForumChannel]. +enum ForumSort { + latestActivity._(0), + creationDate._(1); + + /// The value of this forum sort. + final int value; + + const ForumSort._(this.value); + + @override + String toString() => 'ForumSort($value)'; +} + +/// The layout in a [ForumChannel]. +enum ForumLayout { + notSet._(0), + listView._(1), + galleryView._(2); + + /// The value of this forum layout. + final int value; + + const ForumLayout._(this.value); + + @override + String toString() => 'ForumLayout($value)'; +} diff --git a/lib/src/models/channel/types/group_dm.dart b/lib/src/models/channel/types/group_dm.dart new file mode 100644 index 000000000..4d3823bcd --- /dev/null +++ b/lib/src/models/channel/types/group_dm.dart @@ -0,0 +1,65 @@ +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +/// {@template group_dm_channel} +/// A DM channel with multiple recipients. +/// {@endtemplate} +class GroupDmChannel extends TextChannel { + /// The name of this channel. + final String name; + + /// The recipients of this channel. + final List recipients; + + /// The hash of this channel's icon. + final String? iconHash; + + /// The ID of this channel's owner. + final Snowflake ownerId; + + /// The ID of the application which created this channel, if it was created by an application. + final Snowflake? applicationId; + + /// Whether this channel is managed. + final bool isManaged; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final Duration? rateLimitPerUser; + + @override + ChannelType get type => ChannelType.groupDm; + + /// {@macro group_dm_channel} + GroupDmChannel({ + required super.id, + required super.manager, + required this.name, + required this.recipients, + required this.iconHash, + required this.ownerId, + required this.applicationId, + required this.isManaged, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.rateLimitPerUser, + }); + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; + + /// This channel's owner. + PartialUser get owner => manager.client.users[ownerId]; + + /// The application that created this channel, if it was created by an application. + PartialApplication? get application => applicationId == null ? null : manager.client.applications[applicationId!]; +} diff --git a/lib/src/models/channel/types/guild_announcement.dart b/lib/src/models/channel/types/guild_announcement.dart new file mode 100644 index 000000000..afcb40308 --- /dev/null +++ b/lib/src/models/channel/types/guild_announcement.dart @@ -0,0 +1,118 @@ +import 'package:nyxx/src/builders/channel/thread.dart'; +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/has_threads_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/channel/thread_list.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +/// {@template guild_announcement_channel} +/// An announcement channel in a [Guild]. +/// {@endtemplate} +class GuildAnnouncementChannel extends TextChannel implements GuildChannel, HasThreadsChannel { + /// The topic of this channel. + final String? topic; + + @override + final Duration defaultAutoArchiveDuration; + + @override + final Duration? defaultThreadRateLimitPerUser; + + @override + final Snowflake guildId; + + @override + final bool isNsfw; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final String name; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + final Duration? rateLimitPerUser; + + @override + ChannelType get type => ChannelType.guildAnnouncement; + + /// {@macro guild_announcement_channel} + GuildAnnouncementChannel({ + required super.id, + required super.manager, + required this.topic, + required this.defaultAutoArchiveDuration, + required this.defaultThreadRateLimitPerUser, + required this.guildId, + required this.isNsfw, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.name, + required this.parentId, + required this.permissionOverwrites, + required this.position, + required this.rateLimitPerUser, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + @override + Future createThread(ThreadBuilder builder) => manager.createThread(id, builder); + + @override + Future createThreadFromMessage(Snowflake messageId, ThreadFromMessageBuilder builder) => manager.createThreadFromMessage(id, messageId, builder); + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future listPrivateArchivedThreads({DateTime? before, int? limit}) => manager.listPrivateArchivedThreads(id, before: before, limit: limit); + + @override + Future listPublicArchivedThreads({DateTime? before, int? limit}) => manager.listPublicArchivedThreads(id, before: before, limit: limit); + + @override + Future listJoinedPrivateArchivedThreads({DateTime? before, int? limit}) => + manager.listJoinedPrivateArchivedThreads(id, before: before, limit: limit); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => manager.client.webhooks.fetchChannelWebhooks(id); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} diff --git a/lib/src/models/channel/types/guild_category.dart b/lib/src/models/channel/types/guild_category.dart new file mode 100644 index 000000000..54816fc10 --- /dev/null +++ b/lib/src/models/channel/types/guild_category.dart @@ -0,0 +1,69 @@ +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +/// {@template guild_category} +/// A category for organizing other [Channel]s in a [Guild]. +/// {@endtemplate} +class GuildCategory extends Channel implements GuildChannel { + @override + final Snowflake guildId; + + @override + final bool isNsfw; + + @override + final String name; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + ChannelType get type => ChannelType.guildCategory; + + /// {@macro guild_category} + GuildCategory({ + required super.id, + required super.manager, + required this.guildId, + required this.isNsfw, + required this.name, + required this.parentId, + required this.permissionOverwrites, + required this.position, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => throw UnsupportedError('Cannot fetch webhooks in guild category'); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} diff --git a/lib/src/models/channel/types/guild_stage.dart b/lib/src/models/channel/types/guild_stage.dart new file mode 100644 index 000000000..ca7d1ba87 --- /dev/null +++ b/lib/src/models/channel/types/guild_stage.dart @@ -0,0 +1,103 @@ +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/voice_channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +/// {@template guild_stage_channel} +/// A stage channel. +/// {@endtemplate} +class GuildStageChannel extends TextChannel implements VoiceChannel, GuildChannel { + @override + final int bitrate; + + @override + final Snowflake guildId; + + @override + final bool isNsfw; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final String name; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + final Duration? rateLimitPerUser; + + @override + final String? rtcRegion; + + @override + final int? userLimit; + + @override + final VideoQualityMode videoQualityMode; + + @override + ChannelType get type => ChannelType.guildStageVoice; + + /// {@macro guild_stage_channel} + GuildStageChannel({ + required super.id, + required super.manager, + required this.bitrate, + required this.guildId, + required this.isNsfw, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.name, + required this.parentId, + required this.permissionOverwrites, + required this.position, + required this.rateLimitPerUser, + required this.rtcRegion, + required this.userLimit, + required this.videoQualityMode, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => manager.client.webhooks.fetchChannelWebhooks(id); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} diff --git a/lib/src/models/channel/types/guild_text.dart b/lib/src/models/channel/types/guild_text.dart new file mode 100644 index 000000000..0991deaae --- /dev/null +++ b/lib/src/models/channel/types/guild_text.dart @@ -0,0 +1,118 @@ +import 'package:nyxx/src/builders/channel/thread.dart'; +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/has_threads_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/channel/thread_list.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +/// {@template guild_text_channel} +/// A [TextChannel] in a [Guild]. +/// {@endtemplate} +class GuildTextChannel extends TextChannel implements GuildChannel, HasThreadsChannel { + /// The topic of this channel. + final String? topic; + + @override + final Duration defaultAutoArchiveDuration; + + @override + final Duration? defaultThreadRateLimitPerUser; + + @override + final Snowflake guildId; + + @override + final bool isNsfw; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final String name; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + final Duration? rateLimitPerUser; + + @override + ChannelType get type => ChannelType.guildText; + + /// {@macro guild_text_channel} + GuildTextChannel({ + required super.id, + required super.manager, + required this.topic, + required this.defaultAutoArchiveDuration, + required this.defaultThreadRateLimitPerUser, + required this.guildId, + required this.isNsfw, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.name, + required this.parentId, + required this.permissionOverwrites, + required this.position, + required this.rateLimitPerUser, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + @override + Future createThread(ThreadBuilder builder) => manager.createThread(id, builder); + + @override + Future createThreadFromMessage(Snowflake messageId, ThreadFromMessageBuilder builder) => manager.createThreadFromMessage(id, messageId, builder); + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future listPrivateArchivedThreads({DateTime? before, int? limit}) => manager.listPrivateArchivedThreads(id, before: before, limit: limit); + + @override + Future listPublicArchivedThreads({DateTime? before, int? limit}) => manager.listPublicArchivedThreads(id, before: before, limit: limit); + + @override + Future listJoinedPrivateArchivedThreads({DateTime? before, int? limit}) => + manager.listJoinedPrivateArchivedThreads(id, before: before, limit: limit); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => manager.client.webhooks.fetchChannelWebhooks(id); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} diff --git a/lib/src/models/channel/types/guild_voice.dart b/lib/src/models/channel/types/guild_voice.dart new file mode 100644 index 000000000..40e9a6e45 --- /dev/null +++ b/lib/src/models/channel/types/guild_voice.dart @@ -0,0 +1,103 @@ +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/voice_channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +/// {@template guild_voice_channel} +/// A [VoiceChannel] in a [Guild]. +/// {@endtemplate} +class GuildVoiceChannel extends TextChannel implements GuildChannel, VoiceChannel { + @override + final int bitrate; + + @override + final Snowflake guildId; + + @override + final bool isNsfw; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final String name; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + final Duration? rateLimitPerUser; + + @override + final String? rtcRegion; + + @override + final int? userLimit; + + @override + final VideoQualityMode videoQualityMode; + + @override + ChannelType get type => ChannelType.guildVoice; + + /// {@macro guild_voice_channel} + GuildVoiceChannel({ + required super.id, + required super.manager, + required this.bitrate, + required this.guildId, + required this.isNsfw, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.name, + required this.parentId, + required this.permissionOverwrites, + required this.position, + required this.rateLimitPerUser, + required this.rtcRegion, + required this.userLimit, + required this.videoQualityMode, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => manager.client.webhooks.fetchChannelWebhooks(id); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} diff --git a/lib/src/models/channel/types/private_thread.dart b/lib/src/models/channel/types/private_thread.dart new file mode 100644 index 000000000..63b5e98a8 --- /dev/null +++ b/lib/src/models/channel/types/private_thread.dart @@ -0,0 +1,154 @@ +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +/// {@template private_thread} +/// A private [Thread] channel. +/// {@endtemplate} +class PrivateThread extends TextChannel implements Thread { + final bool isInvitable; + + @override + final List? appliedTags; + + @override + final int approximateMemberCount; + + @override + final DateTime archiveTimestamp; + + @override + final Duration autoArchiveDuration; + + @override + final DateTime createdAt; + + @override + final Snowflake guildId; + + @override + final bool isArchived; + + @override + final bool isLocked; + + @override + final bool isNsfw; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final int messageCount; + + @override + final String name; + + @override + final Snowflake ownerId; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + final Duration? rateLimitPerUser; + + @override + final int totalMessagesSent; + + @override + final ChannelFlags? flags; + + @override + ChannelType get type => ChannelType.privateThread; + + /// {@macro private_thread} + PrivateThread({ + required super.id, + required super.manager, + required this.isInvitable, + required this.appliedTags, + required this.approximateMemberCount, + required this.archiveTimestamp, + required this.autoArchiveDuration, + required this.createdAt, + required this.guildId, + required this.isArchived, + required this.isLocked, + required this.isNsfw, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.messageCount, + required this.name, + required this.ownerId, + required this.parentId, + required this.permissionOverwrites, + required this.position, + required this.rateLimitPerUser, + required this.totalMessagesSent, + required this.flags, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; + + @override + PartialUser get owner => manager.client.users[ownerId]; + + @override + PartialMember get ownerMember => guild.members[ownerId]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + @override + Future addThreadMember(Snowflake memberId) => manager.addThreadMember(id, memberId); + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future fetchThreadMember(Snowflake memberId) => manager.fetchThreadMember(id, memberId); + + @override + Future> listThreadMembers({bool? withMembers, Snowflake? after, int? limit}) => + manager.listThreadMembers(id, after: after, limit: limit, withMembers: withMembers); + + @override + Future removeThreadMember(Snowflake memberId) => manager.removeThreadMember(id, memberId); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => manager.client.webhooks.fetchChannelWebhooks(id); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} diff --git a/lib/src/models/channel/types/public_thread.dart b/lib/src/models/channel/types/public_thread.dart new file mode 100644 index 000000000..e2f897c0b --- /dev/null +++ b/lib/src/models/channel/types/public_thread.dart @@ -0,0 +1,151 @@ +import 'package:nyxx/src/builders/invite.dart'; +import 'package:nyxx/src/builders/permission_overwrite.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/models/webhook.dart'; + +/// {@template public_thread} +/// A public [Thread] channel. +/// {@endtemplate} +class PublicThread extends TextChannel implements Thread { + @override + final List? appliedTags; + + @override + final int approximateMemberCount; + + @override + final DateTime archiveTimestamp; + + @override + final Duration autoArchiveDuration; + + @override + final DateTime createdAt; + + @override + final Snowflake guildId; + + @override + final bool isArchived; + + @override + final bool isLocked; + + @override + final bool isNsfw; + + @override + final Snowflake? lastMessageId; + + @override + final DateTime? lastPinTimestamp; + + @override + final int messageCount; + + @override + final String name; + + @override + final Snowflake ownerId; + + @override + final Snowflake? parentId; + + @override + final List permissionOverwrites; + + @override + final int position; + + @override + final Duration? rateLimitPerUser; + + @override + final int totalMessagesSent; + + @override + final ChannelFlags? flags; + + @override + ChannelType get type => ChannelType.publicThread; + + /// {@macro public_thread} + PublicThread({ + required super.id, + required super.manager, + required this.appliedTags, + required this.approximateMemberCount, + required this.archiveTimestamp, + required this.autoArchiveDuration, + required this.createdAt, + required this.guildId, + required this.isArchived, + required this.isLocked, + required this.isNsfw, + required this.lastMessageId, + required this.lastPinTimestamp, + required this.messageCount, + required this.name, + required this.ownerId, + required this.parentId, + required this.permissionOverwrites, + required this.position, + required this.rateLimitPerUser, + required this.totalMessagesSent, + required this.flags, + }); + + @override + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + PartialMessage? get lastMessage => lastMessageId == null ? null : messages[lastMessageId!]; + + @override + PartialUser get owner => manager.client.users[ownerId]; + + @override + PartialMember get ownerMember => guild.members[ownerId]; + + @override + PartialChannel? get parent => parentId == null ? null : manager.client.channels[parentId!]; + + @override + Future addThreadMember(Snowflake memberId) => manager.addThreadMember(id, memberId); + + @override + Future deletePermissionOverwrite(Snowflake id) => manager.deletePermissionOverwrite(this.id, id); + + @override + Future fetchThreadMember(Snowflake memberId) => manager.fetchThreadMember(id, memberId); + + @override + Future> listThreadMembers({bool? withMembers, Snowflake? after, int? limit}) => + manager.listThreadMembers(id, after: after, limit: limit, withMembers: withMembers); + + @override + Future removeThreadMember(Snowflake memberId) => manager.removeThreadMember(id, memberId); + + @override + Future updatePermissionOverwrite(PermissionOverwriteBuilder builder) => manager.updatePermissionOverwrite(id, builder); + + @override + Future> fetchWebhooks() => manager.client.webhooks.fetchChannelWebhooks(id); + + @override + Future> listInvites() => manager.listInvites(id); + + @override + Future createInvite(InviteBuilder builder, {String? auditLogReason}) => manager.createInvite(id, builder, auditLogReason: auditLogReason); +} diff --git a/lib/src/models/channel/voice_channel.dart b/lib/src/models/channel/voice_channel.dart new file mode 100644 index 000000000..aed347644 --- /dev/null +++ b/lib/src/models/channel/voice_channel.dart @@ -0,0 +1,41 @@ +import 'package:nyxx/src/models/channel/channel.dart'; + +/// A voice channel. +abstract class VoiceChannel implements Channel { + /// The bitrate of the channel in bits/s. + int get bitrate; + + /// The maximum number of users that can join this channel at once. + int? get userLimit; + + /// The ID of the voice region for this channel, or automatic if `null`. + String? get rtcRegion; + + /// The [VideoQualityMode] for cameras in this channel. + VideoQualityMode get videoQualityMode; +} + +/// The quality mode of cameras in a [VoiceChannel]. +enum VideoQualityMode { + /// Automatic. + auto._(1), + + /// 720p. + full._(2); + + /// The value of this [VideoQualityMode]. + final int value; + + const VideoQualityMode._(this.value); + + /// Parse a [VideoQualityMode] from an [int]. + /// + /// [value] must be a valid [VideoQualityMode]. + factory VideoQualityMode.parse(int value) => VideoQualityMode.values.firstWhere( + (mode) => mode.value == value, + orElse: () => throw FormatException('Unknown VideoQualityMode', value), + ); + + @override + String toString() => 'VideoQualityMode($value)'; +} diff --git a/lib/src/models/commands/application_command.dart b/lib/src/models/commands/application_command.dart new file mode 100644 index 000000000..c9a915b9a --- /dev/null +++ b/lib/src/models/commands/application_command.dart @@ -0,0 +1,111 @@ +import 'package:nyxx/src/http/managers/application_command_manager.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/commands/application_command_option.dart'; +import 'package:nyxx/src/models/commands/application_command_permissions.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; + +/// A partial [ApplicationCommand]. +class PartialApplicationCommand extends WritableSnowflakeEntity { + @override + final ApplicationCommandManager manager; + + /// Create a new [PartialApplicationCommand]. + PartialApplicationCommand({required super.id, required this.manager}); + + /// Fetch the permissions for this command in a given guild. + Future fetchPermissions(Snowflake guildId) => manager.client.guilds[guildId].commands.fetchPermissions(id); +} + +/// {@template application_command} +/// A command that can be executed by users and is displayed in the Discord client UI. +/// +/// Also known as "Slash commands". +/// {@endtemplate} +class ApplicationCommand extends PartialApplicationCommand { + /// The type of this command. + final ApplicationCommandType type; + + /// The ID of the application this command belongs to. + final Snowflake applicationId; + + /// The ID of the guild this command belongs to. + final Snowflake? guildId; + + /// The name of this command. + final String name; + + /// A map of localizations for the name of this command. + final Map? nameLocalizations; + + /// The description of this command. + final String description; + + /// A map of localizations for the description of this command. + final Map? descriptionLocalizations; + + /// A list of options for this command if this command has a type of [ApplicationCommandType.chatInput]. + final List? options; + + /// The default permissions needed to execute this command. + final Permissions? defaultMemberPermissions; + + /// Whether this command can be ran in DMs. + final bool? hasDmPermission; + + /// Whether this command is NSFW. + final bool? isNsfw; + + /// An auto-incrementing version number. + final Snowflake version; + + /// {@macro application_command} + ApplicationCommand({ + required super.id, + required super.manager, + required this.type, + required this.applicationId, + required this.guildId, + required this.name, + required this.nameLocalizations, + required this.description, + required this.descriptionLocalizations, + required this.options, + required this.defaultMemberPermissions, + required this.hasDmPermission, + required this.isNsfw, + required this.version, + }); + + /// The application this command belongs to. + PartialApplication get application => manager.client.applications[applicationId]; + + /// The guild this command belongs to. + PartialGuild? get guild => guildId == null ? null : manager.client.guilds[guildId!]; +} + +/// The type of an [ApplicationCommand]. +enum ApplicationCommandType { + chatInput._(1), + user._(2), + message._(3); + + /// The value of this [ApplicationCommandType]. + final int value; + + const ApplicationCommandType._(this.value); + + /// Parse an [ApplicationCommandType] from an [int]. + /// + /// The [value] must be a valid application command type. + factory ApplicationCommandType.parse(int value) => ApplicationCommandType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown application command type', value), + ); + + @override + String toString() => 'ApplicationCommandType($value)'; +} diff --git a/lib/src/models/commands/application_command_option.dart b/lib/src/models/commands/application_command_option.dart new file mode 100644 index 000000000..fe243e24a --- /dev/null +++ b/lib/src/models/commands/application_command_option.dart @@ -0,0 +1,122 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template command_option} +/// An option in an [ApplicationCommand] with a type of [ApplicationCommandType.chatInput]. +/// {@endtemplate} +class CommandOption with ToStringHelper { + /// The type of this option. + final CommandOptionType type; + + /// The name of this option. + final String name; + + /// A localized map of names for this option. + final Map? nameLocalizations; + + /// The description of this option. + final String description; + + /// A localized map of descriptions for this option. + final Map? descriptionLocalizations; + + /// Whether this option is required. + final bool? isRequired; + + /// The choices available for this option. + final List? choices; + + /// If this option is a subcommand, the options of the subcommand. + final List? options; + + /// The types of channel that can be selected. + final List? channelTypes; + + /// The minimum value for this option. + final num? minValue; + + /// The maximum value for this option. + final num? maxValue; + + /// The minimum length for this option. + final int? minLength; + + /// The maximum length for this option. + final int? maxLength; + + /// Whether this option has autocompletion. + final bool? hasAutocomplete; + + /// {@macro command_option} + CommandOption({ + required this.type, + required this.name, + required this.nameLocalizations, + required this.description, + required this.descriptionLocalizations, + required this.isRequired, + required this.choices, + required this.options, + required this.channelTypes, + required this.minValue, + required this.maxValue, + required this.minLength, + required this.maxLength, + required this.hasAutocomplete, + }); +} + +/// The type of a [CommandOption]. +enum CommandOptionType { + subCommand._(1), + subCommandGroup._(2), + string._(3), + integer._(4), + boolean._(5), + user._(6), + channel._(7), + role._(8), + mentionable._(9), + number._(10), + attachment._(11); + + /// The value of this [CommandOptionType]. + final int value; + + const CommandOptionType._(this.value); + + /// Parse a [CommandOptionType] from an [int]. + /// + /// The [value] must be a valid command option type. + factory CommandOptionType.parse(int value) => CommandOptionType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown command option type', value), + ); + + @override + String toString() => 'CommandOptionType($value)'; +} + +/// {@template command_option_choice} +/// A choice for a [CommandOption]. +/// {@endtemplate} +class CommandOptionChoice { + /// The name of this choice. + final String name; + + /// A localized map of names for this choice. + final Map? nameLocalizations; + + /// The value of this choice. + final dynamic value; + + /// {@macro command_option_choice} + CommandOptionChoice({required this.name, required this.nameLocalizations, required this.value}); +} + +/// A common superclass for entities that can be passed in options of type [CommandOptionType.mentionable]. +/// +/// The only subtypes are [User] and [Role]. +abstract interface class CommandOptionMentionable> implements SnowflakeEntity {} diff --git a/lib/src/models/commands/application_command_permissions.dart b/lib/src/models/commands/application_command_permissions.dart new file mode 100644 index 000000000..e507c88a7 --- /dev/null +++ b/lib/src/models/commands/application_command_permissions.dart @@ -0,0 +1,95 @@ +import 'package:nyxx/src/http/managers/application_command_manager.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/commands/application_command.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template command_permissions} +/// The permissions for an [ApplicationCommand] in a guild. +/// {@endtemplate} +class CommandPermissions extends SnowflakeEntity { + /// The manager for this [CommandPermissions]. + final GuildApplicationCommandManager manager; + + /// The ID of the application these permissions apply to. + final Snowflake applicationId; + + /// The ID of the guild these permissions apply in. + final Snowflake guildId; + + /// A list of specific permissions. + final List permissions; + + /// {@macro command_permissions} + CommandPermissions({ + required this.manager, + required super.id, + required this.applicationId, + required this.guildId, + required this.permissions, + }); + + /// The command these permissions apply to, or `null` if these permissions apply to the entire application. + PartialApplicationCommand? get command => id == applicationId ? null : manager.client.guilds[guildId].commands[id]; + + /// The application these permissions apply to. + PartialApplication get application => manager.client.applications[applicationId]; + + /// The guild these permissions apply in. + PartialGuild get guild => manager.client.guilds[guildId]; + + @override + Future fetch() async { + if (command != null) { + return await command!.fetchPermissions(guildId); + } + + final permissions = await guild.commands.listPermissions(); + return permissions.firstWhere((element) => element.id == id); + } + + @override + Future get() async => manager.permissionsCache[id] ?? await fetch(); +} + +/// {@template command_permission} +/// The permission for a role, user or channel to use an [ApplicationCommand]. +/// {@endtemplate} +class CommandPermission with ToStringHelper { + /// The ID of the target entity. + final Snowflake id; + + /// The type of entity. + final CommandPermissionType type; + + /// Whether the entity has the permission to use the command. + final bool hasPermission; + + /// {@macro command_permission} + CommandPermission({required this.id, required this.type, required this.hasPermission}); +} + +/// The type of a [CommandPermission]. +enum CommandPermissionType { + role._(1), + user._(2), + channel._(3); + + /// The value of this [CommandPermissionType]. + final int value; + + const CommandPermissionType._(this.value); + + /// Parse a [CommandPermissionType] from an [int]. + /// + /// The [value] must be a valid command permission type. + factory CommandPermissionType.parse(int value) => CommandPermissionType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown command permission type', value), + ); + + @override + String toString() => 'CommandPermissionType($value)'; +} diff --git a/lib/src/models/discord_color.dart b/lib/src/models/discord_color.dart new file mode 100644 index 000000000..3d588f69e --- /dev/null +++ b/lib/src/models/discord_color.dart @@ -0,0 +1,86 @@ +/// A 24-bit RGB color. +class DiscordColor { + /// The 24 bit encoding of this color. + final int value; + + /// The red channel of this color. + /// + /// Will be between 0 and 255 inclusive. + int get r => (value >> 16) & 0xff; + + /// The green channel of this color. + /// + /// Will be between 0 and 255 inclusive. + int get g => (value >> 8) & 0xff; + + /// The blue channel of this color. + /// + /// Will be between 0 and 255 inclusive. + int get b => value & 0xff; + + /// Create a [DiscordColor] from a 24 bit encoded [value]. + const DiscordColor(this.value) : assert(value >= 0 && value < 0xffffff, 'value must be between 0 and ${0xffffff}'); + + /// Create a [DiscordColor] from [r], [g] and [b] channels ranging from 0 to 255 combined. + /// + /// [r], [g] and [b] must be positive and less than 256. + const DiscordColor.fromRgb(int r, int g, int b) + : assert(r >= 0 && r < 256, 'r must be between 0 and 255'), + assert(g >= 0 && g < 256, 'g must be between 0 and 255'), + assert(b >= 0 && b < 256, 'b must be between 0 and 255'), + value = r << 16 | g << 8 | b; + + /// Create a [DiscordColor] from [r], [g] and [b] channels ranging from 0.0 to 1.0 combined. + /// + /// [r], [g] and [b] must be positive and less than or equal to `1.0`. + factory DiscordColor.fromScaledRgb(double r, double g, double b) { + assert(r >= 0 && r <= 1, 'r must be between 0 and 1'); + assert(g >= 0 && g <= 1, 'g must be between 0 and 1'); + assert(b >= 0 && b <= 1, 'b must be between 0 and 1'); + + return DiscordColor.fromRgb( + (r * 255).floor(), + (g * 255).floor(), + (b * 255).floor(), + ); + } + + /// Parse a string value to a [DiscordColor]. + /// + /// [color] must be a valid 6-digit hexadecimal integer, optionally prefixed by a `#` symbol. + factory DiscordColor.parseHexString(String color) { + if (color.isEmpty) { + throw FormatException('Source cannot be empty', color, 0); + } + + if (color.startsWith('#')) { + color = color.substring(1); + } + + if (color.length != 6) { + throw FormatException('Source must contain 6 hexadecimal digits', color, color.length); + } + + return DiscordColor(int.parse(color, radix: 16)); + } + + /// Create a new [DiscordColor] identical to this one with one or more channels replaced. + DiscordColor copyWith({int? r, int? g, int? b}) => DiscordColor.fromRgb( + r ?? this.r, + g ?? this.g, + b ?? this.b, + ); + + /// Convert this [DiscordColor] to a hexadecimal string that can be parsed with + /// [DiscordColor.parseHexString]. + String toHexString() => '#${value.toRadixString(16).padLeft(6, '0')}'.toUpperCase(); + + @override + String toString() => 'DiscordColor(${toHexString()})'; + + @override + bool operator ==(Object other) => identical(this, other) || (other is DiscordColor && other.value == value); + + @override + int get hashCode => value.hashCode; +} diff --git a/lib/src/models/emoji.dart b/lib/src/models/emoji.dart new file mode 100644 index 000000000..1e709b02f --- /dev/null +++ b/lib/src/models/emoji.dart @@ -0,0 +1,90 @@ +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/managers/emoji_manager.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +/// A partial [Emoji] object. +class PartialEmoji extends WritableSnowflakeEntity { + @override + final EmojiManager manager; + + /// Create a new [PartialEmoji]. + PartialEmoji({required super.id, required this.manager}); +} + +/// An emoji. Either a [TextEmoji] or a [GuildEmoji]. +abstract class Emoji extends PartialEmoji { + /// The emoji's name. Can be `dartlang` for a custom emoji, or `❤️` for a text emoji. + String? get name; + + Emoji({ + required super.id, + required super.manager, + }); +} + +/// A text emoji, such as `❤️`. +class TextEmoji extends Emoji { + @override + final String name; + + TextEmoji({ + required super.id, + required super.manager, + required this.name, + }); + + // Intercept fetch since the manager will throw if we attempt to fetch a text emoji + @override + Future fetch() async => this; +} + +/// A custom guild emoji. +class GuildEmoji extends Emoji { + @override + final String? name; + + /// Id of roles allowed to use this emoji. + final List? roleIds; + + /// The user that created this emoji. + final User? user; + + /// Whether this emoji must be wrapped in colons. + final bool? requiresColons; + + /// Whether this emoji is managed. + final bool? isManaged; + + /// Whether this emoji is animated. + final bool? isAnimated; + + /// Whether this emoji can be used, may be false due to loss of Server Boosts. + final bool? isAvailable; + + GuildEmoji({ + required super.id, + required super.manager, + required this.name, + required this.roleIds, + required this.user, + required this.requiresColons, + required this.isManaged, + required this.isAnimated, + required this.isAvailable, + }); + + /// The roles allowed to use this emoji. + List? get roles => roleIds?.map((e) => manager.client.guilds[manager.guildId].roles[e]).toList(); + + /// This emoji's image. + CdnAsset get image => CdnAsset( + client: manager.client, + base: HttpRoute()..emojis(), + hash: id.toString(), + isAnimated: isAnimated, + ); +} diff --git a/lib/src/models/gateway/event.dart b/lib/src/models/gateway/event.dart new file mode 100644 index 000000000..1f969c45e --- /dev/null +++ b/lib/src/models/gateway/event.dart @@ -0,0 +1,104 @@ +import 'package:nyxx/src/gateway/gateway.dart'; +import 'package:nyxx/src/models/gateway/opcode.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template gateway_event} +/// The base class for all events received from the Gateway. +/// {@endtemplate} +abstract class GatewayEvent with ToStringHelper { + /// The opcode of this event. + final Opcode opcode; + + /// {@macro gateway_event} + GatewayEvent({required this.opcode}); +} + +/// {@template raw_dispatch_event} +/// An unparsed dispatch event. +/// {@endtemplate} +class RawDispatchEvent extends GatewayEvent { + /// The sequence number for this event. + final int seq; + + /// The name of the event. + final String name; + + /// The payload associated with the event. + final Map payload; + + /// {@macro raw_dispatch_event} + RawDispatchEvent({required this.seq, required this.name, required this.payload}) : super(opcode: Opcode.dispatch); +} + +/// {@template dispatch_event} +/// The base class for all events sent by dispatch. +/// {@endtemplate} +abstract class DispatchEvent extends GatewayEvent { + /// The gateway that handled this event. + final Gateway gateway; + + /// {@macro dispatch_event} + DispatchEvent({required this.gateway}) : super(opcode: Opcode.dispatch); +} + +/// {@template unknown_dispatch_event} +/// Emitted when a [RawDispatchEvent] could not be parsed to a [DispatchEvent] due to the event being unknown. +/// +/// This either means the event is internal and not documented, or that nyxx has not yet updated to support it. +/// {@endtemplate} +class UnknownDispatchEvent extends DispatchEvent { + /// The [RawDispatchEvent] that couldn't be parsed. + final RawDispatchEvent raw; + + /// {@macro unknown_dispatch_event} + UnknownDispatchEvent({required super.gateway, required this.raw}); +} + +/// {@template heartbeat_event} +/// Emitted when the client receives a request to heartbeat. +/// {@endtemplate} +class HeartbeatEvent extends GatewayEvent { + /// {@macro heartbeat_event} + HeartbeatEvent() : super(opcode: Opcode.heartbeat); +} + +/// {@template reconnect_event} +/// Emitted when the client receives a request to reconnect. +/// {@endtemplate} +class ReconnectEvent extends GatewayEvent { + /// {@macro reconnect_events} + ReconnectEvent() : super(opcode: Opcode.reconnect); +} + +/// {@template invalid_session_event} +/// Emitted when the client's session is invalid. +/// {@endtemplate} +class InvalidSessionEvent extends GatewayEvent { + /// Whether the client can resume the session on a new connection. + final bool isResumable; + + /// {@macro invalid_session_event} + InvalidSessionEvent({required this.isResumable}) : super(opcode: Opcode.invalidSession); +} + +/// {@template hello_event} +/// Emitted when the client first establishes a connection to the gateway. +/// {@endtemplate} +class HelloEvent extends GatewayEvent { + /// The interval at which the client should heartbeat. + final Duration heartbeatInterval; + + /// {@macro hello_event} + HelloEvent({required this.heartbeatInterval}) : super(opcode: Opcode.hello); +} + +/// {@template heartbeat_ack_event} +/// Emitted when the server acknowledges the client's heartbeat. +/// {@endtemplate} +class HeartbeatAckEvent extends GatewayEvent { + /// The time taken for this event to be sent in response to the last [Opcode.heartbeat] message. + final Duration latency; + + /// {@macro heartbeat_ack_event} + HeartbeatAckEvent({required this.latency}) : super(opcode: Opcode.heartbeatAck); +} diff --git a/lib/src/models/gateway/events/application_command.dart b/lib/src/models/gateway/events/application_command.dart new file mode 100644 index 000000000..6422ed7d4 --- /dev/null +++ b/lib/src/models/gateway/events/application_command.dart @@ -0,0 +1,16 @@ +import 'package:nyxx/src/models/commands/application_command_permissions.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; + +/// {@template application_command_permissions_update_event} +/// Emitted when the permissions for an application command are updated. +/// {@endtemplate} +class ApplicationCommandPermissionsUpdateEvent extends DispatchEvent { + /// The permissions that were updated. + final CommandPermissions permissions; + + /// The permissions as they were cached before the update. + final CommandPermissions? oldPermissions; + + /// {@macro application_command_permissions_update_event} + ApplicationCommandPermissionsUpdateEvent({required super.gateway, required this.permissions, required this.oldPermissions}); +} diff --git a/lib/src/models/gateway/events/auto_moderation.dart b/lib/src/models/gateway/events/auto_moderation.dart new file mode 100644 index 000000000..d52965723 --- /dev/null +++ b/lib/src/models/gateway/events/auto_moderation.dart @@ -0,0 +1,117 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/auto_moderation.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +/// {@template auto_moderation_rule_create_event} +/// Emitted when an auto moderation rule is created. +/// {@endtemplate} +class AutoModerationRuleCreateEvent extends DispatchEvent { + /// The rule that was created. + final AutoModerationRule rule; + + /// {@macro auto_moderation_rule_create_event} + AutoModerationRuleCreateEvent({required super.gateway, required this.rule}); +} + +/// {@template auto_moderation_rule_update_event} +/// Emitted when an auto moderation rule is updated. +/// {@endtemplate} +class AutoModerationRuleUpdateEvent extends DispatchEvent { + /// The rule as it was cached before it was updated. + final AutoModerationRule? oldRule; + + /// The rule that was updated. + final AutoModerationRule rule; + + /// {@macro auto_moderation_rule_update_event} + AutoModerationRuleUpdateEvent({required super.gateway, required this.oldRule, required this.rule}); +} + +/// {@template auto_moderation_rule_delete_event} +/// Emitted when an auto moderation rule is deleted. +/// {@endtemplate} +class AutoModerationRuleDeleteEvent extends DispatchEvent { + /// The rule that was deleted. + final AutoModerationRule rule; + + /// {@macro auto_moderation_rule_delete_event} + AutoModerationRuleDeleteEvent({required super.gateway, required this.rule}); +} + +/// {@template auto_moderation_action_execution_event} +/// Emitted when an auto moderation action is taken. +/// {@endtemplate} +class AutoModerationActionExecutionEvent extends DispatchEvent { + /// The ID of the guild the event was triggered in. + final Snowflake guildId; + + /// The action that was taken. + final AutoModerationAction action; + + /// The ID of the rule that was triggered. + final Snowflake ruleId; + + /// The trigger type that triggered the action. + final TriggerType triggerType; + + /// The ID of the user that triggered the action. + final Snowflake userId; + + /// The ID of the channel in which the action was taken. + final Snowflake? channelId; + + /// The ID of the message the action was taken on. + final Snowflake? messageId; + + /// The ID of the message sent in the alert channel. + final Snowflake? alertSystemMessageId; + + /// The content of the message which triggered the action. + final String? content; + + /// The keyword which was matched. + final String? matchedKeyword; + + /// The content of the message which was matched. + final String? matchedContent; + + /// {@macro auto_moderation_action_execution_event} + AutoModerationActionExecutionEvent({ + required super.gateway, + required this.guildId, + required this.action, + required this.ruleId, + required this.triggerType, + required this.userId, + required this.channelId, + required this.messageId, + required this.alertSystemMessageId, + required this.content, + required this.matchedKeyword, + required this.matchedContent, + }); + + /// The guild the rule was triggered in. + PartialGuild get guild => gateway.client.guilds[guildId]; + + /// The rule that was triggered. + PartialAutoModerationRule get rule => guild.autoModerationRules[ruleId]; + + /// The user that triggered the rule. + PartialUser get user => gateway.client.users[userId]; + + /// The member that triggered the rule. + PartialMember get member => guild.members[userId]; + + /// The channel in which the rule was triggered. + PartialChannel? get channel => channelId == null ? null : gateway.client.channels[channelId!]; + + /// The message that triggered the rule. + PartialMessage? get message => messageId == null ? null : (channel as PartialTextChannel?)?.messages[messageId!]; +} diff --git a/lib/src/models/gateway/events/channel.dart b/lib/src/models/gateway/events/channel.dart new file mode 100644 index 000000000..9fbd15d1d --- /dev/null +++ b/lib/src/models/gateway/events/channel.dart @@ -0,0 +1,180 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// {@template channel_create_event} +/// Emitted when a channel is created. +/// {@endtemplate} +class ChannelCreateEvent extends DispatchEvent { + /// The created channel. + final Channel channel; + + /// {@macro channel_create_event} + ChannelCreateEvent({required super.gateway, required this.channel}); +} + +/// {@template channel_update_event} +/// Emitted when a channel is updated. +/// {@endtemplate} +class ChannelUpdateEvent extends DispatchEvent { + /// The channel as it was in the cache before it was updated. + final Channel? oldChannel; + + /// The updated channel. + final Channel channel; + + /// {@macro channel_update_event} + ChannelUpdateEvent({required super.gateway, required this.oldChannel, required this.channel}); +} + +/// {@template channel_delete_event} +/// Emitted when a channel is deleted. +/// {@endtemplate} +class ChannelDeleteEvent extends DispatchEvent { + /// The channel which was deleted. + final Channel channel; + + /// {@macro channel_delete_event} + ChannelDeleteEvent({required super.gateway, required this.channel}); +} + +/// {@template thread_create_event} +/// Emitted when a thread is created. +/// {@endtemplate} +class ThreadCreateEvent extends DispatchEvent { + /// The thread that was created. + final Thread thread; + + /// {@macro thread_create_event} + ThreadCreateEvent({required super.gateway, required this.thread}); +} + +/// {@template thread_update_event} +/// Emitted when a thread is updated. +/// {@endtemplate} +class ThreadUpdateEvent extends DispatchEvent { + /// The thread as it was cached before it was updated. + final Thread? oldThread; + + /// The updated thread. + final Thread thread; + + /// {@macro thread_update_event} + ThreadUpdateEvent({required super.gateway, required this.oldThread, required this.thread}); +} + +/// {@template thread_delete_event} +/// Emitted when a thread is deleted. +/// {@endtemplate} +class ThreadDeleteEvent extends DispatchEvent { + /// The thread which was deleted. + final PartialChannel thread; + + /// {@macro thread_delete_event} + ThreadDeleteEvent({required super.gateway, required this.thread}); +} + +/// {@template thread_list_sync_event} +/// Emitted when the client's thread list is synced. +/// {@endtemplate} +class ThreadListSyncEvent extends DispatchEvent { + /// The ID of the guild threads are syncing for. + final Snowflake guildId; + + /// The IDs of the channels the threads are syncing for, or `null` if the entire guild is syncing. + final List? channelIds; + + /// The synced threads. + final List threads; + + /// The members of the synced threads. + final List members; + + /// {@macro thread_list_sync_event} + ThreadListSyncEvent({ + required super.gateway, + required this.guildId, + required this.channelIds, + required this.threads, + required this.members, + }); + + /// The guild that the threads are syncing for. + PartialGuild get guild => gateway.client.guilds[guildId]; + + /// The channels the threads are syncing for, or `null` if the entire guild is syncing. + List? get channels => channelIds?.map((e) => gateway.client.channels[e]).toList(); +} + +/// {@template thread_member_update_event} +/// Emitted when the client's thread member is updated. +/// {@endtemplate} +class ThreadMemberUpdateEvent extends DispatchEvent { + /// The updated member. + final ThreadMember member; + + /// {@macro thread_member_update_event} + ThreadMemberUpdateEvent({required super.gateway, required this.member}); +} + +/// {@template thread_members_update_event} +/// Emitted when a members in a thread are updated. +/// {@endtemplate} +class ThreadMembersUpdateEvent extends DispatchEvent { + /// The ID of the thread the members were updated in. + final Snowflake id; + + /// The ID of the guild the thread is in. + final Snowflake guildId; + + /// The number of members in the thread. + final int memberCount; + + /// A list of members added to the thread. + final List? addedMembers; + + /// A list of the IDs of the removed members. + final List? removedMemberIds; + + /// {@macro thread_members_update_event} + ThreadMembersUpdateEvent({ + required super.gateway, + required this.id, + required this.guildId, + required this.memberCount, + required this.addedMembers, + required this.removedMemberIds, + }); + + /// The thread the members were updated in. + PartialChannel get thread => gateway.client.channels[id]; + + /// The guild the thread is in. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template channel_pins_update_event} +/// Emitted when the pinned messages in a channel are changed. +/// {@endtemplate} +class ChannelPinsUpdateEvent extends DispatchEvent { + /// The ID of the guild the channel is in. + final Snowflake? guildId; + + /// The ID of the channel the pins were updated in. + final Snowflake channelId; + + /// The time at which the last message was pinned. + final DateTime? lastPinTimestamp; + + /// {@macro channel_pins_update_event} + ChannelPinsUpdateEvent({required super.gateway, required this.guildId, required this.channelId, required this.lastPinTimestamp}); + + /// The guild the channel is in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; + + /// The channel the pins were updated in. + PartialTextChannel get channel => gateway.client.channels[channelId] as PartialTextChannel; +} diff --git a/lib/src/models/gateway/events/guild.dart b/lib/src/models/gateway/events/guild.dart new file mode 100644 index 000000000..ca59d12ef --- /dev/null +++ b/lib/src/models/gateway/events/guild.dart @@ -0,0 +1,450 @@ +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/stage_instance.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/gateway/events/presence.dart'; +import 'package:nyxx/src/models/guild/audit_log.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/guild/scheduled_event.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/sticker/guild_sticker.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/models/voice/voice_state.dart'; + +/// {@template unavailable_guild_create_event} +/// Emitted when the client is added to an unavailable guild, or when initially receiving guilds over the Gateway. +/// {@endtemplate} +class UnavailableGuildCreateEvent extends DispatchEvent { + /// The guild the client was added to. + final PartialGuild guild; + + /// {@macro unavailable_guild_create_event} + UnavailableGuildCreateEvent({required super.gateway, required this.guild}); +} + +/// {@template guild_create_event} +/// Emitted when a client is added to a guild or when initially receiving guilds over the Gateway. +/// {@endtemplate} +class GuildCreateEvent extends DispatchEvent implements UnavailableGuildCreateEvent { + @override + final Guild guild; + + /// The time at which the client joined the guild. + final DateTime joinedAt; + + /// Whether the guild is large. + final bool isLarge; + + /// The number of members in the guild. + final int memberCount; + + /// A list of the [VoiceState]s of members currently in voice channels. + final List voiceStates; + + /// A list of members in the guild. + final List members; + + /// A list of channels in the guild. + final List channels; + + /// A list of threads in the guild. + final List threads; + + /// A list of initial presence update events in the guild. + final List presences; + + /// A list of stage instances in the guild. + final List stageInstances; + + /// A list of scheduled events in the guild. + final List scheduledEvents; + + /// {@macro guild_create_event} + GuildCreateEvent({ + required super.gateway, + required this.guild, + required this.joinedAt, + required this.isLarge, + required this.memberCount, + required this.voiceStates, + required this.members, + required this.channels, + required this.threads, + required this.presences, + required this.stageInstances, + required this.scheduledEvents, + }); +} + +/// {@template guild_update_event} +/// Emitted when a guild is updated. +/// {@endtemplate} +class GuildUpdateEvent extends DispatchEvent { + /// The guild as it was cached before the update. + final Guild? oldGuild; + + /// The updated guild. + final Guild guild; + + /// {@macro guild_update_event} + GuildUpdateEvent({required super.gateway, required this.oldGuild, required this.guild}); +} + +/// {@template guild_delete_event} +/// Emitted when the client is removed from a guild. +/// {@endtemplate} +class GuildDeleteEvent extends DispatchEvent { + /// The guild the client was removed from. + final PartialGuild guild; + + /// Whether the client was removed because the guild is unavailable. + final bool isUnavailable; + + /// {@macro guild_delete_event} + GuildDeleteEvent({required super.gateway, required this.guild, required this.isUnavailable}); +} + +/// {@template guild_audit_log_create_event} +/// Emitted when an audit log entry is created in a guild. +/// {@endtemplate} +class GuildAuditLogCreateEvent extends DispatchEvent { + /// The entry that was created. + final AuditLogEntry entry; + + /// The ID of the guild in which the entry was created. + final Snowflake guildId; + + /// {@macro guild_audit_log_create_event} + GuildAuditLogCreateEvent({required super.gateway, required this.entry, required this.guildId}); + + /// The guild in which the entry was created. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_ban_add_event} +/// Emitted when a user is banned in a guild. +/// {@endtemplate} +class GuildBanAddEvent extends DispatchEvent { + /// The ID of the guild the user was banned in. + final Snowflake guildId; + + /// The banned user. + final User user; + + /// {@macro guild_ban_add_event} + GuildBanAddEvent({required super.gateway, required this.guildId, required this.user}); + + /// The guild in which the user was banned. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_ban_remove_event} +/// Emitted when a user is unbanned in a guild. +/// {@endtemplate} +class GuildBanRemoveEvent extends DispatchEvent { + /// The ID of the guild the user was unbanned from. + final Snowflake guildId; + + /// The unbanned user. + final User user; + + /// {@macro guild_ban_remove_event} + GuildBanRemoveEvent({required super.gateway, required this.guildId, required this.user}); + + /// The guild in which the user was unbanned. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_emojis_update_event} +/// Emitted when a guild's emojis are updated. +/// {@endtemplate} +class GuildEmojisUpdateEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The updated emojis. + final List emojis; + + /// {@macro guild_emojis_update_event} + GuildEmojisUpdateEvent({required super.gateway, required this.guildId, required this.emojis}); + + /// The guild in which emojis were updated. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_stickers_update_event} +/// Emitted when a guild's stickers are updated. +/// {@endtemplate} +class GuildStickersUpdateEvent extends DispatchEvent { + /// The ID ot the guild. + final Snowflake guildId; + + /// Array of updated stickers. + final List stickers; + + /// {@macro guild_stickers_update_event} + GuildStickersUpdateEvent({required super.gateway, required this.guildId, required this.stickers}); + + /// The guild in which the stickers were updated. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_integrations_update_event} +/// Emitted when a guild's integrations are updated. +/// {@endtemplate} +class GuildIntegrationsUpdateEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// {@macro guild_integrations_update_event} + GuildIntegrationsUpdateEvent({required super.gateway, required this.guildId}); + + /// The guild in which the integrations were updated. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_member_add_event} +/// Emitted when a member joins a guild. +/// {@endtemplate} +class GuildMemberAddEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The added member. + final Member member; + + /// {@macro guild_member_add_event} + GuildMemberAddEvent({required super.gateway, required this.guildId, required this.member}); + + /// The guild in which the member was added. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_member_remove_event} +/// Emitted when a member is removed from a guild. +/// {@endtemplate} +class GuildMemberRemoveEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The removed user. + final User user; + + /// {@macro guild_member_remove_event} + GuildMemberRemoveEvent({required super.gateway, required this.guildId, required this.user}); + + /// The guild in which the member was removed. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_member_update_event} +/// Emitted when a guild member is updated. +/// {@endtemplate} +class GuildMemberUpdateEvent extends DispatchEvent { + /// The member as it was cached before the update. + final Member? oldMember; + + /// The updated member. + final Member member; + + /// The ID of the guild. + final Snowflake guildId; + + /// {@macro guild_member_update_event} + GuildMemberUpdateEvent({required super.gateway, required this.oldMember, required this.member, required this.guildId}); + + /// The guild in which the member was updated. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_members_chunk_event} +/// Emitted as a response to [Gateway.listGuildMembers]. +/// {@endtemplate} +class GuildMembersChunkEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The members in this chunk. + final List members; + + /// The index of this chunk. + final int chunkIndex; + + /// The total number of chunks. + final int chunkCount; + + /// A list of IDs that were not found in the guild. + final List? notFound; + + /// A list of presences for the [members] in this chunk. + final List? presences; + + /// The custom nonce set when requesting the members. + final String? nonce; + + /// {@macro guild_members_chunk_event} + GuildMembersChunkEvent({ + required super.gateway, + required this.guildId, + required this.members, + required this.chunkIndex, + required this.chunkCount, + required this.notFound, + required this.presences, + required this.nonce, + }); + + /// The guild members are being sent from. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_role_create_event} +/// Emitted when a role is created in a guild. +/// {@endtemplate} +class GuildRoleCreateEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The created role. + final Role role; + + /// {@macro guild_role_create_event} + GuildRoleCreateEvent({required super.gateway, required this.guildId, required this.role}); + + /// The guild in which the role was created. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_role_update_event} +/// Emitted when a role is updated in a guild +/// {@endtemplate} +class GuildRoleUpdateEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The role as it was cached before the update. + final Role? oldRole; + + /// The updated role. + final Role role; + + /// {@macro guild_role_update_event} + GuildRoleUpdateEvent({required super.gateway, required this.guildId, required this.oldRole, required this.role}); + + /// The guild in which the role was updated. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_role_delete_event} +/// Emitted when a role is deleted in a guild. +/// {@endtemplate} +class GuildRoleDeleteEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The ID of the deleted role. + final Snowflake roleId; + + /// {@macro guild_role_delete_event} + GuildRoleDeleteEvent({required super.gateway, required this.roleId, required this.guildId}); + + /// The guild in which the role was deleted. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template guild_scheduled_event_create_event} +/// Emitted when a scheduled event is created. +/// {@endtemplate} +class GuildScheduledEventCreateEvent extends DispatchEvent { + /// The event that was created. + final ScheduledEvent event; + + /// {@macro guild_scheduled_event_create_event} + GuildScheduledEventCreateEvent({required super.gateway, required this.event}); +} + +/// {@template guild_scheduled_event_update_event} +/// Emitted when a scheduled event is updated. +/// {@endtemplate} +class GuildScheduledEventUpdateEvent extends DispatchEvent { + /// The event as it was in the cache before it was updated. + final ScheduledEvent? oldEvent; + + /// The updated event. + final ScheduledEvent event; + + /// {@macro guild_scheduled_event_update_event} + GuildScheduledEventUpdateEvent({required super.gateway, required this.oldEvent, required this.event}); +} + +/// {@template guild_scheduled_event_delete_event} +/// Emitted when a scheduled event is deleted. +/// {@endtemplate} +class GuildScheduledEventDeleteEvent extends DispatchEvent { + /// The event that was deleted. + final ScheduledEvent event; + + /// {@macro guild_scheduled_event_delete_event} + GuildScheduledEventDeleteEvent({required super.gateway, required this.event}); +} + +/// {@template guild_scheduled_event_user_add_event} +/// Emitted when a user is added to a scheduled event. +/// {@endtemplate} +class GuildScheduledEventUserAddEvent extends DispatchEvent { + /// The ID of the scheduled event. + final Snowflake scheduledEventId; + + /// The ID of the added user. + final Snowflake userId; + + /// The ID of the guild. + final Snowflake guildId; + + /// {@macro guild_scheduled_event_user_add_event} + GuildScheduledEventUserAddEvent({required super.gateway, required this.scheduledEventId, required this.userId, required this.guildId}); + + /// The guild that the scheduled event is in. + PartialGuild get guild => gateway.client.guilds[guildId]; + + /// The scheduled event. + PartialScheduledEvent get scheduledEvent => guild.scheduledEvents[scheduledEventId]; + + /// The user that was added. + PartialUser get user => gateway.client.users[userId]; + + /// The member that was added. + PartialMember get member => guild.members[userId]; +} + +/// {@template guild_scheduled_event_user_remove_event} +/// Emitted when a user is removed from a scheduled event. +/// {@endtemplate} +class GuildScheduledEventUserRemoveEvent extends DispatchEvent { + /// The ID of the scheduled event. + final Snowflake scheduledEventId; + + /// The ID of the user. + final Snowflake userId; + + /// The ID of the guild. + final Snowflake guildId; + + /// {@macro guild_scheduled_event_user_remove_event} + GuildScheduledEventUserRemoveEvent({required super.gateway, required this.scheduledEventId, required this.userId, required this.guildId}); + + /// The guild that the scheduled event is in. + PartialGuild get guild => gateway.client.guilds[guildId]; + + /// The scheduled event. + PartialScheduledEvent get scheduledEvent => guild.scheduledEvents[scheduledEventId]; + + /// The user that was removed. + PartialUser get user => gateway.client.users[userId]; + + /// The member that was removed. + PartialMember get member => guild.members[userId]; +} diff --git a/lib/src/models/gateway/events/integration.dart b/lib/src/models/gateway/events/integration.dart new file mode 100644 index 000000000..ed0bb1133 --- /dev/null +++ b/lib/src/models/gateway/events/integration.dart @@ -0,0 +1,65 @@ +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/integration.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// {@template integration_create_event} +/// Emitted when an integration is created. +/// {@endtemplate} +class IntegrationCreateEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The created integration. + final Integration integration; + + /// {@macro integration_create_event} + IntegrationCreateEvent({required super.gateway, required this.guildId, required this.integration}); + + /// The guild the integration was created in. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template integration_update_event} +/// Emitted when an integration is updated. +/// {@endtemplate} +class IntegrationUpdateEvent extends DispatchEvent { + /// The ID of the guild + final Snowflake guildId; + + /// The integration as it was cached before the update. + final Integration? oldIntegration; + + /// The updated integration. + final Integration integration; + + /// {@macro integration_update_event} + IntegrationUpdateEvent({required super.gateway, required this.guildId, required this.oldIntegration, required this.integration}); + + /// The guild the integration was updated in. + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template integration_delete_event} +/// Emitted when an integration is deleted. +/// {@endtemplate} +class IntegrationDeleteEvent extends DispatchEvent { + /// The ID of the deleted integration. + final Snowflake id; + + /// The ID of the guild. + final Snowflake guildId; + + /// The ID of the application associated with the integration. + final Snowflake? applicationId; + + /// {@macro integration_delete_event} + IntegrationDeleteEvent({required super.gateway, required this.id, required this.guildId, required this.applicationId}); + + /// The guild the integration was deleted from. + PartialGuild get guild => gateway.client.guilds[guildId]; + + /// The application associated with the integration. + PartialApplication? get application => applicationId == null ? null : gateway.client.applications[applicationId!]; +} diff --git a/lib/src/models/gateway/events/interaction.dart b/lib/src/models/gateway/events/interaction.dart new file mode 100644 index 000000000..cc73280a6 --- /dev/null +++ b/lib/src/models/gateway/events/interaction.dart @@ -0,0 +1,13 @@ +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/interaction.dart'; + +/// {@template interaction_create_event} +/// Emitted when an interaction is received by the client. +/// {@endtemplate} +class InteractionCreateEvent> extends DispatchEvent { + // The created interaction. + final T interaction; + + /// {@macro interaction_create_event} + InteractionCreateEvent({required super.gateway, required this.interaction}); +} diff --git a/lib/src/models/gateway/events/invite.dart b/lib/src/models/gateway/events/invite.dart new file mode 100644 index 000000000..e0577816f --- /dev/null +++ b/lib/src/models/gateway/events/invite.dart @@ -0,0 +1,39 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/invite/invite_metadata.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// {@template invite_create_event} +/// Emitted when an invite is created. +/// {@endtemplate} +class InviteCreateEvent extends DispatchEvent { + /// The invite that was created. + final InviteWithMetadata invite; + + /// {@macro invite_create_event} + InviteCreateEvent({required super.gateway, required this.invite}); +} + +/// {@template invite_delete_event} +/// Emitted when an invite is deleted. +/// {@endtemplate} +class InviteDeleteEvent extends DispatchEvent { + /// The ID of the channel the invite was for. + final Snowflake channelId; + + /// The ID of the guild the invite was for. + final Snowflake? guildId; + + /// The code of the invite. + final String code; + + /// {@macro invite_delete_event} + InviteDeleteEvent({required super.gateway, required this.channelId, required this.guildId, required this.code}); + + /// The channel the invite was for. + PartialChannel get channel => gateway.client.channels[channelId]; + + /// The guild the invite was for. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; +} diff --git a/lib/src/models/gateway/events/message.dart b/lib/src/models/gateway/events/message.dart new file mode 100644 index 000000000..1b8c13295 --- /dev/null +++ b/lib/src/models/gateway/events/message.dart @@ -0,0 +1,263 @@ +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +/// {@template message_create_event} +/// Emitted when a message is sent. +/// {@endtemplate} +class MessageCreateEvent extends DispatchEvent { + /// The ID of the guild the message was sent in. + final Snowflake? guildId; + + /// The member that sent the message. + final PartialMember? member; + + /// A list of users explicitly mentioned in the message. + final List mentions; + + /// The message that was sent. + final Message message; + + /// {@macro message_create_event} + MessageCreateEvent({required super.gateway, required this.guildId, required this.member, required this.mentions, required this.message}); + + /// The guild the message was sent in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; +} + +/// {@template message_update_event} +/// Emitted when a message is updated. +/// {@endtemplate} +class MessageUpdateEvent extends DispatchEvent { + /// The ID of the guild the message was in. + final Snowflake? guildId; + + /// The member that sent the message. + final PartialMember? member; + + /// A list of users explicitly mentioned in the message. + final List mentions; + + /// The updated message. + final PartialMessage message; + + /// The message as it was cached before the update. + final Message? oldMessage; + + /// {@macro message_update_event} + MessageUpdateEvent({ + required super.gateway, + required this.guildId, + required this.member, + required this.mentions, + required this.message, + required this.oldMessage, + }); + + /// The guild the message was updated in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; +} + +/// {@template message_delete_event} +/// Emitted when a message is deleted. +/// {@endtemplate} +class MessageDeleteEvent extends DispatchEvent { + /// The ID of the deleted message. + final Snowflake id; + + /// The ID of the channel the message was deleted in. + final Snowflake channelId; + + /// The ID of the guild the message was deleted in. + final Snowflake? guildId; + + /// {@macro message_delete_event} + MessageDeleteEvent({required super.gateway, required this.id, required this.channelId, required this.guildId}); + + /// The guild the message was deleted in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; + + /// The channel the message was deleted in. + PartialTextChannel get channel => gateway.client.channels[channelId] as PartialTextChannel; +} + +/// {@template message_bulk_delete_event} +/// Emitted when multiple messages are bulk deleted. +/// {@endtemplate} +class MessageBulkDeleteEvent extends DispatchEvent { + /// A list of the IDs of the deleted messages. + final List ids; + + /// The ID of the channel the messages were deleted in. + final Snowflake channelId; + + /// The ID of the guild the messages were deleted in. + final Snowflake? guildId; + + /// {@macro message_bulk_delete_event} + MessageBulkDeleteEvent({required super.gateway, required this.ids, required this.channelId, required this.guildId}); + + /// The guild the messages were deleted in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; + + /// The channel the messages were deleted in. + PartialTextChannel get channel => gateway.client.channels[channelId] as PartialTextChannel; +} + +/// {@template message_reaction_add_event} +/// Emitted when a reaction is added to a message. +/// {@endtemplate} +class MessageReactionAddEvent extends DispatchEvent { + /// The ID of the user that added the reaction. + final Snowflake userId; + + /// The ID of the channel the message is in. + final Snowflake channelId; + + /// The ID of the message the reaction was added to. + final Snowflake messageId; + + /// The ID of the guild the message is in. + final Snowflake? guildId; + + /// The member that added the reaction to the message. + final Member? member; + + /// The emoji that was added. + final Emoji emoji; + + /// {@macro message_reaction_add_event} + MessageReactionAddEvent({ + required super.gateway, + required this.userId, + required this.channelId, + required this.messageId, + required this.guildId, + required this.member, + required this.emoji, + }); + + /// The guild the message is in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; + + /// The channel the message is in. + PartialTextChannel get channel => gateway.client.channels[channelId] as PartialTextChannel; + + /// The user that added the reaction. + PartialUser get user => gateway.client.users[userId]; + + /// The message the reaction was added to. + PartialMessage get message => channel.messages[messageId]; +} + +/// {@template message_reaction_remove_event} +/// Emitted when a reaction is removed from a message. +/// {@endtemplate} +class MessageReactionRemoveEvent extends DispatchEvent { + /// The ID of the user that removed their reaction. + final Snowflake userId; + + /// The ID of the channel the message is in. + final Snowflake channelId; + + /// The ID of the message the reaction was removed from. + final Snowflake messageId; + + /// The ID of the guild the message is in. + final Snowflake? guildId; + + /// The emoji that was removed. + final Emoji emoji; + + /// {@macro message_reaction_remove_event} + MessageReactionRemoveEvent({ + required super.gateway, + required this.userId, + required this.channelId, + required this.messageId, + required this.guildId, + required this.emoji, + }); + + /// The guild the message is in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; + + /// The channel the message is in. + PartialTextChannel get channel => gateway.client.channels[channelId] as PartialTextChannel; + + /// The user that removed the reaction. + PartialUser get user => gateway.client.users[userId]; + + /// The message the reaction was removed from. + PartialMessage get message => channel.messages[messageId]; +} + +/// {@template message_reaction_remove_all_event} +/// Emitted when all reactions are removed from a message. +/// {@endtemplate} +class MessageReactionRemoveAllEvent extends DispatchEvent { + /// The ID of the channel the message is in. + final Snowflake channelId; + + /// The ID of the messages the reactions were removed from. + final Snowflake messageId; + + /// The ID of the guild the message is in. + final Snowflake? guildId; + + /// {@macro message_reaction_remove_all_event} + MessageReactionRemoveAllEvent({ + required super.gateway, + required this.channelId, + required this.messageId, + required this.guildId, + }); + + /// The guild the message is in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; + + /// The channel the message is in. + PartialTextChannel get channel => gateway.client.channels[channelId] as PartialTextChannel; + + /// The message the reactions were removed from. + PartialMessage get message => channel.messages[messageId]; +} + +/// {@template message_reaction_remove_emoji_event} +/// Emitted when all reactions of a specific emoji are removed from a message. +/// {@endtemplate} +class MessageReactionRemoveEmojiEvent extends DispatchEvent { + /// The ID of the channel the message is in. + final Snowflake channelId; + + /// The ID of the message the reactions were removed from. + final Snowflake messageId; + + /// The ID of the guild the message is in. + final Snowflake? guildId; + + final PartialEmoji emoji; + + /// {@macro message_reaction_remove_emoji_event} + MessageReactionRemoveEmojiEvent({ + required super.gateway, + required this.channelId, + required this.messageId, + required this.guildId, + required this.emoji, + }); + + /// The guild the message is in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; + + /// The channel the message is in. + PartialTextChannel get channel => gateway.client.channels[channelId] as PartialTextChannel; + + /// The message the reactions were removed from. + PartialMessage get message => channel.messages[messageId]; +} diff --git a/lib/src/models/gateway/events/presence.dart b/lib/src/models/gateway/events/presence.dart new file mode 100644 index 000000000..55dd2d9c7 --- /dev/null +++ b/lib/src/models/gateway/events/presence.dart @@ -0,0 +1,93 @@ +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/presence.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +/// {@template presence_update_event} +/// Emitted when a user updates their presence. +/// {@endtemplate} +class PresenceUpdateEvent extends DispatchEvent { + /// The user that updated their presence. + final PartialUser? user; + + /// The ID of the guild the presence was updated in. + final Snowflake? guildId; + + /// The user's current status. + final UserStatus? status; + + /// The user's current activities. + final List? activities; + + /// The user's current client status. + final ClientStatus? clientStatus; + + /// {@macro presence_update_event} + PresenceUpdateEvent({ + required super.gateway, + required this.user, + required this.guildId, + required this.status, + required this.activities, + required this.clientStatus, + }); + + /// The guild the presence was updated in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; +} + +/// {@template typing_start_event} +/// Emitted when a user starts typing in a channel. +/// {@endtemplate} +class TypingStartEvent extends DispatchEvent { + /// The ID of the channel. + final Snowflake channelId; + + /// The ID of the guild the channel is in. + final Snowflake? guildId; + + /// The ID of the user that started typing. + final Snowflake userId; + + /// The time at which the user started typing. + final DateTime timestamp; + + /// The member that started typing. + final Member? member; + + /// {@macro typing_start_event} + TypingStartEvent({ + required super.gateway, + required this.channelId, + required this.guildId, + required this.userId, + required this.timestamp, + required this.member, + }); + + /// The guild the user started typing in. + PartialGuild? get guild => guildId == null ? null : gateway.client.guilds[guildId!]; + + /// The channel the user started typing in. + PartialTextChannel get channel => gateway.client.channels[channelId] as PartialTextChannel; + + /// The user that started typing. + PartialUser get user => gateway.client.users[userId]; +} + +/// {@template user_update_event} +/// Emitted when a user is updated. +/// {@endtemplate} +class UserUpdateEvent extends DispatchEvent { + /// The user as it was cached before it was updated. + final User? oldUser; + + /// The updated user. + final User user; + + /// {@macro user_update_event} + UserUpdateEvent({required super.gateway, required this.oldUser, required this.user}); +} diff --git a/lib/src/models/gateway/events/ready.dart b/lib/src/models/gateway/events/ready.dart new file mode 100644 index 000000000..96a5dae91 --- /dev/null +++ b/lib/src/models/gateway/events/ready.dart @@ -0,0 +1,54 @@ +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +/// {@template ready_event} +/// Emitted when the client's Gateway session is established. +/// {@endtemplate} +class ReadyEvent extends DispatchEvent { + /// The version of the API being used. + final int version; + + /// The current client's user. + final User user; + + /// A list of guilds the user is in. + final List guilds; + + /// The ID of the Gateway session. + final String sessionId; + + /// The URL to use when resuming the Gateway session. + final Uri gatewayResumeUrl; + + /// The ID of the shard. + final int? shardId; + + /// The total number of shards. + final int? totalShards; + + /// The client's application. + final PartialApplication application; + + /// {@macro ready_event} + ReadyEvent({ + required super.gateway, + required this.version, + required this.user, + required this.guilds, + required this.sessionId, + required this.gatewayResumeUrl, + required this.shardId, + required this.totalShards, + required this.application, + }); +} + +/// {@template resumed_event} +/// Emitted when +/// {@endtemplate} +class ResumedEvent extends DispatchEvent { + /// {@macro resumed_event} + ResumedEvent({required super.gateway}); +} diff --git a/lib/src/models/gateway/events/stage_instance.dart b/lib/src/models/gateway/events/stage_instance.dart new file mode 100644 index 000000000..8dac59389 --- /dev/null +++ b/lib/src/models/gateway/events/stage_instance.dart @@ -0,0 +1,38 @@ +import 'package:nyxx/src/models/channel/stage_instance.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; + +/// {@template stage_instance_create_event} +/// Emitted when a stage instance is created. +/// {@endtemplate} +class StageInstanceCreateEvent extends DispatchEvent { + /// The updated stage instance. + final StageInstance instance; + + /// {@macro stage_instance_create_event} + StageInstanceCreateEvent({required super.gateway, required this.instance}); +} + +/// {@template stage_instance_update_event} +/// Emitted when a stage instance is updated. +/// {@endtemplate} +class StageInstanceUpdateEvent extends DispatchEvent { + /// The stage instance as it was cached before the update. + final StageInstance? oldInstance; + + /// The updated stage instance. + final StageInstance instance; + + /// {@macro stage_instance_update_event} + StageInstanceUpdateEvent({required super.gateway, required this.oldInstance, required this.instance}); +} + +/// {@template stage_instance_delete_event} +/// Emitted when a stage instance is deleted. +/// {@endtemplate} +class StageInstanceDeleteEvent extends DispatchEvent { + /// The stage instance that was deleted. + final StageInstance instance; + + /// {@macro stage_instance_delete_event} + StageInstanceDeleteEvent({required super.gateway, required this.instance}); +} diff --git a/lib/src/models/gateway/events/voice.dart b/lib/src/models/gateway/events/voice.dart new file mode 100644 index 000000000..455a3b378 --- /dev/null +++ b/lib/src/models/gateway/events/voice.dart @@ -0,0 +1,38 @@ +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/voice/voice_state.dart'; + +/// {@template voice_state_update_event} +/// Emitted when a user's voice state is updated. +/// {@endtemplate} +class VoiceStateUpdateEvent extends DispatchEvent { + /// The updated voice state. + final VoiceState state; + + /// The voice state as it was cached before the update. + final VoiceState? oldState; + + /// {@macro voice_state_update_event} + VoiceStateUpdateEvent({required super.gateway, required this.oldState, required this.state}); +} + +/// {@template voice_server_update_event} +/// Emitted when joining a voice channel to update the voice servers. +/// {@endtemplate} +class VoiceServerUpdateEvent extends DispatchEvent { + /// The voice token. + final String token; + + /// The ID of the guild. + final Snowflake guildId; + + /// The endpoint to connect to. + final String? endpoint; + + /// {@macro voice_server_update_event} + VoiceServerUpdateEvent({required super.gateway, required this.token, required this.guildId, required this.endpoint}); + + /// The guild. + PartialGuild get guild => gateway.client.guilds[guildId]; +} diff --git a/lib/src/models/gateway/events/webhook.dart b/lib/src/models/gateway/events/webhook.dart new file mode 100644 index 000000000..26f3943e3 --- /dev/null +++ b/lib/src/models/gateway/events/webhook.dart @@ -0,0 +1,24 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// {@template webhooks_update_event} +/// Emitted when the webhooks in a channel are updated. +/// {@endtemplate} +class WebhooksUpdateEvent extends DispatchEvent { + /// The ID of the guild. + final Snowflake guildId; + + /// The ID of the channel. + final Snowflake channelId; + + /// {@macro webhooks_update_event} + WebhooksUpdateEvent({required super.gateway, required this.guildId, required this.channelId}); + + /// The guild the webhook was updated in. + PartialGuild get guild => gateway.client.guilds[guildId]; + + /// The channel the webhook was updated in. + PartialChannel get channel => gateway.client.channels[channelId]; +} diff --git a/lib/src/models/gateway/gateway.dart b/lib/src/models/gateway/gateway.dart new file mode 100644 index 000000000..d6a61e488 --- /dev/null +++ b/lib/src/models/gateway/gateway.dart @@ -0,0 +1,55 @@ +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template gateway_configuration} +/// Information about how to connect to the Gateway. +/// {@endtemplate} +class GatewayConfiguration with ToStringHelper { + /// The URL to connect to. + final Uri url; + + /// {@macro gateway_configuration} + GatewayConfiguration({required this.url}); +} + +/// {@template gateway_bot} +/// Information about how to connect to the Gateway, with client-specific information. +/// {@endtemplate} +class GatewayBot extends GatewayConfiguration { + /// The recommended number of shards to use. + final int shards; + + /// Information about the client's session start limits. + final SessionStartLimit sessionStartLimit; + + /// {@macro gateway_bot} + GatewayBot({ + required super.url, + required this.shards, + required this.sessionStartLimit, + }); +} + +/// {@template session_start_limit} +/// Information about a client's session start limits. +/// {@endtemplate} +class SessionStartLimit with ToStringHelper { + /// The total number of sessions that can be opened. + final int total; + + /// The current number of sessions that have been opened. + final int remaining; + + /// The time after which [remaining] will reset. + final Duration resetAfter; + + /// The maximum number of concurrent shards identifying. + final int maxConcurrency; + + /// {@macro session_start_limit} + SessionStartLimit({ + required this.total, + required this.remaining, + required this.resetAfter, + required this.maxConcurrency, + }); +} diff --git a/lib/src/models/gateway/opcode.dart b/lib/src/models/gateway/opcode.dart new file mode 100644 index 000000000..755c21af5 --- /dev/null +++ b/lib/src/models/gateway/opcode.dart @@ -0,0 +1,40 @@ +/// Opcodes sent or received over the Gateway. +enum Opcode { + /// An event is dispatched to the client. + dispatch._(0), + + /// Sent when heartbeating or received when requesting a heartbeat. + heartbeat._(1), + + /// Sent when opening a Gateway session. + identify._(2), + + /// Sent when updating the client's presence. + presenceUpdate._(3), + + /// Sent when updating the client's voice state. + voiceStateUpdate._(4), + + /// Send when resuming a Gateway session. + resume._(6), + + /// Received when the client should reconnect. + reconnect._(7), + + /// Sent to request guild members. + requestGuildMembers._(8), + + /// Received when the client's session is invalid. + invalidSession._(9), + + /// Received when the connection to the Gateway is opened. + hello._(10), + + /// Received when the server receives the client's heartbeat. + heartbeatAck._(11); + + /// The value of this [Opcode]. + final int value; + + const Opcode._(this.value); +} diff --git a/lib/src/models/guild/audit_log.dart b/lib/src/models/guild/audit_log.dart new file mode 100644 index 000000000..806647c91 --- /dev/null +++ b/lib/src/models/guild/audit_log.dart @@ -0,0 +1,218 @@ +import 'package:nyxx/src/http/managers/audit_log_manager.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permission_overwrite.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// A partial [AuditLogEntry]. +class PartialAuditLogEntry extends ManagedSnowflakeEntity { + @override + final AuditLogManager manager; + + /// Create a new [PartialAuditLogEntry]. + PartialAuditLogEntry({required super.id, required this.manager}); +} + +/// {@template audit_log_entry} +/// An entry in a [Guild]'s audit log. +/// {@endtemplate} +class AuditLogEntry extends PartialAuditLogEntry { + /// The ID of the targeted entity. + final Snowflake? targetId; + + /// A list of changes made to the entity. + final List? changes; + + /// The ID of the user that triggered the action. + final Snowflake? userId; + + /// The type of action taken. + final AuditLogEvent actionType; + + /// Additional information associated with this entry. + final AuditLogEntryInfo? options; + + /// The reason for this action. + final String? reason; + + /// {@macro audit_log_entry} + AuditLogEntry({ + required super.id, + required super.manager, + required this.targetId, + required this.changes, + required this.userId, + required this.actionType, + required this.options, + required this.reason, + }); + + /// The user that triggered the action. + PartialUser? get user => userId == null ? null : manager.client.users[userId!]; +} + +/// {@template audit_log_change} +/// A change to an object's field in an [AuditLogEntry]. +/// {@endtemplate} +class AuditLogChange with ToStringHelper { + /// The old, unparsed value of the field. + final dynamic oldValue; + + /// The new, unparsed value of the field. + final dynamic newValue; + + /// The name of the affected field. + final String key; + + /// {@macro audit_log_change} + AuditLogChange({ + required this.oldValue, + required this.newValue, + required this.key, + }); +} + +/// The type of event an [AuditLogEntry] represents. +enum AuditLogEvent { + guildUpdate._(1), + channelCreate._(10), + channelUpdate._(11), + channelDelete._(12), + channelOverwriteCreate._(13), + channelOverwriteUpdate._(14), + channelOverwriteDelete._(15), + memberKick._(20), + memberPrune._(21), + memberBanAdd._(22), + memberBanRemove._(23), + memberUpdate._(24), + memberRoleUpdate._(25), + memberMove._(26), + memberDisconnect._(27), + botAdd._(28), + roleCreate._(30), + roleUpdate._(31), + roleDelete._(32), + inviteCreate._(40), + inviteUpdate._(41), + inviteDelete._(42), + webhookCreate._(50), + webhookUpdate._(51), + webhookDelete._(52), + emojiCreate._(60), + emojiUpdate._(61), + emojiDelete._(62), + messageDelete._(72), + messageBulkDelete._(73), + messagePin._(74), + messageUnpin._(75), + integrationCreate._(80), + integrationUpdate._(81), + integrationDelete._(82), + stageInstanceCreate._(83), + stageInstanceUpdate._(84), + stageInstanceDelete._(85), + stickerCreate._(90), + stickerUpdate._(91), + stickerDelete._(92), + guildScheduledEventCreate._(100), + guildScheduledEventUpdate._(101), + guildScheduledEventDelete._(102), + threadCreate._(110), + threadUpdate._(111), + threadDelete._(112), + applicationCommandPermissionUpdate._(121), + autoModerationRuleCreate._(140), + autoModerationRuleUpdate._(141), + autoModerationRuleDelete._(142), + autoModerationBlockMessage._(143), + autoModerationFlagToChannel._(144), + autoModerationUserCommunicationDisabled._(145); + + /// The value of this [AuditLogEvent]. + final int value; + + const AuditLogEvent._(this.value); + + /// Parse an [AuditLogEvent] from an [int]. + /// + /// The [value] must be a valid audit log event. + factory AuditLogEvent.parse(int value) => AuditLogEvent.values.firstWhere( + (event) => event.value == value, + orElse: () => throw FormatException('Unknown audit log event', value), + ); + + @override + String toString() => 'AuditLogEvent($value)'; +} + +/// {@template audit_log_entry_info} +/// Extra information associated with an [AuditLogEntry]. +/// {@endtemplate} +class AuditLogEntryInfo with ToStringHelper { + /// The manager for this [AuditLogEntryInfo]. + final AuditLogManager manager; + + /// The ID of the application whose permissions were targeted. + final Snowflake? applicationId; + + /// The name of the Auto Moderation rule that was triggered. + final String? autoModerationRuleName; + + /// The trigger type of the Auto Moderation rule that was triggered. + final String? autoModerationTriggerType; + + /// The ID of the channel in which entities were targeted. + final Snowflake? channelId; + + /// The number of targeted entities. + final String? count; + + /// The number of days after which inactive members were kicked. + final String? deleteMemberDays; + + /// The ID of the overwritten entity. + final Snowflake? id; + + /// The number of members removed by a prune. + final String? membersRemoved; + + /// The ID of the targeted message. + final Snowflake? messageId; + + /// The name of the role. + final String? roleName; + + // The type of overwrite that was targeted. + final PermissionOverwriteType? overwriteType; + + /// {@macro audit_log_entry_info} + AuditLogEntryInfo({ + required this.manager, + required this.applicationId, + required this.autoModerationRuleName, + required this.autoModerationTriggerType, + required this.channelId, + required this.count, + required this.deleteMemberDays, + required this.id, + required this.membersRemoved, + required this.messageId, + required this.roleName, + required this.overwriteType, + }); + + /// The application whose permissions were targeted. + PartialApplication? get application => applicationId == null ? null : manager.client.applications[applicationId!]; + + /// The channel in which entities were targeted. + PartialChannel? get channel => channelId == null ? null : manager.client.channels[channelId!]; + + /// The targeted message. + PartialMessage? get message => messageId == null ? null : (channel as PartialTextChannel?)?.messages[messageId!]; +} diff --git a/lib/src/models/guild/auto_moderation.dart b/lib/src/models/guild/auto_moderation.dart new file mode 100644 index 000000000..a887a452f --- /dev/null +++ b/lib/src/models/guild/auto_moderation.dart @@ -0,0 +1,249 @@ +import 'package:nyxx/src/http/managers/auto_moderation_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// A partial [AutoModerationRule]. +class PartialAutoModerationRule extends WritableSnowflakeEntity { + @override + final AutoModerationManager manager; + + /// Create a new [PartialAutoModerationRule]. + PartialAutoModerationRule({required super.id, required this.manager}); +} + +/// {@template auto_moderation_rule} +/// A rule use for auto-moderation in a [Guild]. +/// {@endtemplate} +class AutoModerationRule extends PartialAutoModerationRule { + /// The ID of the guild this rule is in. + final Snowflake guildId; + + /// The name of this rule. + final String name; + + /// The ID of the user that created this rule. + final Snowflake creatorId; + + /// The type of event on which this rule triggers. + final AutoModerationEventType eventType; + + /// The type of trigger for this rule. + final TriggerType triggerType; + + /// Any metadata associated with this rule. + final TriggerMetadata metadata; + + /// The actions taken when this rule is triggered. + final List actions; + + /// Whether this rule is enabled. + final bool isEnabled; + + /// The IDs of the roles exempt to this rule. + final List exemptRoleIds; + + /// The IDs of the channels exempt to this rule. + final List exemptChannelIds; + + /// {@macro auto_moderation_rule} + AutoModerationRule({ + required super.id, + required super.manager, + required this.guildId, + required this.name, + required this.creatorId, + required this.eventType, + required this.triggerType, + required this.metadata, + required this.actions, + required this.isEnabled, + required this.exemptRoleIds, + required this.exemptChannelIds, + }); + + PartialGuild get guild => manager.client.guilds[guildId]; + + PartialUser get creator => manager.client.users[creatorId]; + + PartialMember get creatorMember => guild.members[creatorId]; + + List get exemptRoles => exemptRoleIds.map((e) => guild.roles[e]).toList(); + + List get exemptChannels => exemptChannelIds.map((e) => manager.client.channels[e]).toList(); +} + +/// The type of event on which an [AutoModerationRule] triggers. +enum AutoModerationEventType { + messageSend._(1); + + /// The value of this [AutoModerationEventType]. + final int value; + + const AutoModerationEventType._(this.value); + + /// Parse an [AutoModerationEventType] from an [int]. + /// + /// The [value] must be a valid auto moderation event type. + factory AutoModerationEventType.parse(int value) => AutoModerationEventType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown auto moderation event type', value), + ); + + @override + String toString() => 'AutoModerationEventType($value)'; +} + +/// The type of a trigger for an [AutoModerationRule] +enum TriggerType { + keyword._(1), + spam._(2), + keywordPreset._(4), + mentionSpam._(5); + + /// The value of this [TriggerType]. + final int value; + + const TriggerType._(this.value); + + /// Parse an [TriggerType] from an [int]. + /// + /// The [value] must be a valid trigger type. + factory TriggerType.parse(int value) => TriggerType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown trigger type', value), + ); + + @override + String toString() => 'TriggerType($value)'; +} + +/// {@template trigger_metadata} +/// Additional metadata associated with the trigger for an [AutoModerationRule]. +/// {@endtemplate} +class TriggerMetadata with ToStringHelper { + /// A list of words that trigger the rule. + final List? keywordFilter; + + /// A list of regex patterns that trigger the rule. + // TODO: Do we want to parse these as RegExp objects? + final List? regexPatterns; + + /// A list of preset keyword types that trigger the rule. + final List? presets; + + /// A list of words allowed to bypass the rule. + final List? allowList; + + /// The maximum number of mentions in a message. + final int? mentionTotalLimit; + + /// Whether mention raid protection is enabled. + final bool? isMentionRaidProtectionEnabled; + + /// {@macro trigger_metadata} + TriggerMetadata({ + required this.keywordFilter, + required this.regexPatterns, + required this.presets, + required this.allowList, + required this.mentionTotalLimit, + required this.isMentionRaidProtectionEnabled, + }); +} + +/// A preset list of trigger keywords for an [AutoModerationRule]. +enum KeywordPresetType { + profanity._(1), + sexualContent._(2), + slurs._(3); + + /// The value of this [KeywordPresetType]. + final int value; + + const KeywordPresetType._(this.value); + + /// Parse an [KeywordPresetType] from an [int]. + /// + /// The [value] must be a valid keyword preset type. + factory KeywordPresetType.parse(int value) => KeywordPresetType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown keyword preset type', value), + ); + + @override + String toString() => 'KeywordPresetType($value)'; +} + +/// {@template auto_moderation_action} +/// Describes an action to take when an [AutoModerationRule] is triggered. +/// {@endtemplate} +class AutoModerationAction with ToStringHelper { + /// The type of action to perform. + final ActionType type; + + /// Metadata needed to perform the action. + final ActionMetadata? metadata; + + /// {@macro auto_moderation_action} + AutoModerationAction({ + required this.type, + required this.metadata, + }); +} + +/// The type of action for an [AutoModerationAction]. +enum ActionType { + blockMessage._(1), + sendAlertMessage._(2), + timeout._(3); + + /// The value of this [ActionType]. + final int value; + + const ActionType._(this.value); + + /// Parse an [ActionType] from an [int]. + /// + /// The [value] must be a valid action type. + factory ActionType.parse(int value) => ActionType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown action type', value), + ); + + @override + String toString() => 'ActionType($value)'; +} + +/// {@template action_metadata} +/// Additional metadata associated with an [AutoModerationAction]. +/// {@endtemplate} +class ActionMetadata with ToStringHelper { + final AutoModerationManager manager; + + /// The ID of the channel to send the alert message to. + final Snowflake? channelId; + + /// The duration of time to time the user out for. + final Duration? duration; + + /// A custom message to send to the user. + final String? customMessage; + + /// {@macro action_metadata} + ActionMetadata({ + required this.manager, + required this.channelId, + required this.duration, + required this.customMessage, + }); + + /// The channel to send the alert message to. + PartialTextChannel? get channel => channelId == null ? null : manager.client.channels[channelId!] as PartialTextChannel?; +} diff --git a/lib/src/models/guild/ban.dart b/lib/src/models/guild/ban.dart new file mode 100644 index 000000000..0e035c7c3 --- /dev/null +++ b/lib/src/models/guild/ban.dart @@ -0,0 +1,16 @@ +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template ban} +/// A ban in a [Guild]. +/// {@endtemplate} +class Ban with ToStringHelper { + /// The reason for the ban. + final String? reason; + + /// The banned user. + final User user; + + /// {@macro ban} + Ban({required this.reason, required this.user}); +} diff --git a/lib/src/models/guild/guild.dart b/lib/src/models/guild/guild.dart new file mode 100644 index 000000000..c47908e73 --- /dev/null +++ b/lib/src/models/guild/guild.dart @@ -0,0 +1,788 @@ +import 'dart:typed_data'; + +import 'package:nyxx/src/builders/channel/channel_position.dart'; +import 'package:nyxx/src/builders/channel/guild_channel.dart'; +import 'package:nyxx/src/builders/guild/template.dart'; +import 'package:nyxx/src/builders/guild/welcome_screen.dart'; +import 'package:nyxx/src/builders/guild/widget.dart'; +import 'package:nyxx/src/builders/voice.dart'; +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/managers/application_command_manager.dart'; +import 'package:nyxx/src/http/managers/audit_log_manager.dart'; +import 'package:nyxx/src/http/managers/auto_moderation_manager.dart'; +import 'package:nyxx/src/http/managers/emoji_manager.dart'; +import 'package:nyxx/src/http/managers/guild_manager.dart'; +import 'package:nyxx/src/http/managers/integration_manager.dart'; +import 'package:nyxx/src/http/managers/member_manager.dart'; +import 'package:nyxx/src/http/managers/role_manager.dart'; +import 'package:nyxx/src/http/managers/scheduled_event_manager.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/http/managers/sticker_manager.dart'; +import 'package:nyxx/src/models/channel/guild_channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/channel/thread_list.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/guild/ban.dart'; +import 'package:nyxx/src/models/guild/guild_preview.dart'; +import 'package:nyxx/src/models/guild/guild_widget.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/guild/onboarding.dart'; +import 'package:nyxx/src/models/guild/template.dart'; +import 'package:nyxx/src/models/guild/welcome_screen.dart'; +import 'package:nyxx/src/models/invite/invite.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/sticker/guild_sticker.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/models/voice/voice_region.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +/// A partial [Guild]. +class PartialGuild extends WritableSnowflakeEntity { + @override + final GuildManager manager; + + /// A [MemberManager] for the members of this guild. + MemberManager get members => MemberManager(manager.client.options.memberCacheConfig, manager.client, guildId: id); + + /// A [RoleManager] for the roles of this guild. + RoleManager get roles => RoleManager(manager.client.options.roleCacheConfig, manager.client, guildId: id); + + /// A [ScheduledEventManager] for the scheduled events of this guild. + ScheduledEventManager get scheduledEvents => ScheduledEventManager(manager.client.options.scheduledEventCacheConfig, manager.client, guildId: id); + + /// An [AutoModerationManager] for the auto moderation rules of this guild. + AutoModerationManager get autoModerationRules => AutoModerationManager(manager.client.options.autoModerationRuleConfig, manager.client, guildId: id); + + /// An [IntegrationManager] for the integrations of this guild. + IntegrationManager get integrations => IntegrationManager(manager.client.options.integrationConfig, manager.client, guildId: id); + + /// An [EmojiManager] for the emojis of this guild. + EmojiManager get emojis => EmojiManager(manager.client.options.emojiCacheConfig, manager.client, guildId: id); + + /// An [GuildStickerManager] for the stickers of this guild. + GuildStickerManager get stickers => GuildStickerManager(manager.client.options.stickerCacheConfig, manager.client, guildId: id); + + /// An [AuditLogManager] for the audit log of this guild. + AuditLogManager get auditLogs => AuditLogManager(manager.client.options.auditLogEntryConfig, manager.client, guildId: id); + + /// A [GuildApplicationCommandManager] for the application commands of this guild. + GuildApplicationCommandManager get commands => GuildApplicationCommandManager( + manager.client.options.applicationCommandConfig, + manager.client, + applicationId: manager.client.application.id, + guildId: id, + permissionsConfig: manager.client.options.commandPermissionsConfig, + ); + + /// Create a new [PartialGuild]. + PartialGuild({required super.id, required this.manager}); + + @override + Future fetch({bool? withCounts}) => manager.fetch(id, withCounts: withCounts); + + /// Fetch this guild's preview. + Future fetchPreview() => manager.fetchGuildPreview(id); + + /// Fetch the channels in this guild. + Future> fetchChannels() => manager.fetchGuildChannels(id); + + /// Create a channel in this guild. + Future createChannel(GuildChannelBuilder builder, {String? auditLogReason}) => + manager.createGuildChannel(id, builder, auditLogReason: auditLogReason); + + /// Update the channel positions in this guild. + Future updateChannelPositions(List positions) => manager.updateChannelPositions(id, positions); + + /// List the active threads in this guild. + Future listActiveThreads() => manager.listActiveThreads(id); + + /// List the bans in this guild. + Future> listBans() => manager.listBans(id); + + /// Ban a member in this guild. + Future createBan(Snowflake userId, {Duration? deleteMessages, String? auditLogReason}) => + manager.createBan(id, userId, auditLogReason: auditLogReason, deleteMessages: deleteMessages); + + /// Unban a member in this guild. + Future deleteBan(Snowflake userId, {String? auditLogReason}) => manager.deleteBan(id, userId, auditLogReason: auditLogReason); + + /// Fetch the MFA level for this guild. + Future updateMfaLevel(MfaLevel level, {String? auditLogReason}) => manager.updateMfaLevel(id, level, auditLogReason: auditLogReason); + + /// Fetch the member prune count for the given [days] and [roleIds]. + Future fetchPruneCount({int? days, List? roleIds}) => manager.fetchPruneCount(id, days: days, roleIds: roleIds); + + /// Start a member prune with the given [days] and [roleIds]. + /// + /// Returns the pruned count if [computeCount] is `true` + Future startPrune({int? days, bool? computeCount, List? roleIds, String? auditLogReason}) => manager.startGuildPrune( + id, + auditLogReason: auditLogReason, + computeCount: computeCount, + days: days, + roleIds: roleIds, + ); + + /// List the voice regions available in the guild. + Future> listVoiceRegions() => manager.listVoiceRegions(id); + + /// Fetch this guild's widget settings. + Future fetchWidgetSettings() => manager.fetchWidgetSettings(id); + + /// Update this guild's widget settings. + Future updateWidgetSettings(WidgetSettingsUpdateBuilder builder, {String? auditLogReason}) => + manager.updateWidgetSettings(id, builder, auditLogReason: auditLogReason); + + /// Fetch this guild's widget information. + Future fetchWidget() => manager.fetchGuildWidget(id); + + /// Fetch this guild's widget image. + /// + /// The returned data is in PNG format. + Future fetchWidgetImage({WidgetImageStyle? style}) => manager.fetchGuildWidgetImage(id, style: style); + + /// Fetch this guild's welcome screen. + Future fetchWelcomeScreen() => manager.fetchWelcomeScreen(id); + + /// Update this guild's welcome screen. + Future updateWelcomeScreen(WelcomeScreenUpdateBuilder builder, {String? auditLogReason}) => + manager.updateWelcomeScreen(id, builder, auditLogReason: auditLogReason); + + /// Fetch the onboarding information for this guild. + Future fetchOnboarding() => manager.fetchOnboarding(id); + + /// Update the current user's voice state in this guild. + Future updateCurrentUserVoiceState(CurrentUserVoiceStateUpdateBuilder builder) => manager.updateCurrentUserVoiceState(id, builder); + + /// Update a member's voice state in this guild. + Future updateVoiceState(Snowflake userId, VoiceStateUpdateBuilder builder) => manager.updateVoiceState(id, userId, builder); + + /// List the templates in this guild. + Future> listTemplates() => manager.listGuildTemplates(id); + + /// Create a template in this guild. + Future createTemplate(GuildTemplateBuilder builder) => manager.createGuildTemplate(id, builder); + + /// Sync a template in this guild. + Future syncTemplate(String code) => manager.syncGuildTemplate(id, code); + + /// Update a template in this guild. + Future updateTemplate(String code, GuildTemplateUpdateBuilder builder) => manager.updateGuildTemplate(id, code, builder); + + /// Delete a template in this guild. + Future deleteTemplate(String code) => manager.deleteGuildTemplate(id, code); + + /// List the invites to this guild. + Future> listInvites() => manager.listInvites(id); + + /// Fetch the current user's member in this guild. + Future fetchCurrentMember() => manager.client.users.fetchCurrentUserMember(id); + + /// Leave this guild. + Future leave() => manager.client.users.leaveGuild(id); + + /// Fetch this guild's vanity invite code. + Future fetchVanityCode() => manager.fetchVanityCode(id); +} + +/// {@template guild} +/// A collection of channels & users. +/// +/// Guilds are often referred to as servers. +/// {@endtemplate} +class Guild extends PartialGuild { + /// This guild's name. + final String name; + + /// The hash of this guild's icon. + final String? iconHash; + + /// The hash of this guild's splash image. + final String? splashHash; + + /// The hash of this guild's discovery splash image. + final String? discoverySplashHash; + + /// Whether this guild is owned by the current user. + /// + /// {@template get_current_user_guilds_only} + /// This field is only present when fetching the current user's guilds. + /// {@endtemplate} + final bool? isOwnedByCurrentUser; + + /// The ID of this guild's owner. + final Snowflake ownerId; + + /// The current user's permissions. + /// + /// {@macro get_current_user_guilds_only} + final Permissions? currentUserPermissions; + + /// The ID of this guild's AFK channel. + final Snowflake? afkChannelId; + + /// The time after which members are moved into the AFK channel. + final Duration afkTimeout; + + /// Whether the widget is enabled in this guild. + final bool isWidgetEnabled; + + /// The channel ID the widget's invite will send users to. + final Snowflake? widgetChannelId; + + /// This guild's verification level. + final VerificationLevel verificationLevel; + + /// The default message notification level. + final MessageNotificationLevel defaultMessageNotificationLevel; + + /// The explicit content filter level for this guild. + final ExplicitContentFilterLevel explicitContentFilterLevel; + + /// A list of roles in this guild. + // Renamed to avoid conflict with the roles manager. + final List roleList; + + /// A list of emojis in this guild. + // Renamed to avoid conflict with the emojis manager. + final List emojiList; + + /// A set of features enabled in this guild. + final GuildFeatures features; + + /// This guild's MFA level. + final MfaLevel mfaLevel; + + /// The ID of the application that created this guild. + final Snowflake? applicationId; + + /// The ID of the channel system messages are sent to. + final Snowflake? systemChannelId; + + /// The configuration for this guild's system channel. + final SystemChannelFlags systemChannelFlags; + + /// The ID of the rules channel in a community guild. + final Snowflake? rulesChannelId; + + /// The maximum number of presences in this guild. + final int? maxPresences; + + /// The maximum number of members in this guild. + final int? maxMembers; + + /// This guild's vanity invite URL code. + final String? vanityUrlCode; + + /// This guild's description. + final String? description; + + /// The hash of this guild's banner. + final String? bannerHash; + + /// The current premium tier of this guild. + final PremiumTier premiumTier; + + /// The number of members who have boosted this guild. + final int? premiumSubscriptionCount; + + /// This guild's preferred locale. + final Locale preferredLocale; + + /// The ID of the public updates channel in a community guild. + final Snowflake? publicUpdatesChannelId; + + /// The maximum number of users in a video channel. + final int? maxVideoChannelUsers; + + /// The maximum number of users in a stage video channel. + final int? maxStageChannelUsers; + + /// An approximate number of members in this guild. + /// + /// {@template fetch_with_counts_only} + /// This is only returned when fetching this guild with `withCounts` set to `true`. + /// {@endtemplate} + final int? approximateMemberCount; + + /// An approximate number of presences in this guild. + /// + /// {@macro fetch_with_counts_only} + final int? approximatePresenceCount; + + /// This guild's welcome screen. + final WelcomeScreen? welcomeScreen; + + /// This guild's NSFW level. + final NsfwLevel nsfwLevel; + + /// Whether this guild has the premium progress bar enabled. + final bool hasPremiumProgressBarEnabled; + + /// A list of stickers in this guild. + // Renamed to avoid conflict with the stickers manager. + final List stickerList; + + /// {@macro guild} + Guild({ + required super.id, + required super.manager, + required this.name, + required this.iconHash, + required this.splashHash, + required this.discoverySplashHash, + required this.isOwnedByCurrentUser, + required this.ownerId, + required this.currentUserPermissions, + required this.afkChannelId, + required this.afkTimeout, + required this.isWidgetEnabled, + required this.widgetChannelId, + required this.verificationLevel, + required this.defaultMessageNotificationLevel, + required this.explicitContentFilterLevel, + required this.roleList, + required this.features, + required this.mfaLevel, + required this.applicationId, + required this.systemChannelId, + required this.systemChannelFlags, + required this.rulesChannelId, + required this.maxPresences, + required this.maxMembers, + required this.vanityUrlCode, + required this.description, + required this.bannerHash, + required this.premiumTier, + required this.premiumSubscriptionCount, + required this.preferredLocale, + required this.publicUpdatesChannelId, + required this.maxVideoChannelUsers, + required this.maxStageChannelUsers, + required this.approximateMemberCount, + required this.approximatePresenceCount, + required this.welcomeScreen, + required this.nsfwLevel, + required this.hasPremiumProgressBarEnabled, + required this.emojiList, + required this.stickerList, + }); + + /// The owner of the guild. + PartialUser get owner => manager.client.users[ownerId]; + + /// The member for the owner of the guild. + PartialMember get ownerMember => members[ownerId]; + + /// The AFK channel. + PartialChannel? get afkChannel => afkChannelId == null ? null : manager.client.channels[afkChannelId!]; + + /// The channel this guild's widget will send users to. + PartialChannel? get widgetChannel => widgetChannelId == null ? null : manager.client.channels[widgetChannelId!]; + + /// The application that created this guild. + PartialApplication? get application => applicationId == null ? null : manager.client.applications[applicationId!]; + + /// The channel system messages are sent to. + PartialTextChannel? get systemChannel => systemChannelId == null ? null : manager.client.channels[systemChannelId!] as PartialTextChannel?; + + /// The rules channel in a community server. + PartialTextChannel? get rulesChannel => rulesChannelId == null ? null : manager.client.channels[rulesChannelId!] as PartialTextChannel?; + + /// The public updates channel in a community server. + PartialTextChannel? get publicUpdatesChannel => + publicUpdatesChannelId == null ? null : manager.client.channels[publicUpdatesChannelId!] as PartialTextChannel?; + + /// This guild's icon. + CdnAsset? get icon => iconHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..icons(id: id.toString()), + hash: iconHash!, + ); + + /// This guild's splash image. + CdnAsset? get splash => splashHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..splashes(id: id.toString()), + hash: splashHash!, + ); + + /// This guild's discovery splash image. + CdnAsset? get discoverySplash => discoverySplashHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..discoverySplashes(id: id.toString()), + hash: discoverySplashHash!, + ); + + /// This guild's banner. + CdnAsset? get banner => bannerHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..banners(id: id.toString()), + hash: bannerHash!, + ); +} + +/// The verification level for a guild. +enum VerificationLevel { + none._(0), + low._(1), + medium._(2), + high._(3), + veryHigh._(4); + + /// The value of this verification level. + final int value; + + const VerificationLevel._(this.value); + + /// Parses a [VerificationLevel] from an [int]. + /// + /// The [value] must be a valid verification level. + factory VerificationLevel.parse(int value) => VerificationLevel.values.firstWhere( + (level) => level.value == value, + orElse: () => throw FormatException('Invalid verification level', value), + ); + + @override + String toString() => 'VerificationLevel($value)'; +} + +/// The level at which message notifications are sent in a guild. +enum MessageNotificationLevel { + allMessages._(0), + onlyMentions._(1); + + /// The value of this message notification level. + final int value; + + const MessageNotificationLevel._(this.value); + + /// Parses a [MessageNotificationLevel] from an [int]. + /// + /// The [value] must be a valid message notification level. + factory MessageNotificationLevel.parse(int value) => MessageNotificationLevel.values.firstWhere( + (level) => level.value == value, + orElse: () => throw FormatException('Invalid message notification level', value), + ); + + @override + String toString() => 'MessageNotificationLevel($value)'; +} + +/// The level of explicit content filtering in a guild. +enum ExplicitContentFilterLevel { + disabled._(0), + membersWithoutRoles._(1), + allMembers._(2); + + /// The value of this explicit content filter level. + final int value; + + const ExplicitContentFilterLevel._(this.value); + + /// Parses an [ExplicitContentFilterLevel] from an [int]. + /// + /// The [value] must be a valid explicit content filter level. + factory ExplicitContentFilterLevel.parse(int value) => ExplicitContentFilterLevel.values.firstWhere( + (level) => level.value == value, + orElse: () => throw FormatException('Invalid explicit content filter level', value), + ); + + @override + String toString() => 'ExplicitContentFilterLevel($value)'; +} + +/// Features that can be enabled in certain guilds. +// Artificial flags for guild features. The values are arbitrary, and are associated with the strings from the API in [GuildManager]. +class GuildFeatures extends Flags { + /// The guild has an animated banner. + static const animatedBanner = Flag.fromOffset(0); + + /// The guild has an animated icon. + static const animatedIcon = Flag.fromOffset(1); + + /// The guild has the Application Command Permissions V2. + static const applicationCommandPermissionsV2 = Flag.fromOffset(2); + + /// The guild has auto moderation. + static const autoModeration = Flag.fromOffset(3); + + /// The guild has a banner. + static const banner = Flag.fromOffset(4); + + /// The guild is a community guild. + static const community = Flag.fromOffset(5); + + /// The guild has enabled monetization. + static const creatorMonetizableProvisional = Flag.fromOffset(6); + + /// The guild has enabled the role subscription promo page. + static const creatorStorePage = Flag.fromOffset(7); + + /// The guild has been set as a support server on the App Directory. + static const developerSupportServer = Flag.fromOffset(8); + + /// The guild is able to be discovered in the directory. + static const discoverable = Flag.fromOffset(9); + + /// The guild is able to be featured in the directory. + static const featurable = Flag.fromOffset(10); + + /// The guild has paused invites, preventing new users from joining. + static const invitesDisabled = Flag.fromOffset(11); + + /// The guild has access to set an invite splash background. + static const inviteSplash = Flag.fromOffset(12); + + /// The guild has enabled Membership Screening. + static const memberVerificationGateEnabled = Flag.fromOffset(13); + + /// The guild has increased custom sticker slots. + static const moreStickers = Flag.fromOffset(14); + + /// The guild has access to create announcement channels. + static const news = Flag.fromOffset(15); + + /// The guild is partnered. + static const partnered = Flag.fromOffset(16); + + /// The guild can be previewed before joining via Membership Screening or the directory. + static const previewEnabled = Flag.fromOffset(17); + + /// The guild has disabled alerts for join raids in the configured safety alerts channel. + static const raidAlertsDisabled = Flag.fromOffset(18); + + /// The guild is able to set role icons. + static const roleIcons = Flag.fromOffset(19); + + /// The guild has role subscriptions that can be purchased. + static const roleSubscriptionsAvailableForPurchase = Flag.fromOffset(20); + + /// The guild has enabled role subscriptions. + static const roleSubscriptionsEnabled = Flag.fromOffset(21); + + /// The guild has enabled ticketed events. + static const ticketedEventsEnabled = Flag.fromOffset(22); + + /// The guild has access to set a vanity URL. + static const vanityUrl = Flag.fromOffset(23); + + /// The guild is verified. + static const verified = Flag.fromOffset(24); + + /// The guild has access to set 384kbps bitrate in voice (previously VIP voice servers). + static const vipRegions = Flag.fromOffset(25); + + /// The guild has enabled the welcome screen. + static const welcomeScreenEnabled = Flag.fromOffset(26); + + /// Create a new [GuildFeatures]. + const GuildFeatures(super.value); + + /// Whether this guild has the [animatedBanner] feature. + bool get hasAnimatedBanner => has(animatedBanner); + + /// Whether this guild has the [animatedIcon] feature. + bool get hasAnimatedIcon => has(animatedIcon); + + /// Whether this guild has the [applicationCommandPermissionsV2] feature. + bool get hasApplicationCommandPermissionsV2 => has(applicationCommandPermissionsV2); + + /// Whether this guild has the [autoModeration] feature. + bool get hasAutoModeration => has(autoModeration); + + /// Whether this guild has the [banner] feature. + bool get hasBanner => has(banner); + + /// Whether this guild has the [community] feature. + bool get hasCommunity => has(community); + + /// Whether this guild has the [creatorMonetizableProvisional] feature. + bool get isCreatorMonetizableProvisional => has(creatorMonetizableProvisional); + + /// Whether this guild has the [creatorStorePage] feature. + bool get hasCreatorStorePage => has(creatorStorePage); + + /// Whether this guild has the [developerSupportServer] feature. + bool get hasDeveloperSupportServer => has(developerSupportServer); + + /// Whether this guild has the [discoverable] feature. + bool get isDiscoverable => has(discoverable); + + /// Whether this guild has the [featurable] feature. + bool get isFeaturable => has(featurable); + + /// Whether this guild has the [invitesDisabled] feature. + bool get hasInvitesDisabled => has(invitesDisabled); + + /// Whether this guild has the [inviteSplash] feature. + bool get hasInviteSplash => has(inviteSplash); + + /// Whether this guild has the [memberVerificationGateEnabled] feature. + bool get hasMemberVerificationGateEnabled => has(memberVerificationGateEnabled); + + /// Whether this guild has the [moreStickers] feature. + bool get hasMoreStickers => has(moreStickers); + + /// Whether this guild has the [news] feature. + bool get hasNews => has(news); + + /// Whether this guild has the [partnered] feature. + bool get isPartnered => has(partnered); + + /// Whether this guild has the [previewEnabled] feature. + bool get hasPreviewEnabled => has(previewEnabled); + + /// Whether this guild has the [roleIcons] feature. + bool get hasRoleIcons => has(roleIcons); + + /// Whether this guild has the [roleSubscriptionsAvailableForPurchase] feature. + bool get hasRoleSubscriptionsAvailableForPurchase => has(roleSubscriptionsAvailableForPurchase); + + /// Whether this guild has the [roleSubscriptionsEnabled] feature. + bool get hasRoleSubscriptionsEnabled => has(roleSubscriptionsEnabled); + + /// Whether this guild has the [ticketedEventsEnabled] feature. + bool get hasTicketedEventsEnabled => has(ticketedEventsEnabled); + + /// Whether this guild has the [vanityUrl] feature. + bool get hasVanityUrl => has(vanityUrl); + + /// Whether this guild has the [verified] feature. + bool get isVerified => has(verified); + + /// Whether this guild has the [vipRegions] feature. + bool get hasVipRegions => has(vipRegions); + + /// Whether this guild has the [welcomeScreenEnabled] feature. + bool get hasWelcomeScreenEnabled => has(welcomeScreenEnabled); + + /// Whether this guild has the [raidAlertsDisabled] feature. + bool get hasRaidAlertsDisabled => has(raidAlertsDisabled); +} + +/// The MFA level required for moderators of a guild. +enum MfaLevel { + none._(0), + elevated._(1); + + /// The value of this MFA level. + final int value; + + const MfaLevel._(this.value); + + /// Parses an [MfaLevel] from an [int]. + /// + /// The [value] must be a valid mfa level. + factory MfaLevel.parse(int value) => MfaLevel.values.firstWhere( + (level) => level.value == value, + orElse: () => throw FormatException('Invalid mfa level', value), + ); + + @override + String toString() => 'MfaLevel($value)'; +} + +/// The configuration of a guild's system channel. +class SystemChannelFlags extends Flags { + /// Suppress member join notifications. + static const suppressJoinNotifications = Flag.fromOffset(0); + + /// Suppress server boost notifications. + static const suppressPremiumSubscriptions = Flag.fromOffset(1); + + /// Suppress server setup tips. + static const suppressGuildReminderNotifications = Flag.fromOffset(2); + + /// Hide member join sticker reply buttons. + static const suppressJoinNotificationReplies = Flag.fromOffset(3); + + /// Suppress role subscription purchase and renewal notifications. + static const suppressRoleSubscriptionPurchaseNotifications = Flag.fromOffset(4); + + /// Hide role subscription sticker reply buttons. + static const suppressRoleSubscriptionPurchaseNotificationReplies = Flag.fromOffset(5); + + /// Create a new [SystemChannelFlags]. + const SystemChannelFlags(super.value); + + /// Whether this configuration has the [suppressJoinNotifications] flag. + bool get shouldSuppressJoinNotifications => has(suppressJoinNotifications); + + /// Whether this configuration has the [suppressPremiumSubscriptions] flag. + bool get shouldSuppressPremiumSubscriptions => has(suppressPremiumSubscriptions); + + /// Whether this configuration has the [suppressGuildReminderNotifications] flag. + bool get shouldSuppressGuildReminderNotifications => has(suppressGuildReminderNotifications); + + /// Whether this configuration has the [suppressJoinNotificationReplies] flag. + bool get shouldSuppressJoinNotificationReplies => has(suppressJoinNotificationReplies); + + /// Whether this configuration has the [suppressRoleSubscriptionPurchaseNotifications] flag. + bool get shouldSuppressRoleSubscriptionPurchaseNotifications => has(suppressRoleSubscriptionPurchaseNotifications); + + /// Whether this configuration has the [suppressRoleSubscriptionPurchaseNotificationReplies] flag. + bool get shouldSuppressRoleSubscriptionPurchaseNotificationReplies => has(suppressRoleSubscriptionPurchaseNotificationReplies); +} + +/// The premium tier of a guild. +enum PremiumTier { + none._(0), + one._(1), + two._(2), + three._(3); + + /// The value of this tier. + final int value; + + const PremiumTier._(this.value); + + /// Parses a [PremiumTier] from an [int]. + /// + /// The [value] must be a valid premium tier. + factory PremiumTier.parse(int value) => PremiumTier.values.firstWhere( + (level) => level.value == value, + orElse: () => throw FormatException('Invalid premium tier', value), + ); + + @override + String toString() => 'PremiumTier($value)'; +} + +/// The NSFW level of a guild. +enum NsfwLevel { + unset._(0), + explicit._(1), + safe._(2), + ageRestricted._(3); + + /// The value of this NSFW level. + final int value; + + const NsfwLevel._(this.value); + + /// Parses an [NsfwLevel] from an [int]. + /// + /// The [value] must be a valid nsfw level. + factory NsfwLevel.parse(int value) => NsfwLevel.values.firstWhere( + (level) => level.value == value, + orElse: () => throw FormatException('Invalid nsfw level', value), + ); + + @override + String toString() => 'NsfwLevel($value)'; +} diff --git a/lib/src/models/guild/guild_preview.dart b/lib/src/models/guild/guild_preview.dart new file mode 100644 index 000000000..08af83ed9 --- /dev/null +++ b/lib/src/models/guild/guild_preview.dart @@ -0,0 +1,54 @@ +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/sticker/guild_sticker.dart'; + +/// {@template guild_preview} +/// A preview of a [Guild]. +/// {@endtemplate} +class GuildPreview extends PartialGuild { + /// The name of the guild. + final String name; + + /// The hash of the guild's icon. + final String? iconHash; + + /// The hash of the guild's splash image. + final String? splashHash; + + /// The hash of the guild's discovery splash image. + final String? discoverySplashHash; + + /// The emojis in the guild. + final List emojiList; + + /// The features enabled in the guild. + final GuildFeatures features; + + /// An approximate number of members in the guild. + final int approximateMemberCount; + + /// An approximate number of presences in the guild. + final int approximatePresenceCount; + + /// The guild's description. + final String? description; + + /// A list of stickers in this guild. + final List stickerList; + + /// {@macro guild_preview} + GuildPreview({ + required super.id, + required super.manager, + required this.name, + required this.iconHash, + required this.splashHash, + required this.discoverySplashHash, + required this.emojiList, + required this.features, + required this.approximateMemberCount, + required this.approximatePresenceCount, + required this.description, + required this.stickerList, + }); +} diff --git a/lib/src/models/guild/guild_widget.dart b/lib/src/models/guild/guild_widget.dart new file mode 100644 index 000000000..f183dd690 --- /dev/null +++ b/lib/src/models/guild/guild_widget.dart @@ -0,0 +1,87 @@ +import 'package:nyxx/src/http/managers/guild_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template guild_widget} +/// A [Guild]'s widget. +/// {@endtemplate} +class GuildWidget with ToStringHelper { + /// The manager for this [GuildWidget]. + final GuildManager manager; + + /// The ID of the guild this widget is for. + final Snowflake guildId; + + // The name of the guild. + final String name; + + /// An invite URL to the guild. + final String? invite; + + /// A list of channels in the guild. + final List channels; + + /// A list of users in the guild. + final List users; + + /// The number of presences in the guild. + final int presenceCount; + + /// {@macro guild_widget} + GuildWidget({ + required this.manager, + required this.guildId, + required this.name, + required this.invite, + required this.channels, + required this.users, + required this.presenceCount, + }); + + /// The guild this widget is for. + PartialGuild get guild => manager.client.guilds[guildId]; +} + +/// {@template widget_settings} +/// The settings for a [Guild]'s widget. +/// {@endtemplate} +class WidgetSettings with ToStringHelper { + /// The manager for this [WidgetSettings]. + final GuildManager manager; + + /// Whether the widget is enabled in this guild. + final bool isEnabled; + + /// The ID of the channel the widget should send users to. + final Snowflake? channelId; + + /// {@macro widget_settings} + WidgetSettings({ + required this.manager, + required this.isEnabled, + required this.channelId, + }); + + /// The channel the widget should send users to. + PartialChannel? get channel => channelId == null ? null : manager.client.channels[channelId!]; +} + +/// The style of a guild widget image. +enum WidgetImageStyle { + shield._('shield'), + banner1._('banner1'), + banner2._('banner2'), + banner3._('banner3'), + banner4._('banner4'); + + /// The value of this style. + final String value; + + const WidgetImageStyle._(this.value); + + @override + String toString() => 'WidgetImageStyle($value)'; +} diff --git a/lib/src/models/guild/integration.dart b/lib/src/models/guild/integration.dart new file mode 100644 index 000000000..ca514ba38 --- /dev/null +++ b/lib/src/models/guild/integration.dart @@ -0,0 +1,157 @@ +import 'package:nyxx/src/http/managers/integration_manager.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// A partial [Integration]. +class PartialIntegration extends ManagedSnowflakeEntity { + @override + final IntegrationManager manager; + + /// Create a new [PartialIntegration]. + PartialIntegration({required super.id, required this.manager}); + + /// Delete this integration. + Future delete({String? auditLogReason}) => manager.delete(id, auditLogReason: auditLogReason); +} + +/// {@template integration} +/// An integration in a [Guild]. +/// {@endtemplate} +class Integration extends PartialIntegration { + /// The name of this integration. + final String name; + + /// The type of this integration. + final String type; + + /// Whether this integration is enabled. + final bool isEnabled; + + /// Whether this integration is syncing. + final bool? isSyncing; + + /// The ID of the role this integration uses for subscribers. + final Snowflake? roleId; + + /// Whether emoticons should be synced for this integration. + final bool? enableEmoticons; + + /// The behavior of expiring subscribers for this integration. + final IntegrationExpireBehavior? expireBehavior; + + /// The grace period for expiring subscribers. + final Duration? expireGracePeriod; + + /// The user for this integration. + final User? user; + + /// Information about this integration's account. + final IntegrationAccount account; + + /// The time at which this integration last synced. + final DateTime? syncedAt; + + /// The number of subscribers to this integration. + final int? subscriberCount; + + /// Whether this integration is revoked. + final bool? isRevoked; + + /// The application for this integration. + final IntegrationApplication? application; + + /// The OAuth2 scopes this integration is authorized for. + final List? scopes; + + /// {@macro integration} + Integration({ + required super.id, + required super.manager, + required this.name, + required this.type, + required this.isEnabled, + required this.isSyncing, + required this.roleId, + required this.enableEmoticons, + required this.expireBehavior, + required this.expireGracePeriod, + required this.user, + required this.account, + required this.syncedAt, + required this.subscriberCount, + required this.isRevoked, + required this.application, + required this.scopes, + }); + + /// The role this integration uses for subscribers. + PartialRole? get role => roleId == null ? null : manager.client.guilds[manager.guildId].roles[roleId!]; +} + +/// The behavior of an integration when a member's subscription expires. +enum IntegrationExpireBehavior { + removeRole._(0), + kick._(1); + + /// TThe value of this [IntegrationExpireBehavior]. + final int value; + + const IntegrationExpireBehavior._(this.value); + + /// Parse an [IntegrationExpireBehavior] from an [int]. + /// + /// The [value] must be a valid integration expire behavior. + factory IntegrationExpireBehavior.parse(int value) => IntegrationExpireBehavior.values.firstWhere( + (behavior) => behavior.value == value, + orElse: () => throw FormatException('Unknown integration expire behavior', value), + ); + + @override + String toString() => 'IntegrationExpireBehavior($value)'; +} + +/// {@template integration_account} +/// Information about an integration's account. +/// {@endtemplate} +class IntegrationAccount with ToStringHelper { + /// The ID of this account. + final Snowflake id; + + /// The name of this account. + final String name; + + /// {@macro integration_account} + IntegrationAccount({required this.id, required this.name}); +} + +/// {@template integration_application} +/// Information about an integration's application. +/// {@endtemplate} +class IntegrationApplication with ToStringHelper { + /// The ID of this application. + final Snowflake id; + + /// The name of this application. + final String name; + + /// The hash of this application's icon. + final String? iconHash; + + /// The description of this application. + final String description; + + /// The bot associated with this application. + final User? bot; + + /// {@macro integration_application} + IntegrationApplication({ + required this.id, + required this.name, + required this.iconHash, + required this.description, + required this.bot, + }); +} diff --git a/lib/src/models/guild/member.dart b/lib/src/models/guild/member.dart new file mode 100644 index 000000000..25bf2e346 --- /dev/null +++ b/lib/src/models/guild/member.dart @@ -0,0 +1,134 @@ +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/managers/member_manager.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +/// A partial [Member]. +class PartialMember extends WritableSnowflakeEntity { + @override + final MemberManager manager; + + /// Create a new [PartialMember]. + PartialMember({required super.id, required this.manager}); + + /// Add a role to this member. + Future addRole(Snowflake roleId, {String? auditLogReason}) => manager.addRole(id, roleId, auditLogReason: auditLogReason); + + /// Remove a role from this member. + Future removeRole(Snowflake roleId, {String? auditLogReason}) => manager.removeRole(id, roleId); + + /// Ban this member. + Future ban({String? auditLogReason}) => manager.client.guilds[manager.guildId].createBan(id, auditLogReason: auditLogReason); + + /// Unban this member. + Future unban({String? auditLogReason}) => manager.client.guilds[manager.guildId].deleteBan(id, auditLogReason: auditLogReason); +} + +/// {@template member} +/// The representation of a [User] in a [Guild]. +/// {@endtemplate} +class Member extends PartialMember { + /// The [User] this member represents. + final User? user; + + /// This member's nickname. + final String? nick; + + /// The hash of this member's avatar image. + final String? avatarHash; + + /// A list of the IDs of the roles this member has. + final List roleIds; + + /// The time at which this member joined the guild. + final DateTime joinedAt; + + /// The time at which this member started boosting the guild. + final DateTime? premiumSince; + + /// Whether this member is deafened in voice channels. + final bool? isDeaf; + + /// Whether this member is muted in voice channels. + final bool? isMute; + + /// A set of flags associated with this member. + final MemberFlags flags; + + /// Whether this member has not yet passed the guild's membership screening requirements. + final bool isPending; + + /// In an interaction payload, the computed permissions of this member in the current channel. + final Permissions? permissions; + + /// The time until which this member is timed out. + final DateTime? communicationDisabledUntil; + + /// {@macro member} + Member({ + required super.id, + required super.manager, + required this.user, + required this.nick, + required this.avatarHash, + required this.roleIds, + required this.joinedAt, + required this.premiumSince, + required this.isDeaf, + required this.isMute, + required this.flags, + required this.isPending, + required this.permissions, + required this.communicationDisabledUntil, + }); + + /// The roles this member has. + List get roles => roleIds.map((e) => manager.client.guilds[manager.guildId].roles[e]).toList(); + + /// This member's avatar. + CdnAsset? get avatar => avatarHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute() + ..guilds(id: manager.guildId.toString()) + ..users(id: id.toString()) + ..avatars(), + hash: avatarHash!, + ); +} + +/// Flags that can be applied to a [Member]. +class MemberFlags extends Flags { + /// This member has left and rejoined the guild. + static const didRejoin = Flag.fromOffset(0); + + /// This member completed the guild's onboarding process. + static const completedOnboarding = Flag.fromOffset(1); + + /// This member is exempt from guild verification requirements. + static const bypassesVerification = Flag.fromOffset(2); + + /// This member has started the guild's onboarding process. + static const startedOnboarding = Flag.fromOffset(3); + + /// Whether this member has the [didRejoin] flag. + bool get hasRejoined => has(didRejoin); + + /// Whether this member has the [completedOnboarding] flag. + bool get didCompleteOnboarding => has(completedOnboarding); + + /// Whether this member has the [bypassesVerification] flag. + bool get hasBypassVerification => has(bypassesVerification); + + /// Whether this member has the [startedOnboarding] flag. + bool get didStartOnboarding => has(startedOnboarding); + + /// Create a new [MemberFlags]. + const MemberFlags(super.value); +} diff --git a/lib/src/models/guild/onboarding.dart b/lib/src/models/guild/onboarding.dart new file mode 100644 index 000000000..ec75336f3 --- /dev/null +++ b/lib/src/models/guild/onboarding.dart @@ -0,0 +1,142 @@ +import 'package:nyxx/src/http/managers/guild_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template onboarding} +/// The configuration for a [Guild]'s onboarding process. +/// {@endtemplate} +class Onboarding with ToStringHelper { + /// The manager for this [onboarding]. + final GuildManager manager; + + /// The ID of the guild this onboarding is for. + final Snowflake guildId; + + /// A list of prompts in this onboarding. + final List prompts; + + /// A list of channel IDs that get opted into automatically. + final List defaultChannelIds; + + /// Whether onboarding is enabled for this guild. + final bool isEnabled; + + /// {@macro onboarding} + Onboarding({ + required this.manager, + required this.guildId, + required this.prompts, + required this.defaultChannelIds, + required this.isEnabled, + }); + + /// The guild this onboarding is for. + PartialGuild get guild => manager.client.guilds[guildId]; + + /// A list of channels that get opted into automatically. + List get channels => defaultChannelIds.map((e) => manager.client.channels[e]).toList(); +} + +/// {@template onboarding_prompt} +/// A prompt in an [Onboarding] flow. +/// {@endtemplate} +class OnboardingPrompt with ToStringHelper { + /// The ID of this prompt. + final Snowflake id; + + /// The type of this prompt. + final OnboardingPromptType type; + + /// The options available for this prompt. + final List options; + + /// The title of this prompt. + final String title; + + /// Whether the user can select at most one option. + final bool isSingleSelect; + + /// Whether selecting an option is required. + final bool isRequired; + + /// If this prompt appears in the onboarding flow. + /// + /// If `false`, this prompt will only be visible in the Roles & Channels tab of the Discord client. + final bool isInOnboarding; + + /// {@macro onboarding_prompt} + OnboardingPrompt({ + required this.id, + required this.type, + required this.options, + required this.title, + required this.isSingleSelect, + required this.isRequired, + required this.isInOnboarding, + }); +} + +/// The type of an [Onboarding] prompt. +enum OnboardingPromptType { + multipleChoice._(0), + dropdown._(1); + + /// The value of this [OnboardingPromptType]. + final int value; + + const OnboardingPromptType._(this.value); + + /// Parse an [OnboardingPromptType] from an [int]. + /// + /// The [value] must be a valid onboarding prompt type. + factory OnboardingPromptType.parse(int value) => OnboardingPromptType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown onboarding prompt type', value), + ); + + @override + String toString() => 'OnboardingPromptType($value)'; +} + +/// {@template onboarding_prompt_option} +/// An option in an [OnboardingPrompt]. +/// {@endtemplate} +class OnboardingPromptOption with ToStringHelper { + /// The manager for this [OnboardingPromptOption]. + final GuildManager manager; + + /// The ID of this option. + final Snowflake id; + + /// The IDs of the channels the user is granted access to. + final List channelIds; + + /// The IDs of the roles the user is given. + final List roleIds; + + /// The emoji associated with this onboarding prompt. + final Emoji? emoji; + + /// The title of this option. + final String title; + + /// A description of this option. + final String? description; + + /// {@macro onboarding_prompt_option} + OnboardingPromptOption({ + required this.manager, + required this.id, + required this.channelIds, + required this.roleIds, + required this.emoji, + required this.title, + required this.description, + }); + + /// The channels the user is granted access to. + List get channels => channelIds.map((e) => manager.client.channels[e]).toList(); +} diff --git a/lib/src/models/guild/scheduled_event.dart b/lib/src/models/guild/scheduled_event.dart new file mode 100644 index 000000000..0fd73a2c3 --- /dev/null +++ b/lib/src/models/guild/scheduled_event.dart @@ -0,0 +1,200 @@ +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/managers/scheduled_event_manager.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/stage_instance.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// A partial [ScheduledEvent]. +class PartialScheduledEvent extends WritableSnowflakeEntity { + @override + final ScheduledEventManager manager; + + /// Create a new [PartialScheduledEvent]. + PartialScheduledEvent({required super.id, required this.manager}); + + /// List the users that have followed this event. + Future> listUsers({int? limit, bool? withMembers, Snowflake? before, Snowflake? after}) => + manager.listEventUsers(id, withMembers: withMembers, after: after, before: before, limit: limit); +} + +/// {@template scheduled_event} +/// A scheduled event in a [Guild]. +/// {@endtemplate} +class ScheduledEvent extends PartialScheduledEvent { + /// The ID of the guild this event is in. + final Snowflake guildId; + + /// The ID of the channel this event will be hosted in. + final Snowflake? channelId; + + /// The ID of the user that created the event, + final Snowflake? creatorId; + + /// The name of this event. + final String name; + + /// The description of this event. + final String? description; + + /// The time at which this event is scheduled to start. + final DateTime scheduledStartTime; + + /// The time at which this event is scheduled to end. + final DateTime? scheduledEndTime; + + /// The privacy level of this event. + /// + /// Can currently only be [PrivacyLevel.guildOnly]. + final PrivacyLevel privacyLevel; + + /// The status of this event. + final EventStatus status; + + /// The type of the entity associated with this event. + final ScheduledEntityType type; + + /// The ID of the entity associated with this event. + final Snowflake? entityId; + + /// Additional metadata about this event. + final EntityMetadata? metadata; + + /// The user that created this event. + final User? creator; + + /// The number of users interested in this event. + final int? userCount; + + /// The hash of this event's cover image. + final String? coverImageHash; + + /// {@macro scheduled_event} + ScheduledEvent({ + required super.id, + required super.manager, + required this.guildId, + required this.channelId, + required this.creatorId, + required this.name, + required this.description, + required this.scheduledStartTime, + required this.scheduledEndTime, + required this.privacyLevel, + required this.status, + required this.type, + required this.entityId, + required this.metadata, + required this.creator, + required this.userCount, + required this.coverImageHash, + }); + + /// The guild this event is in. + PartialGuild get guild => manager.client.guilds[guildId]; + + /// The channel this event will be hosted in. + PartialChannel? get channel => channelId == null ? null : manager.client.channels[channelId!]; + + /// The member for the user that created this event. + PartialMember? get creatorMember => creatorId == null ? null : guild.members[creatorId!]; + + /// This scheduled event's cover image. + CdnAsset? get coverImage => coverImageHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..guildEvents(id: id.toString()), + hash: coverImageHash!, + ); +} + +/// The status of a [ScheduledEvent]. +enum EventStatus { + scheduled._(1), + active._(2), + completed._(3), + cancelled._(4); + + /// TThe value of this [EventStatus]. + final int value; + + const EventStatus._(this.value); + + /// Parse an [EventStatus] from an [int]. + /// + /// The [value] must be a valid event status. + factory EventStatus.parse(int value) => EventStatus.values.firstWhere( + (status) => status.value == value, + orElse: () => throw FormatException('Unknown event status', value), + ); + + @override + String toString() => 'EventStatus($value)'; +} + +/// The type of the entity associated with a [ScheduledEvent]. +enum ScheduledEntityType { + stageInstance._(1), + voice._(2), + external._(3); + + /// The value of this [ScheduledEntityType]. + final int value; + + const ScheduledEntityType._(this.value); + + /// Parse a [ScheduledEntityType] from an [int]. + /// + /// The [value] must be a valid scheduled entity type. + factory ScheduledEntityType.parse(int value) => ScheduledEntityType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown scheduled entity type', value), + ); + + @override + String toString() => 'ScheduledEntityType($value)'; +} + +/// {@template entity_metadata} +/// Additional metadata associated with a [ScheduledEvent]. +/// {@endtemplate} +class EntityMetadata with ToStringHelper { + /// The location the event will take place in. + final String? location; + + /// {@macro entity_metadata} + EntityMetadata({required this.location}); +} + +/// {@template scheduled_event_user} +/// A user that has followed a [ScheduledEvent]. +/// {@endtemplate} +class ScheduledEventUser with ToStringHelper { + final ScheduledEventManager manager; + + /// The ID of the event the user followed. + final Snowflake scheduledEventId; + + /// The user that followed the event. + final User user; + + /// The member associated with the user. + final Member? member; + + /// {@macro scheduled_event_user} + ScheduledEventUser({ + required this.manager, + required this.scheduledEventId, + required this.user, + required this.member, + }); + + /// The event the user followed. + PartialScheduledEvent get scheduledEvent => manager[scheduledEventId]; +} diff --git a/lib/src/models/guild/template.dart b/lib/src/models/guild/template.dart new file mode 100644 index 000000000..07a71cedd --- /dev/null +++ b/lib/src/models/guild/template.dart @@ -0,0 +1,82 @@ +import 'package:nyxx/src/builders/guild/template.dart'; +import 'package:nyxx/src/builders/image.dart'; +import 'package:nyxx/src/http/managers/guild_manager.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template guild_template} +/// A snapshot of a [Guild] that can be used to create a new guild. +/// {@endtemplate} +class GuildTemplate with ToStringHelper { + /// The code of this template. + final String code; + + /// The manager for this template. + final GuildManager manager; + + /// The name of this template. + final String name; + + /// The description of this template. + final String? description; + + /// The number of times this template was used. + final int usageCount; + + /// The ID of the user that created this template. + final Snowflake creatorId; + + /// The user that created this template. + final User creator; + + /// The time at which this template was created. + final DateTime createdAt; + + /// The time at which this template was last updated. + final DateTime updatedAt; + + /// The ID of the guild this template was created from. + final Snowflake sourceGuildId; + + /// The snapshot of the guild that will be used for this template. + final Guild serializedSourceGuild; + + /// Whether this template has unsynced changes. + final bool? isDirty; + + /// {@macro guild_template} + GuildTemplate({ + required this.code, + required this.manager, + required this.name, + required this.description, + required this.usageCount, + required this.creatorId, + required this.creator, + required this.createdAt, + required this.updatedAt, + required this.sourceGuildId, + required this.serializedSourceGuild, + required this.isDirty, + }); + + /// The guild this template was created from. + PartialGuild get sourceGuild => manager.client.guilds[sourceGuildId]; + + /// Create a guild from this template. + Future use({required String name, ImageBuilder? icon}) => manager.createGuildFromTemplate(code, name: name, icon: icon); + + /// Fetch this template. + Future fetch() => manager.fetchGuildTemplate(code); + + /// Sync this template to the source guild. + Future sync() => manager.syncGuildTemplate(sourceGuildId, code); + + /// Update this template. + Future update(GuildTemplateUpdateBuilder builder) => manager.updateGuildTemplate(sourceGuildId, code, builder); + + /// Delete this template. + Future delete() => manager.deleteGuildTemplate(sourceGuildId, code); +} diff --git a/lib/src/models/guild/welcome_screen.dart b/lib/src/models/guild/welcome_screen.dart new file mode 100644 index 000000000..360e6c32f --- /dev/null +++ b/lib/src/models/guild/welcome_screen.dart @@ -0,0 +1,50 @@ +import 'package:nyxx/src/http/managers/guild_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template welcome_screen} +/// The configuration for the welcome screen in a guild. +/// {@endtemplate} +class WelcomeScreen with ToStringHelper { + /// The description shown in this welcome screen. + final String? description; + + /// A list of channels shown in this welcome screen. + final List channels; + + /// {@macro welcome_screen} + WelcomeScreen({required this.description, required this.channels}); +} + +/// {@template welcome_screen_channel} +/// A channel shown in a [WelcomeScreen]. +/// {@endtemplate} +class WelcomeScreenChannel with ToStringHelper { + /// The manager for this [WelcomeScreenChannel]. + final GuildManager manager; + + /// The ID of the channel this welcome screen channel represents. + final Snowflake channelId; + + /// A description for this channel. + final String description; + + /// The ID of the emoji associated with this channel. + final Snowflake? emojiId; + + /// The name of the emoji associated with this channel. + final String? emojiName; + + /// {@macro welcome_screen_channel} + WelcomeScreenChannel({ + required this.manager, + required this.channelId, + required this.description, + required this.emojiId, + required this.emojiName, + }); + + /// The channel this welcome screen channel represents. + PartialChannel get channel => manager.client.channels[channelId]; +} diff --git a/lib/src/models/interaction.dart b/lib/src/models/interaction.dart new file mode 100644 index 000000000..439837f16 --- /dev/null +++ b/lib/src/models/interaction.dart @@ -0,0 +1,518 @@ +import 'package:nyxx/src/builders/application_command.dart'; +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/interaction_response.dart'; +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/http/managers/interaction_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/commands/application_command.dart'; +import 'package:nyxx/src/models/commands/application_command_option.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/message/attachment.dart'; +import 'package:nyxx/src/models/message/component.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template interaction} +/// An interaction sent by Discord when a user interacts with an [ApplicationCommand], a [MessageComponent] +/// or a [ModalBuilder]. +/// {@endtemplate} +abstract class Interaction with ToStringHelper { + /// The manager for this interaction. + final InteractionManager manager; + + /// The ID of this interaction. + final Snowflake id; + + /// The ID of the application this interaction is for. + final Snowflake applicationId; + + /// The type of this interaction. + final InteractionType type; + + /// The data payload associated with this interaction. + final T data; + + /// The ID of the guild this interaction was triggered in. + final Snowflake? guildId; + + /// The channel this interaction was triggered in. + final PartialChannel? channel; + + /// The ID of the channel this interaction was triggered in. + final Snowflake? channelId; + + /// The member that triggered this interaction. + final Member? member; + + /// The user that triggered this interaction. + final User? user; + + /// The token to use when responding to this interaction. + final String token; + + /// The interaction version. + final int version; + + /// The message this interaction was triggered on. + final Message? message; + + /// The permissions of the application that triggered this interaction. + final Permissions? appPermissions; + + /// The preferred locale of the user that triggered this interaction. + final Locale? locale; + + /// The preferred locale of the guild in which this interaction was triggered. + final Locale? guildLocale; + + /// {@macro interaction} + Interaction({ + required this.manager, + required this.id, + required this.applicationId, + required this.type, + required this.data, + required this.guildId, + required this.channel, + required this.channelId, + required this.member, + required this.user, + required this.token, + required this.version, + required this.message, + required this.appPermissions, + required this.locale, + required this.guildLocale, + }); + + /// The guild in which this interaction was triggered. + PartialGuild? get guild => guildId == null ? null : manager.client.guilds[guildId!]; +} + +mixin MessageResponse on Interaction { + /// Whether this interaction has been acknowledged or responded to + bool _didAcknowledge = false; + bool _didRespond = false; + bool? _wasEphemeral; + + /// Acknowledge this interaction. + Future acknowledge({bool? isEphemeral}) async { + if (_didAcknowledge) { + throw AlreadyAcknowledgedError(this); + } + + _didAcknowledge = true; + _wasEphemeral = isEphemeral; + + await manager.createResponse(id, token, InteractionResponseBuilder.deferredChannelMessage(isEphemeral: isEphemeral)); + } + + /// Send a response to this interaction. + Future respond(MessageBuilder builder, {bool? isEphemeral}) async { + if (_didRespond) { + throw AlreadyRespondedError(this); + } + + if (!_didAcknowledge) { + _didAcknowledge = true; + _didRespond = true; + _wasEphemeral = isEphemeral; + + await manager.createResponse(id, token, InteractionResponseBuilder.channelMessage(builder, isEphemeral: isEphemeral)); + } else { + assert(isEphemeral == _wasEphemeral || isEphemeral == null, 'Cannot change the value of isEphemeral between acknowledge and respond'); + + await manager.createFollowup(token, builder); + } + } + + /// Fetch the original response to this interaction. + Future fetchOriginalResponse() => manager.fetchOriginalResponse(token); + + /// Update the original response to this interaction. + Future updateOriginalResponse(MessageUpdateBuilder builder) => manager.updateOriginalResponse(token, builder); + + /// Delete the original response to this interaction. + Future deleteOriginalResponse() => manager.deleteOriginalResponse(token); + + /// Create a followup to this interaction. + Future createFollowup(MessageBuilder builder, {bool? isEphemeral}) => manager.createFollowup(token, builder, isEphemeral: isEphemeral); + + /// Fetch a followup to this interaction. + Future fetchFollowup(Snowflake id) => manager.fetchFollowup(token, id); + + /// Update a followup to this interaction. + Future updateFollowup(Snowflake id, MessageUpdateBuilder builder) => manager.updateFollowup(token, id, builder); + + /// Delete a followup to this interaction. + Future deleteFollowup(Snowflake id) => manager.deleteFollowup(token, id); +} + +mixin ModalResponse on Interaction { + abstract bool _didRespond; + abstract bool _didAcknowledge; + + /// Send a modal response to this interaction. + Future respondModal(ModalBuilder builder) async { + assert(!_didAcknowledge, 'Cannot open a modal after a response or acknowledge has been sent'); + + _didAcknowledge = true; + _didRespond = true; + + await manager.createResponse(id, token, InteractionResponseBuilder.modal(builder)); + } +} + +/// {@template ping_interaction} +/// A ping interaction. +/// {@endtemplate} +class PingInteraction extends Interaction { + /// {@macro ping_interaction} + PingInteraction({ + required super.manager, + required super.id, + required super.applicationId, + required super.type, + required super.guildId, + required super.channel, + required super.channelId, + required super.member, + required super.user, + required super.token, + required super.version, + required super.message, + required super.appPermissions, + required super.locale, + required super.guildLocale, + }) : super(data: null); + + /// Send a pong response to this interaction. + Future respond() => manager.createResponse(id, token, InteractionResponseBuilder.pong()); +} + +/// {@template application_command_interaction} +/// An application command interaction. +/// {@endtemplate} +class ApplicationCommandInteraction extends Interaction + with MessageResponse, ModalResponse { + /// {@macro application_command_interaction} + ApplicationCommandInteraction({ + required super.manager, + required super.id, + required super.applicationId, + required super.type, + required super.data, + required super.guildId, + required super.channel, + required super.channelId, + required super.member, + required super.user, + required super.token, + required super.version, + required super.message, + required super.appPermissions, + required super.locale, + required super.guildLocale, + }); +} + +/// {@template message_component_interaction} +/// A message component interaction. +/// {@endtemplate} +class MessageComponentInteraction extends Interaction + with MessageResponse, ModalResponse { + /// {@macro message_component_interaction} + MessageComponentInteraction({ + required super.manager, + required super.id, + required super.applicationId, + required super.type, + required super.data, + required super.guildId, + required super.channel, + required super.channelId, + required super.member, + required super.user, + required super.token, + required super.version, + required super.message, + required super.appPermissions, + required super.locale, + required super.guildLocale, + }); + + bool? _didUpdateMessage; + + @override + Future acknowledge({bool? updateMessage, bool? isEphemeral}) async { + if (_didAcknowledge) { + throw AlreadyAcknowledgedError(this); + } + + assert(updateMessage != true || isEphemeral != true, 'Cannot set isEphemeral to true if updateMessage is set to true'); + + _didAcknowledge = true; + _didUpdateMessage = updateMessage; + _wasEphemeral = isEphemeral; + + if (updateMessage == true) { + await manager.createResponse(id, token, InteractionResponseBuilder.deferredUpdateMessage()); + } else { + await manager.createResponse(id, token, InteractionResponseBuilder.deferredChannelMessage(isEphemeral: isEphemeral)); + } + } + + @override + Future respond(Builder builder, {bool? updateMessage, bool? isEphemeral}) async { + assert(updateMessage == null || type == InteractionType.messageComponent, 'Cannot set updateMessage for non-component interactions'); + assert(updateMessage != true || isEphemeral != true, 'Cannot set isEphemeral to true if updateMessage is set to true'); + assert(builder is MessageUpdateBuilder == updateMessage, 'builder must be a MessageUpdateBuilder if updateMessage is true'); + assert(builder is MessageBuilder != updateMessage, 'builder must be a MessageBuilder if updateMessage is null or false'); + + if (!_didAcknowledge) { + assert(updateMessage != true || isEphemeral != true, 'Cannot set isEphemeral to true if updateMessage is set to true'); + + _didAcknowledge = true; + _didRespond = true; + _didUpdateMessage = updateMessage; + _wasEphemeral = isEphemeral; + + if (updateMessage == true) { + await manager.createResponse(id, token, InteractionResponseBuilder.updateMessage(builder as MessageUpdateBuilder)); + } else { + await manager.createResponse(id, token, InteractionResponseBuilder.channelMessage(builder as MessageBuilder)); + } + } else { + assert(updateMessage == _didUpdateMessage || updateMessage == null, 'Cannot change the value of updateMessage between acknowledge and respond'); + assert(isEphemeral == _wasEphemeral || isEphemeral == null, 'Cannot change the value of isEphemeral between acknowledge and respond'); + + if (_didRespond) { + throw AlreadyRespondedError(this); + } + + _didRespond = true; + + if (updateMessage == true) { + await manager.updateOriginalResponse(token, builder as MessageUpdateBuilder); + } else { + await manager.createFollowup(token, builder as MessageBuilder); + } + } + } +} + +/// {@template modal_submit_interaction} +/// A modal submit interaction. +/// {@endtemplate} +class ModalSubmitInteraction extends Interaction with MessageResponse { + /// {@macro modal_submit_interaction} + ModalSubmitInteraction({ + required super.manager, + required super.id, + required super.applicationId, + required super.type, + required super.data, + required super.guildId, + required super.channel, + required super.channelId, + required super.member, + required super.user, + required super.token, + required super.version, + required super.message, + required super.appPermissions, + required super.locale, + required super.guildLocale, + }); +} + +/// {@template application_command_autocomplete_interaction} +/// An application command autocomplete interaction. +/// {@endtemplate} +class ApplicationCommandAutocompleteInteraction extends Interaction { + /// {@macro application_command_autocomplete_interaction} + ApplicationCommandAutocompleteInteraction({ + required super.manager, + required super.id, + required super.applicationId, + required super.type, + required super.data, + required super.guildId, + required super.channel, + required super.channelId, + required super.member, + required super.user, + required super.token, + required super.version, + required super.message, + required super.appPermissions, + required super.locale, + required super.guildLocale, + }); + + /// Send a response to this interaction. + Future respond(List> builders) => + manager.createResponse(id, token, InteractionResponseBuilder.autocompleteResult(builders)); +} + +/// The type of an interaction. +enum InteractionType { + ping._(1), + applicationCommand._(2), + messageComponent._(3), + applicationCommandAutocomplete._(4), + modalSubmit._(5); + + /// The value of this [InteractionType]. + final int value; + + const InteractionType._(this.value); + + /// Parse an [InteractionType] from an [int]. + /// + /// The [value] must be a valid interaction type. + factory InteractionType.parse(int value) => InteractionType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown interaction type', value), + ); + + @override + String toString() => 'InteractionType($value)'; +} + +/// {@template application_command_interaction_data} +/// The data sent in an [ApplicationCommandInteraction] or an [ApplicationCommandAutocompleteInteraction]. +/// {@endtemplate} +class ApplicationCommandInteractionData with ToStringHelper { + /// The ID of the command. + final Snowflake id; + + /// The name of the command. + final String name; + + /// The type of the command. + final ApplicationCommandType type; + + /// Additional data about entities in the payload. + final ResolvedData? resolved; + + /// A list of provided options. + final List? options; + + /// The ID of the guild the command was invoked in. + final Snowflake? guildId; + + /// The ID of the entity the command was invoked on. + final Snowflake? targetId; + + /// {@macro application_command_interaction_data} + ApplicationCommandInteractionData({ + required this.id, + required this.name, + required this.type, + required this.resolved, + required this.options, + required this.guildId, + required this.targetId, + }); +} + +/// {@template resolved_data} +/// A mapping of IDs to entities. +/// {@endtemplate} +class ResolvedData with ToStringHelper { + /// A mapping of user ID to [User]. + final Map? users; + + /// A mapping of member ID to [Member]. + final Map? members; + + /// A mapping of role ID to [Role]. + final Map? roles; + + /// A mapping of channel ID to [PartialChannel]. + final Map? channels; + + /// A mapping of message ID to [PartialMessage]. + final Map? messages; + + /// A mapping of attachment ID to [Attachment]. + final Map? attachments; + + /// {@macro resolved_data} + ResolvedData({ + required this.users, + required this.members, + required this.roles, + required this.channels, + required this.messages, + required this.attachments, + }); +} + +/// {@template interaction_option} +/// The value of a command option passed in an [ApplicationCommandInteraction]. +/// {@endtemplate} +class InteractionOption with ToStringHelper { + /// The name of the option. + final String name; + + /// The type of the option. + final CommandOptionType type; + + /// The value of the option provided by the user. + final dynamic value; + + /// A list of sub-options if this option is a subcommand or subcommand group. + final List? options; + + /// Whether the user is focusing this option. + final bool? isFocused; + + /// {@macro interaction_option} + InteractionOption({ + required this.name, + required this.type, + required this.value, + required this.options, + required this.isFocused, + }); +} + +/// {@template message_component_interaction_data} +/// The data sent in a [MessageComponentInteraction]. +/// {@endtemplate} +class MessageComponentInteractionData with ToStringHelper { + /// The custom ID of the component that was used. + final String customId; + + /// The type of component that was used. + final MessageComponentType type; + + /// A list of values provided if the component was a [SelectMenuComponent]. + final List? values; + + /// {@macro message_component_interaction_data} + MessageComponentInteractionData({required this.customId, required this.type, required this.values}); +} + +/// {@template modal_submit_interaction_data} +/// The data sent in a [ModalSubmitInteraction]. +/// {@endtemplate} +class ModalSubmitInteractionData with ToStringHelper { + /// The custom ID of the modal. + final String customId; + + /// A list of components in the modal. + final List components; + + /// {@macro modal_submit_interaction_data} + ModalSubmitInteractionData({required this.customId, required this.components}); +} diff --git a/lib/src/models/invite/invite.dart b/lib/src/models/invite/invite.dart new file mode 100644 index 000000000..7de37fb9f --- /dev/null +++ b/lib/src/models/invite/invite.dart @@ -0,0 +1,93 @@ +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/scheduled_event.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template invite} +/// An invite to a [Guild] or [Channel]. +/// If the invite is to a [Channel], this will be a [GroupDmChannel]. +/// {@endtemplate} +class Invite with ToStringHelper { + /// The invite's code. This is a unique identifier. + final String code; + + /// The [Guild] this invite is for. + final PartialGuild? guild; + + /// The [PartialChannel] this invite is for. + final PartialChannel channel; + + /// The [User] who created this invite. + final User? inviter; + + /// The [TargetType] for this voice channel invite. + final TargetType? targetType; + + /// The [User] whose stream to display for this voice channel stream invite. + final User? targetUser; + + /// The [PartialApplication] to open for this voice channel embedded application invite. + final PartialApplication? targetApplication; + + /// The approximate count of members in the [Guild] this invite is for. + /// + /// {@template invite_approximate_member_count} + /// This is only available when [InviteManager.fetch] is called with `withCounts` set to `true`. + /// {@endtemplate} + final int? approximateMemberCount; + + /// The approximate count of online members in the [Guild] this invite is for. + /// + /// {@macro invite_approximate_member_count} + final int? approximatePresenceCount; + + /// The expiration date of this invite. + /// + /// This is only available when [InviteManager.fetch] is called with `withExpiration` set to `true`. + final DateTime? expiresAt; + + /// The [ScheduledEvent] data, only included if [InviteManager.fetch] is called with `guildScheduledEvent` is set to a valid [Snowflake]. + final ScheduledEvent? guildScheduledEvent; + + /// {@macro invite} + Invite({ + required this.code, + required this.guild, + required this.channel, + required this.inviter, + required this.targetType, + required this.targetUser, + required this.targetApplication, + required this.approximateMemberCount, + required this.approximatePresenceCount, + required this.expiresAt, + required this.guildScheduledEvent, + }); +} + +/// The type of an [Invite]'s target. +enum TargetType { + /// The invite is targeting a stream. + stream._(1), + + /// The invite is targeting an embedded application. + embeddedApplication._(2); + + /// The value of this [TargetType]. + final int value; + + /// Parse a [TargetType] from an [int]. + /// + /// The [value] must be a valid target type. + factory TargetType.parse(int value) => TargetType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown TargetType', value), + ); + + const TargetType._(this.value); + + @override + String toString() => 'TargetType($value)'; +} diff --git a/lib/src/models/invite/invite_metadata.dart b/lib/src/models/invite/invite_metadata.dart new file mode 100644 index 000000000..540eab1b3 --- /dev/null +++ b/lib/src/models/invite/invite_metadata.dart @@ -0,0 +1,37 @@ +import 'invite.dart'; + +class InviteWithMetadata extends Invite { + /// The number of times this invite has been used. + final int uses; + + /// The max number of times this invite can be used. + final int maxUses; + + /// The duration after which the invite expires. + final Duration maxAge; + + /// Whether this invite only grants temporary membership. + final bool isTemporary; + + /// When this invite was created. + final DateTime createdAt; + + InviteWithMetadata({ + required super.code, + required super.guild, + required super.channel, + required super.inviter, + required super.targetType, + required super.targetUser, + required super.targetApplication, + required super.approximateMemberCount, + required super.approximatePresenceCount, + required super.expiresAt, + required super.guildScheduledEvent, + required this.uses, + required this.maxUses, + required this.maxAge, + required this.isTemporary, + required this.createdAt, + }); +} diff --git a/lib/src/models/locale.dart b/lib/src/models/locale.dart new file mode 100644 index 000000000..32f4dd03a --- /dev/null +++ b/lib/src/models/locale.dart @@ -0,0 +1,60 @@ +/// A language locale available in the Discord client. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/reference#locales +enum Locale { + id._('id', 'Indonesian', 'Bahasa Indonesia'), + da._('da', 'Danish', 'Dansk'), + de._('de', 'German', 'Deutsch'), + enGb._('en-GB', 'English, UK', 'English, UK'), + enUs._('en-US', 'English, US', 'English, US'), + esEs._('es-ES', 'Spanish', 'Español'), + fr._('fr', 'French', 'Français'), + hr._('hr', 'Croatian', 'Hrvatski'), + it._('it', 'Italian', 'Italiano'), + lt._('lt', 'Lithuanian', 'Lietuviškai'), + hu._('hu', 'Hungarian', 'Magyar'), + nl._('nl', 'Dutch', 'Nederlands'), + no._('no', 'Norwegian', 'Norsk'), + pl._('pl', 'Polish', 'Polski'), + ptBr._('pt-BR', 'Portuguese, Brazilian', 'Português do Brasil'), + ro._('ro', 'Romanian, Romania', 'Română'), + fi._('fi', 'Finnish', 'Suomi'), + svSe._('sv-SE', 'Swedish', 'Svenska'), + vi._('vi', 'Vietnamese', 'Tiếng Việt'), + tr._('tr', 'Turkish', 'Türkçe'), + cs._('cs', 'Czech', 'Čeština'), + el._('el', 'Greek', 'Ελληνικά'), + bg._('bg', 'Bulgarian', 'български'), + ru._('ru', 'Russian', 'Pусский'), + uk._('uk', 'Ukrainian', 'Українська'), + hi._('hi', 'Hindi', 'हिन्दी'), + th._('th', 'Thai', 'ไทย'), + zhCn._('zh-CN', 'Chinese, China', '中文'), + ja._('ja', 'Japanese', '日本語'), + zhTw._('zh-TW', 'Chinese, Taiwan', '繁體中文'), + ko._('ko', 'Korean', '한국어'); + + /// The identifier for this locale. + final String identifier; + + /// The english name of this locale. + final String name; + + /// The native name of this locale. + final String nativeName; + + const Locale._(this.identifier, this.name, this.nativeName); + + /// Parse a string into a locale. + /// + /// [identifier] must be a string containing an identifier matching [Locale.identifier] for one of + /// the listed locales. + factory Locale.parse(String identifier) => Locale.values.firstWhere( + (locale) => locale.identifier == identifier, + orElse: () => throw FormatException('Unknown Locale', identifier), + ); + + @override + String toString() => 'Locale($identifier)'; +} diff --git a/lib/src/models/message/activity.dart b/lib/src/models/message/activity.dart new file mode 100644 index 000000000..44da6e08d --- /dev/null +++ b/lib/src/models/message/activity.dart @@ -0,0 +1,48 @@ +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template message_activity} +/// Activity data for rich presence related messages. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#message-object-message-activity-structure +/// {@endtemplate} +class MessageActivity with ToStringHelper { + /// The type of this activity. + final MessageActivityType type; + + /// The party ID of the Rich Presence event. + final String? partyId; + + /// {@macro message_activity} + MessageActivity({ + required this.type, + required this.partyId, + }); +} + +/// The type of a message activity. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#message-object-message-activity-types +enum MessageActivityType { + join._(1), + spectate._(2), + listen._(3), + joinRequest._(5); + + /// The value of this [MessageActivityType]. + final int value; + + const MessageActivityType._(this.value); + + /// Parse a [MessageActivityType] from an [int]. + /// + /// [value] must be a valid message activity type. + factory MessageActivityType.parse(int value) => MessageActivityType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown MessageActivityType', value), + ); + + @override + String toString() => 'MessageActivityType($value)'; +} diff --git a/lib/src/models/message/attachment.dart b/lib/src/models/message/attachment.dart new file mode 100644 index 000000000..dd998b63b --- /dev/null +++ b/lib/src/models/message/attachment.dart @@ -0,0 +1,54 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template attachment} +/// An attachment in a [Message]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#attachment-object +/// {@endtemplate} +class Attachment with ToStringHelper { + /// This attachment's ID. + final Snowflake id; + + /// The name of the attached file. + final String fileName; + + /// A description of the attached file. + final String? description; + + /// The content type of the attached file. + final String? contentType; + + /// The size of the attached file in bytes. + final int size; + + /// A URL from which the attached file can be downloaded. + final Uri url; + + /// A proxied URL from which the attached file can be downloaded. + final Uri proxiedUrl; + + /// If the file is an image, the height of the image in pixels. + final int? height; + + /// If the file is an image, the width of the image in pixels. + final int? width; + + /// Whether this attachment is ephemeral. + final bool isEphemeral; + + /// {@macro attachment} + Attachment({ + required this.id, + required this.fileName, + required this.description, + required this.contentType, + required this.size, + required this.url, + required this.proxiedUrl, + required this.height, + required this.width, + required this.isEphemeral, + }); +} diff --git a/lib/src/models/message/author.dart b/lib/src/models/message/author.dart new file mode 100644 index 000000000..63c4e76be --- /dev/null +++ b/lib/src/models/message/author.dart @@ -0,0 +1,19 @@ +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// An author of a message. +/// +/// Will normally be a [User] or a [WebhookAuthor]. +abstract class MessageAuthor { + /// The ID of this entity. + Snowflake get id; + + /// The username of this entity. + String get username; + + /// The avatar hash of this entity. + String? get avatarHash; + + /// The avatar of this entity. + CdnAsset? get avatar; +} diff --git a/lib/src/models/message/channel_mention.dart b/lib/src/models/message/channel_mention.dart new file mode 100644 index 000000000..32eb6ebc2 --- /dev/null +++ b/lib/src/models/message/channel_mention.dart @@ -0,0 +1,32 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; + +/// {@template channel_mention} +/// A channel mentioned in a [Message]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#channel-mention-object +/// {@endtemplate} +class ChannelMention extends PartialChannel { + /// The ID of the [Guild] containing the mentioned channel. + final Snowflake guildId; + + /// The type of channel mentioned. + final ChannelType type; + + /// The name of the mentioned channel. + final String name; + + /// {@macro channel_mention} + ChannelMention({ + required super.id, + required super.manager, + required this.guildId, + required this.type, + required this.name, + }); + + /// The guild containing the mentioned channel. + PartialGuild get guild => manager.client.guilds[guildId]; +} diff --git a/lib/src/models/message/component.dart b/lib/src/models/message/component.dart new file mode 100644 index 000000000..746ebe9a7 --- /dev/null +++ b/lib/src/models/message/component.dart @@ -0,0 +1,184 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +enum MessageComponentType { + actionRow._(1), + button._(2), + stringSelect._(3), + textInput._(4), + userSelect._(5), + roleSelect._(6), + mentionableSelect._(7), + channelSelect._(8); + + final int value; + + const MessageComponentType._(this.value); + + factory MessageComponentType.parse(int value) => MessageComponentType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown message component type', value), + ); + + @override + String toString() => 'MessageComponentType($value)'; +} + +abstract class MessageComponent with ToStringHelper { + MessageComponentType get type; +} + +class ActionRowComponent extends MessageComponent { + @override + MessageComponentType get type => MessageComponentType.actionRow; + + final List components; + + ActionRowComponent({required this.components}); +} + +class ButtonComponent extends MessageComponent { + @override + MessageComponentType get type => MessageComponentType.button; + + final ButtonStyle style; + + final String? label; + + final Emoji? emoji; + + final String? customId; + + final Uri? url; + + final bool? isDisabled; + + ButtonComponent({ + required this.style, + required this.label, + required this.emoji, + required this.customId, + required this.url, + required this.isDisabled, + }); +} + +enum ButtonStyle { + primary._(1), + secondary._(2), + success._(3), + danger._(4), + link._(5); + + final int value; + + const ButtonStyle._(this.value); + + factory ButtonStyle.parse(int value) => ButtonStyle.values.firstWhere( + (style) => style.value == value, + orElse: () => throw FormatException('Unknown button style', value), + ); + + @override + String toString() => 'ButtonStyle($value)'; +} + +class SelectMenuComponent extends MessageComponent { + @override + final MessageComponentType type; + + final String customId; + + final List? options; + + final List? channelTypes; + + final String? placeholder; + + final int? minValues; + + final int? maxValues; + + final bool? isDisabled; + + SelectMenuComponent({ + required this.type, + required this.customId, + required this.options, + required this.channelTypes, + required this.placeholder, + required this.minValues, + required this.maxValues, + required this.isDisabled, + }); +} + +class SelectMenuOption with ToStringHelper { + final String label; + + final String value; + + final String? description; + + final Emoji? emoji; + + final bool? isDefault; + + SelectMenuOption({ + required this.label, + required this.value, + required this.description, + required this.emoji, + required this.isDefault, + }); +} + +class TextInputComponent extends MessageComponent { + @override + MessageComponentType get type => MessageComponentType.textInput; + + final String customId; + + final TextInputStyle style; + + final String label; + + final int? minLength; + + final int? maxLength; + + final bool? isRequired; + + final String? value; + + final String? placeholder; + + TextInputComponent({ + required this.customId, + required this.style, + required this.label, + required this.minLength, + required this.maxLength, + required this.isRequired, + required this.value, + required this.placeholder, + }); +} + +enum TextInputStyle { + short._(1), + paragraph._(2); + + final int value; + + const TextInputStyle._(this.value); + + factory TextInputStyle.parse(int value) => TextInputStyle.values.firstWhere( + (style) => style.value == value, + orElse: () => throw FormatException('Unknown text input style', value), + ); + + @override + String toString() => 'TextInputStyle($value)'; +} diff --git a/lib/src/models/message/embed.dart b/lib/src/models/message/embed.dart new file mode 100644 index 000000000..057aed87c --- /dev/null +++ b/lib/src/models/message/embed.dart @@ -0,0 +1,242 @@ +import 'package:nyxx/src/models/discord_color.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template embed} +/// Rich content that can be embedded into a message, such as a video, image or custom text. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#embed-object +/// {@endtemplate} +class Embed { + /// The title of this embed. + final String? title; + + /// The description of this embed. + final String? description; + + /// The url of this embed. + final Uri? url; + + /// The timestamp of this embed's content. + final DateTime? timestamp; + + /// The color of this embed. + final DiscordColor? color; + + /// This embed's footer information. + final EmbedFooter? footer; + + /// This embed's image information. + final EmbedImage? image; + + /// This embed's thumbnail information. + final EmbedThumbnail? thumbnail; + + /// This embed's video information. + final EmbedVideo? video; + + /// This embed's provider information. + final EmbedProvider? provider; + + /// This embed's author information. + final EmbedAuthor? author; + + /// This embed's fields information. + final List? fields; + + /// {@macro embed} + Embed({ + required this.title, + required this.description, + required this.url, + required this.timestamp, + required this.color, + required this.footer, + required this.image, + required this.thumbnail, + required this.video, + required this.provider, + required this.author, + required this.fields, + }); +} + +/// {@template embed_footer} +/// A footer in an [Embed]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure +/// {@endtemplate} +class EmbedFooter with ToStringHelper { + /// This footer's text. + final String text; + + /// The URL of this footer's icon. + final Uri? iconUrl; + + /// A proxied URL of this footer's icon. + final Uri? proxiedIconUrl; + + /// {@macro embed_footer} + EmbedFooter({ + required this.text, + required this.iconUrl, + required this.proxiedIconUrl, + }); +} + +/// {@template embed_image} +/// An image in an [Embed]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure +/// {@endtemplate} +class EmbedImage with ToStringHelper { + /// The URL of this image. + final Uri url; + + /// A proxied URL of this image. + final Uri? proxiedUrl; + + /// The height of the image in pixels. + final int? height; + + /// The width of the image in pixels. + final int? width; + + /// {@macro embed_image} + EmbedImage({ + required this.url, + required this.proxiedUrl, + required this.height, + required this.width, + }); +} + +/// {@template embed_thumbnail} +/// A thumbnail in an [Embed]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#embed-object-embed-thumbnail-structure +/// {@endtemplate} +class EmbedThumbnail with ToStringHelper { + /// The URL of this footer's image. + final Uri url; + + /// A proxied URL of this footer's image. + final Uri? proxiedUrl; + + /// The height of this footer's image, in pixels. + final int? height; + + /// The width of this footer's image, in pixels. + final int? width; + + /// {@macro embed_thumbnail} + EmbedThumbnail({ + required this.url, + required this.proxiedUrl, + required this.height, + required this.width, + }); +} + +/// {@template embed_video} +/// A video in an [Embed]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure +/// {@endtemplate} +class EmbedVideo with ToStringHelper { + /// The URL of this video. + final Uri? url; + + /// A proxied URL of this video. + final Uri? proxiedUrl; + + /// The height of the video in pixels. + final int? height; + + /// The width of the video in pixels. + final int? width; + + /// {@macro embed_video} + EmbedVideo({ + required this.url, + required this.proxiedUrl, + required this.height, + required this.width, + }); +} + +/// {@template embed_provider} +/// A provider for an [Embed]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#embed-object-embed-provider-structure +/// {@endtemplate} +class EmbedProvider with ToStringHelper { + /// The name of this provider. + final String? name; + + /// The URL of this provider. + final Uri? url; + + /// {@macro embed_provider} + EmbedProvider({ + required this.name, + required this.url, + }); +} + +/// {@template embed_author} +/// An author for an [Embed]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure +/// {@endtemplate} +class EmbedAuthor with ToStringHelper { + /// The name of this author. + final String name; + + /// The URL of this author. + final Uri? url; + + /// The URL of this author's icon. + final Uri? iconUrl; + + /// A proxied URL of this author's icon. + final Uri? proxyIconUrl; + + /// {@macro embed_author} + EmbedAuthor({ + required this.name, + required this.url, + required this.iconUrl, + required this.proxyIconUrl, + }); +} + +/// {@template embed_field} +/// A field in an [Embed]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure +/// {@endtemplate} +class EmbedField with ToStringHelper { + /// The name of this field. + final String name; + + /// The value of this field. + final String value; + + /// Whether this field is displayed inline. + final bool inline; + + /// {@macro embed_field} + EmbedField({ + required this.name, + required this.value, + required this.inline, + }); +} diff --git a/lib/src/models/message/message.dart b/lib/src/models/message/message.dart new file mode 100644 index 000000000..d230d9817 --- /dev/null +++ b/lib/src/models/message/message.dart @@ -0,0 +1,374 @@ +import 'package:nyxx/src/builders/emoji/reaction.dart'; +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/http/managers/message_manager.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/thread.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/interaction.dart'; +import 'package:nyxx/src/models/message/activity.dart'; +import 'package:nyxx/src/models/message/attachment.dart'; +import 'package:nyxx/src/models/message/channel_mention.dart'; +import 'package:nyxx/src/models/message/component.dart'; +import 'package:nyxx/src/models/message/embed.dart'; +import 'package:nyxx/src/models/message/author.dart'; +import 'package:nyxx/src/models/message/reference.dart'; +import 'package:nyxx/src/models/message/reaction.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/message/role_subscription_data.dart'; +import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/sticker/sticker.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/models/webhook.dart'; +import 'package:nyxx/src/utils/flags.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template partial_message} +/// A partial [Message] object. +/// {@endtemplate} +class PartialMessage extends WritableSnowflakeEntity { + @override + final MessageManager manager; + + /// The ID of the [Channel] the message was sent in. + Snowflake get channelId => manager.channelId; + + /// {@macro partial_message} + PartialMessage({required super.id, required this.manager}); + + /// The channel this message was sent in. + PartialTextChannel get channel => manager.client.channels[channelId] as PartialTextChannel; + + /// Update this message. + // An often-used alias to update + Future edit(MessageUpdateBuilder builder) => update(builder); + + /// Crosspost this message to all channels following the channel this message was sent in. + Future crosspost() => manager.crosspost(id); + + /// Pin this message. + Future pin({String? auditLogReason}) => manager.pin(id, auditLogReason: auditLogReason); + + /// Unpin this message. + Future unpin({String? auditLogReason}) => manager.unpin(id, auditLogReason: auditLogReason); + + /// Creates a reaction on this message. + /// ```dart + /// await message.react('👍'); + /// ``` + /// or + /// ```dart + /// final emoji = await client.emoji.fetch(Snowflake(123456789012345678)); + /// await message.react(emoji.toString()); + /// ``` + Future react(ReactionBuilder emoji) => manager.addReaction(id, emoji); + + /// Deletes a reaction by a user, if specified on this message. + /// Otherwise deletes reactions by [emoji]. + Future deleteReaction(ReactionBuilder emoji, {Snowflake? userId}) => + userId == null ? manager.deleteReaction(id, emoji) : manager.deleteReactionForUser(id, userId, emoji); + + /// Deletes all reactions on this message. + Future deleteAllReactions() => manager.deleteAllReactions(id); + + /// Deletes reaction the current user has made on this message. + Future deleteOwnReaction(ReactionBuilder emoji) => manager.deleteOwnReaction(id, emoji); +} + +/// {@template message} +/// Represents a message sent in a [TextChannel]. +/// +/// Not all messages are sent by users. Messages can also be system messages such as the "message pinned" notice that is sent to a channel when a message is +/// pinned. Check [type] to see if a message is [MessageType.normal] or [MessageType.reply]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#message-object +/// {@endtemplate} +class Message extends PartialMessage { + /// The author of this message. + /// + /// This could be a [User] or a [Webhook]. + final MessageAuthor author; + + /// The content of the message. + /// + /// {@template message_content_intent_required} + /// The message content intent is needed for this field to be non-empty. + /// {@endtemplate} + final String content; + + /// The time when this message was sent. + final DateTime timestamp; + + /// The time when this message was last edited, or `null` if the message was never edited. + final DateTime? editedTimestamp; + + /// Whether this was a TTS message. + final bool isTts; + + /// Whether this message mentions everyone. + final bool mentionsEveryone; + + /// A list of users specifically mentioned in this message. + final List mentions; + + final List roleMentions; + + /// A list of channels specifically mentioned in this message. + final List channelMentions; + + /// A list of files attached to this message. + /// + /// {@macro message_content_intent_required} + final List attachments; + + /// A list of embeds in this message. + /// + /// {@macro message_content_intent_required} + final List embeds; + + /// A list of reactions to this message. + final List reactions; + + /// A user-set value to validate a message was sent. + /// + /// This can be an [int] or a [String], set using [MessageBuilder.nonce]. + final dynamic /* int | String */ nonce; + + /// Whether this message is pinned. + final bool isPinned; + + /// The ID of the webhook that sent this message if it was sent by a webhook, `null` otherwise. + final Snowflake? webhookId; + + /// The type of this message. + final MessageType type; + + /// Activity information if this message is related to Rich Presence, `null` otherwise. + final MessageActivity? activity; + + /// The application associated with this message if this messages is related to Rich Presence, `null` otherwise. + final PartialApplication? application; + + /// The ID of the [Application] that sent this message if it is an interaction or a webhook, `null` otherwise. + final Snowflake? applicationId; + + /// Data showing the source of a crosspost, channel follow add, pin, or reply message. + final MessageReference? reference; + + /// Any flags applied to this message. + final MessageFlags flags; + + /// The message associated with [reference]. + final Message? referencedMessage; + + /// Information about the interaction related to this message. + final MessageInteraction? interaction; + + /// The thread that was started from this message if any, `null` otherwise. + final Thread? thread; + + /// A list of components in this message. + final List? components; + + /// List of sticker attached to message + final List stickers; + + /// A generally increasing integer (there may be gaps or duplicates) that represents the approximate position of this message in a thread. + /// + /// Can be used to estimate the relative position of the message in a thread in company with [Thread.totalMessagesSent] on parent thread + final int? position; + + /// Data about the role subscription purchase that prompted this message if this is a [MessageType.roleSubscriptionPurchase] message. + final RoleSubscriptionData? roleSubscriptionData; + + /// {@macro message} + Message({ + required super.id, + required super.manager, + required this.author, + required this.content, + required this.timestamp, + required this.editedTimestamp, + required this.isTts, + required this.mentionsEveryone, + required this.mentions, + required this.roleMentions, + required this.channelMentions, + required this.attachments, + required this.embeds, + required this.reactions, + required this.nonce, + required this.isPinned, + required this.webhookId, + required this.type, + required this.activity, + required this.application, + required this.applicationId, + required this.reference, + required this.flags, + required this.referencedMessage, + required this.interaction, + required this.thread, + required this.components, + required this.position, + required this.roleSubscriptionData, + required this.stickers, + }); + + /// The webhook that sent this message if it was sent by a webhook, `null` otherwise. + PartialWebhook? get webhook => webhookId == null ? null : manager.client.webhooks[webhookId!]; +} + +/// The type of a message. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#message-object-message-types +enum MessageType { + normal._(0), + recipientAdd._(1), + recipientRemove._(2), + call._(3), + channelNameChange._(4), + channelIconChange._(5), + channelPinnedMessage._(6), + userJoin._(7), + guildBoost._(8), + guildBoostTier1._(9), + guildBoostTier2._(10), + guildBoostTier3._(11), + channelFollowAdd._(12), + guildDiscoveryDisqualified._(14), + guildDiscoveryRequalified._(15), + guildDiscoveryGracePeriodInitialWarning._(16), + guildDiscoveryGracePeriodFinalWarning._(17), + threadCreated._(18), + reply._(19), + chatInputCommand._(20), + threadStarterMessage._(21), + guildInviteReminder._(22), + contextMenuCommand._(23), + autoModerationAction._(24), + roleSubscriptionPurchase._(25), + interactionPremiumUpsell._(26), + stageStart._(27), + stageEnd._(28), + stageSpeaker._(29), + stageTopic._(31), + guildApplicationPremiumSubscription._(32); + + /// The value of this [MessageType]. + final int value; + + const MessageType._(this.value); + + /// Parse a [MessageType] from an [int]. + /// + /// [value] must be a valid [MessageType]. + factory MessageType.parse(int value) => MessageType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown MessageType', value), + ); + + @override + String toString() => 'MessageType($value)'; +} + +/// Flags that can be applied to a [Message]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#message-object-message-flags +class MessageFlags extends Flags { + /// This message has been published to subscribed channels (via Channel Following). + static const crossposted = Flag.fromOffset(0); + + /// This message originated from a message in another channel (via Channel Following). + static const isCrosspost = Flag.fromOffset(1); + + /// Do not include any embeds when serializing this message. + static const suppressEmbeds = Flag.fromOffset(2); + + /// The source message for this crosspost has been deleted (via Channel Following). + static const sourceMessageDeleted = Flag.fromOffset(3); + + /// This message came from the urgent message system. + static const urgent = Flag.fromOffset(4); + + /// This message has an associated thread, with the same id as the message. + static const hasThread = Flag.fromOffset(5); + + /// This message is only visible to the user who invoked the Interaction. + static const ephemeral = Flag.fromOffset(6); + + /// This message is an Interaction Response and the bot is "thinking". + static const loading = Flag.fromOffset(7); + + /// This message failed to mention some roles and add their members to the thread. + static const failedToMentionSomeRolesInThread = Flag.fromOffset(8); + + /// This message will not trigger push and desktop notifications. + static const suppressNotifications = Flag.fromOffset(12); + + /// Whether this set of flags has the [crossposted] flag set. + bool get wasCrossposted => has(crossposted); + + /// Whether this set of flags has the [isCrosspost] flag set. + bool get isACrosspost => has(isCrosspost); + + /// Whether this set of flags has the [suppressEmbeds] flag set. + bool get suppressesEmbeds => has(suppressEmbeds); + + /// Whether this set of flags has the [sourceMessageDeleted] flag set. + bool get sourceMessageWasDeleted => has(sourceMessageDeleted); + + /// Whether this set of flags has the [urgent] flag set. + bool get isUrgent => has(urgent); + + /// Whether this set of flags has the [hasThread] flag set. + bool get hasAThread => has(hasThread); + + /// Whether this set of flags has the [ephemeral] flag set. + bool get isEphemeral => has(ephemeral); + + /// Whether this set of flags has the [loading] flag set. + bool get isLoading => has(loading); + + /// Whether this set of flags has the [failedToMentionSomeRolesInThread] flag set. + bool get didFailToMentionSomeRolesInThread => has(failedToMentionSomeRolesInThread); + + /// Whether this set of flags has the [suppressNotifications] flag set. + bool get suppressesNotifications => has(suppressNotifications); + + /// Create a new [MessageFlags]. + const MessageFlags(super.value); +} + +/// {@template message_interaction} +/// Information about an interaction associated with a message. +/// {@endtemplate} +class MessageInteraction with ToStringHelper { + /// The ID of the interaction. + final Snowflake id; + + /// The type of the interaction. + final InteractionType type; + + /// The name of the command. + final String name; + + /// The user that triggered the interaction. + final User user; + + /// The member that triggered the interaction. + final PartialMember? member; + + /// {@macro message_interaction} + MessageInteraction({ + required this.id, + required this.type, + required this.name, + required this.user, + required this.member, + }); +} diff --git a/lib/src/models/message/reaction.dart b/lib/src/models/message/reaction.dart new file mode 100644 index 000000000..29ba02e1f --- /dev/null +++ b/lib/src/models/message/reaction.dart @@ -0,0 +1,25 @@ +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template reaction} +/// A reaction to a message. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#reaction-object +/// {@endtemplate} +class Reaction with ToStringHelper { + /// The number of times this emoji has been used to react. + final int count; + + /// Whether the current user reacted using this emoji. + final bool me; + + final PartialEmoji emoji; + + /// {@macro reaction} + Reaction({ + required this.count, + required this.me, + required this.emoji, + }); +} diff --git a/lib/src/models/message/reference.dart b/lib/src/models/message/reference.dart new file mode 100644 index 000000000..abb969e47 --- /dev/null +++ b/lib/src/models/message/reference.dart @@ -0,0 +1,76 @@ +import 'package:nyxx/src/http/managers/message_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/channel/text_channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template message_reference} +/// A reference to an external entity contained in a message. +/// +/// Different message types will have different objects in their [Message.reference] field. +/// +/// - [Crosspost messages](https://discord.com/developers/docs/resources/channel#message-types-crosspost-messages) +/// (messages with [MessageFlags.isCrosspost] set): +/// +/// All three fields are present. +/// +/// - [Channel Follow Add messages](https://discord.com/developers/docs/resources/channel#message-types-channel-follow-add-messages) +/// ([MessageType.channelFollowAdd]) +/// +/// Only [channelId] and [guildId] are present. +/// +/// - [Pin messages](https://discord.com/developers/docs/resources/channel#message-types-pin-messages) +/// ([MessageType.channelPinnedMessage]) +/// +/// All three fields are present, except if the message was pinned outside of a guild, in which case [guildId] is `null`. +/// +/// - [Replies](https://discord.com/developers/docs/resources/channel#message-types-replies) +/// ([MessageType.reply]) +/// +/// All three fields are present, except if the message was sent outside of a guild, in which case [guildId] is `null`. +/// +/// - [Thread Created messages](https://discord.com/developers/docs/resources/channel#message-types-thread-created-messages) +/// ([MessageType.threadCreated]) +/// +/// Only [channelId] and [guildId] are present, and point to the newly created thread. +/// +/// - [Thread starter messages](https://discord.com/developers/docs/resources/channel#message-types-thread-starter-messages) +/// ([MessageType.threadStarterMessage]) +/// +/// All three fields are present. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#message-reference-object +/// {@endtemplate} +class MessageReference with ToStringHelper { + /// The manager for this [MessageReference]. + final MessageManager manager; + + /// The ID of the originating [Message]. + final Snowflake? messageId; + + /// The ID of the originating message's [Channel]. + final Snowflake channelId; + + /// The ID of the originating message's [Guild]. + final Snowflake? guildId; + + /// {@macro message_reference} + MessageReference({ + required this.manager, + required this.messageId, + required this.channelId, + required this.guildId, + }); + + /// The originating message's channel. + PartialChannel get channel => manager.client.channels[channelId]; + + /// The originating message. + PartialMessage? get message => messageId == null ? null : (channel as PartialTextChannel).messages[messageId!]; + + /// The guild of the originating message. + PartialGuild? get guild => guildId == null ? null : manager.client.guilds[guildId!]; +} diff --git a/lib/src/models/message/role_subscription_data.dart b/lib/src/models/message/role_subscription_data.dart new file mode 100644 index 000000000..56a770e0e --- /dev/null +++ b/lib/src/models/message/role_subscription_data.dart @@ -0,0 +1,29 @@ +import 'package:nyxx/src/models/snowflake.dart'; + +/// {@template role_subscription_data} +/// Information about a role subscription. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#role-subscription-data-object +/// {@endtemplate} +class RoleSubscriptionData { + ///The ID of the sku and listing that the user is subscribed to. + final Snowflake listingId; + + /// The name of the tier that the user is subscribed to. + final String tierName; + + /// The cumulative number of months that the user has been subscribed for. + final int totalMonthsSubscribed; + + /// Whether this notification is for a renewal rather than a new purchase. + final bool isRenewal; + + /// {@macro role_subscription_data} + RoleSubscriptionData({ + required this.listingId, + required this.tierName, + required this.totalMonthsSubscribed, + required this.isRenewal, + }); +} diff --git a/lib/src/models/permission_overwrite.dart b/lib/src/models/permission_overwrite.dart new file mode 100644 index 000000000..11d60ce83 --- /dev/null +++ b/lib/src/models/permission_overwrite.dart @@ -0,0 +1,66 @@ +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template permission_overwrite} +/// A set of overwrites to apply to permissions within a specific channel. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/channel#overwrite-object +/// {@endtemplate} +class PermissionOverwrite with ToStringHelper { + /// The id of the entity the permission changes will apply to. + /// + /// This can be the ID of a [Member] or of a [Role], depending on [type]. + final Snowflake id; + + /// The type of permission overwrite. + final PermissionOverwriteType type; + + /// Extra permissions allowed relative to the base permissions. + /// + /// {@template permission_overwrite_field} + /// External references: + /// * [Permissions] + /// * Computing permissions: https://discord.com/developers/docs/topics/permissions#permission-overwrites + /// {@endtemplate} + final Permissions allow; + + /// Extra permissions denied relative to the base permissions. + /// + /// {@macro permission_overwrite_field} + final Permissions deny; + + /// {@macro permission_overwrite} + PermissionOverwrite({ + required this.id, + required this.type, + required this.allow, + required this.deny, + }); +} + +/// The type of a permission overwrite. +enum PermissionOverwriteType { + /// The overwrite applies to a [Role]'s permissions. + role._(0), + + /// The overwrite applies to a [Member]'s permissions. + member._(1); + + /// The value of this type. + final int value; + + const PermissionOverwriteType._(this.value); + + /// Parse a [PermissionOverwriteType] from a [value]. + /// + /// The [value] must be a valid [PermissionOverwriteType]. + factory PermissionOverwriteType.parse(int value) => PermissionOverwriteType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown PermissionOverwriteType', value), + ); + + @override + String toString() => 'PermissionOverwriteType($value)'; +} diff --git a/lib/src/models/permissions.dart b/lib/src/models/permissions.dart new file mode 100644 index 000000000..b844312f7 --- /dev/null +++ b/lib/src/models/permissions.dart @@ -0,0 +1,271 @@ +import 'package:nyxx/src/utils/flags.dart'; + +/// A set of permissions. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/topics/permissions +class Permissions extends Flags { + /// Allows creation of instant invites. + static const createInstantInvite = Flag.fromOffset(0); + + /// Allows kicking members. + static const kickMembers = Flag.fromOffset(1); + + /// Allows banning members. + static const banMembers = Flag.fromOffset(2); + + /// Allows all permissions and bypasses channel permission overwrites. + static const administrator = Flag.fromOffset(3); + + /// Allows management and editing of channels. + static const manageChannels = Flag.fromOffset(4); + + /// Allows management and editing of the guild. + static const manageGuild = Flag.fromOffset(5); + + /// Allows for the addition of reactions to messages. + static const addReactions = Flag.fromOffset(6); + + /// Allows for viewing of audit logs. + static const viewAuditLog = Flag.fromOffset(7); + + /// Allows for using priority speaker in a voice channel. + static const prioritySpeaker = Flag.fromOffset(8); + + /// Allows the user to go live. + static const stream = Flag.fromOffset(9); + + /// Allows guild members to view a channel, which includes reading messages in text channels and joining voice channels. + static const viewChannel = Flag.fromOffset(10); + + /// Allows for sending messages in a channel and creating threads in a forum (does not allow sending messages in threads). + static const sendMessages = Flag.fromOffset(11); + + /// Allows for sending of /tts messages. + static const sendTtsMessages = Flag.fromOffset(12); + + /// Allows for deletion of other users messages. + static const manageMessages = Flag.fromOffset(13); + + /// Links sent by users with this permission will be auto-embedded. + static const embedLinks = Flag.fromOffset(14); + + /// Allows for uploading images and files. + static const attachFiles = Flag.fromOffset(15); + + /// Allows for reading of message history. + static const readMessageHistory = Flag.fromOffset(16); + + /// Allows for using the @everyone tag to notify all users in a channel, and the @here tag to notify all online users in a channel. + static const mentionEveryone = Flag.fromOffset(17); + + /// Allows the usage of custom emojis from other servers. + static const useExternalEmojis = Flag.fromOffset(18); + + /// Allows for viewing guild insights. + static const viewGuildInsights = Flag.fromOffset(19); + + /// Allows for joining of a voice channel. + static const connect = Flag.fromOffset(20); + + /// Allows for speaking in a voice channel. + static const speak = Flag.fromOffset(21); + + /// Allows for muting members in a voice channel. + static const muteMembers = Flag.fromOffset(22); + + /// Allows for deafening of members in a voice channel. + static const deafenMembers = Flag.fromOffset(23); + + /// Allows for moving of members between voice channels. + static const moveMembers = Flag.fromOffset(24); + + /// Allows for using voice-activity-detection in a voice channel. + static const useVad = Flag.fromOffset(25); + + /// Allows for modification of own nickname. + static const changeNickname = Flag.fromOffset(26); + + /// Allows for modification of other users nicknames. + static const manageNicknames = Flag.fromOffset(27); + + /// Allows management and editing of roles. + static const manageRoles = Flag.fromOffset(28); + + /// Allows management and editing of webhooks. + static const manageWebhooks = Flag.fromOffset(29); + + /// Allows management and editing of emojis, stickers, and soundboard sounds. + static const manageEmojisAndStickers = Flag.fromOffset(30); + + /// Allows members to use application commands, including slash commands and context menu commands.. + static const useApplicationCommands = Flag.fromOffset(31); + + /// Allows for requesting to speak in stage channels. (This permission is under active development and may be changed or removed.). + static const requestToSpeak = Flag.fromOffset(32); + + /// Allows for creating, editing, and deleting scheduled events. + static const manageEvents = Flag.fromOffset(33); + + /// Allows for deleting and archiving threads, and viewing all private threads. + static const manageThreads = Flag.fromOffset(34); + + /// Allows for creating public and announcement threads. + static const createPublicThreads = Flag.fromOffset(35); + + /// Allows for creating private threads. + static const createPrivateThreads = Flag.fromOffset(36); + + /// Allows the usage of custom stickers from other servers. + static const useExternalStickers = Flag.fromOffset(37); + + /// Allows for sending messages in threads. + static const sendMessagesInThreads = Flag.fromOffset(38); + + /// Allows for using Activities (applications with the EMBEDDED flag) in a voice channel. + static const useEmbeddedActivities = Flag.fromOffset(39); + + /// Allows for timing out users to prevent them from sending or reacting to messages in chat and threads, and from speaking in voice and stage channels. + static const moderateMembers = Flag.fromOffset(40); + + /// Allows for viewing role subscription insights. + static const viewCreatorMonetizationAnalytics = Flag.fromOffset(41); + + /// Allows for using soundboard in a voice channel. + static const useSoundboard = Flag.fromOffset(42); + + /// A [Permissions] with all permissions enabled. + static const allPermissions = Permissions(1099511627775); + + /// Whether this set of permissions has the [createInstantInvite] permission. + bool get canCreateInstantInvite => has(createInstantInvite); + + /// Whether this set of permissions has the [kickMembers] permission. + bool get canKickMembers => has(kickMembers); + + /// Whether this set of permissions has the [banMembers] permission. + bool get canBanMembers => has(banMembers); + + /// Whether this set of permissions has the [administrator] permission. + bool get isAdministrator => has(administrator); + + /// Whether this set of permissions has the [manageChannels] permission. + bool get canManageChannels => has(manageChannels); + + /// Whether this set of permissions has the [manageGuild] permission. + bool get canManageGuild => has(manageGuild); + + /// Whether this set of permissions has the [addReactions] permission. + bool get canAddReactions => has(addReactions); + + /// Whether this set of permissions has the [viewAuditLog] permission. + bool get canViewAuditLog => has(viewAuditLog); + + /// Whether this set of permissions has the [prioritySpeaker] permission. + bool get isPrioritySpeaker => has(prioritySpeaker); + + /// Whether this set of permissions has the [stream] permission. + bool get canStream => has(stream); + + /// Whether this set of permissions has the [viewChannel] permission. + bool get canViewChannel => has(viewChannel); + + /// Whether this set of permissions has the [sendMessages] permission. + bool get canSendMessages => has(sendMessages); + + /// Whether this set of permissions has the [sendTtsMessages] permission. + bool get canSendTtsMessages => has(sendTtsMessages); + + /// Whether this set of permissions has the [manageMessages] permission. + bool get canManageMessages => has(manageMessages); + + /// Whether this set of permissions has the [embedLinks] permission. + bool get canEmbedLinks => has(embedLinks); + + /// Whether this set of permissions has the [attachFiles] permission. + bool get canAttachFiles => has(attachFiles); + + /// Whether this set of permissions has the [readMessageHistory] permission. + bool get canReadMessageHistory => has(readMessageHistory); + + /// Whether this set of permissions has the [mentionEveryone] permission. + bool get canMentionEveryone => has(mentionEveryone); + + /// Whether this set of permissions has the [useExternalEmojis] permission. + bool get canUseExternalEmojis => has(useExternalEmojis); + + /// Whether this set of permissions has the [viewGuildInsights] permission. + bool get canViewGuildInsights => has(viewGuildInsights); + + /// Whether this set of permissions has the [connect] permission. + bool get canConnect => has(connect); + + /// Whether this set of permissions has the [speak] permission. + bool get canSpeak => has(speak); + + /// Whether this set of permissions has the [muteMembers] permission. + bool get canMuteMembers => has(muteMembers); + + /// Whether this set of permissions has the [deafenMembers] permission. + bool get canDeafenMembers => has(deafenMembers); + + /// Whether this set of permissions has the [moveMembers] permission. + bool get canMoveMembers => has(moveMembers); + + /// Whether this set of permissions has the [useVad] permission. + bool get canUseVad => has(useVad); + + /// Whether this set of permissions has the [changeNickname] permission. + bool get canChangeNickname => has(changeNickname); + + /// Whether this set of permissions has the [manageNicknames] permission. + bool get canManageNicknames => has(manageNicknames); + + /// Whether this set of permissions has the [manageRoles] permission. + bool get canManageRoles => has(manageRoles); + + /// Whether this set of permissions has the [manageWebhooks] permission. + bool get canManageWebhooks => has(manageWebhooks); + + /// Whether this set of permissions has the [manageEmojisAndStickers] permission. + bool get canManageEmojisAndStickers => has(manageEmojisAndStickers); + + /// Whether this set of permissions has the [useApplicationCommands] permission. + bool get canUseApplicationCommands => has(useApplicationCommands); + + /// Whether this set of permissions has the [requestToSpeak] permission. + bool get canRequestToSpeak => has(requestToSpeak); + + /// Whether this set of permissions has the [manageEvents] permission. + bool get canManageEvents => has(manageEvents); + + /// Whether this set of permissions has the [manageThreads] permission. + bool get canManageThreads => has(manageThreads); + + /// Whether this set of permissions has the [createPublicThreads] permission. + bool get canCreatePublicThreads => has(createPublicThreads); + + /// Whether this set of permissions has the [createPrivateThreads] permission. + bool get canCreatePrivateThreads => has(createPrivateThreads); + + /// Whether this set of permissions has the [useExternalStickers] permission. + bool get canUseExternalStickers => has(useExternalStickers); + + /// Whether this set of permissions has the [sendMessagesInThreads] permission. + bool get canSendMessagesInThreads => has(sendMessagesInThreads); + + /// Whether this set of permissions has the [useEmbeddedActivities] permission. + bool get canUseEmbeddedActivities => has(useEmbeddedActivities); + + /// Whether this set of permissions has the [moderateMembers] permission. + bool get canModerateMembers => has(moderateMembers); + + /// Whether this set of permissions has the [viewCreatorMonetizationAnalytics] permission. + bool get canViewCreatorMonetizationAnalytics => has(viewCreatorMonetizationAnalytics); + + /// Whether this set of permissions has the [useSoundboard] permission. + bool get canUseSoundboard => has(useSoundboard); + + /// Create a new [Permissions] from a permissions value. + const Permissions(super.value); +} diff --git a/lib/src/models/presence.dart b/lib/src/models/presence.dart new file mode 100644 index 000000000..34a9f0ce7 --- /dev/null +++ b/lib/src/models/presence.dart @@ -0,0 +1,279 @@ +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/flags.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template client_status} +/// The status of a client on multiple platforms. +/// {@endtemplate} +class ClientStatus with ToStringHelper { + /// The client's status on a desktop session. + final UserStatus? desktop; + + /// The client's status on a mobile session. + final UserStatus? mobile; + + /// The client's status on a web session, or a bot client's status. + final UserStatus? web; + + /// {@macro client_status} + ClientStatus({required this.desktop, required this.mobile, required this.web}); +} + +/// The status of a client. +enum UserStatus { + online._('online'), + idle._('idle'), + dnd._('dnd'), + offline._('offline'); + + /// The value of this [UserStatus]. + final String value; + + const UserStatus._(this.value); + + /// Parse a [UserStatus] from a [String]. + /// + /// The [value] must be a valid user status. + factory UserStatus.parse(String value) => UserStatus.values.firstWhere( + (status) => status.value == value, + orElse: () => throw FormatException('Unknown user status', value), + ); + + @override + String toString() => 'UserStatus($value)'; +} + +/// {@template activity} +/// A Rich Presence activity. +/// {@endtemplate} +class Activity with ToStringHelper { + /// The name of this activity. + final String name; + + /// The type of this activity. + final ActivityType type; + + /// The URL to this activity. + final Uri? url; + + /// The time at which this activity was started. + final DateTime? createdAt; + + /// Information about this activity's timing. + final ActivityTimestamps? timestamps; + + /// The ID of the application associated with this activity. + final Snowflake? applicationId; + + /// Additional details about the current activity state. + final String? details; + + /// The current state of this activity. + final String? state; + + /// The custom emoji for this activity. + final Emoji? emoji; + + /// Information about this activity's party. + final ActivityParty? party; + + /// Assets to use when displaying this activity. + final ActivityAssets? assets; + + /// Rich presence secrets associated with this activity. + final ActivitySecrets? secrets; + + /// Whether this activity is an instanced game session. + final bool? isInstance; + + /// Flags indicating which fields this activity has. + final ActivityFlags? flags; + + /// A list of buttons displayed with the activity. + final List? buttons; + + /// {@macro activity} + Activity({ + required this.name, + required this.type, + required this.url, + required this.createdAt, + required this.timestamps, + required this.applicationId, + required this.details, + required this.state, + required this.emoji, + required this.party, + required this.assets, + required this.secrets, + required this.isInstance, + required this.flags, + required this.buttons, + }); +} + +/// The type of an activity. +enum ActivityType { + game._(0), + streaming._(1), + listening._(2), + watching._(3), + custom._(4), + competing._(5); + + /// The value of this [ActivityType]. + final int value; + + const ActivityType._(this.value); + + /// Parse an [ActivityType] from an [int]. + /// + /// The [value] must be a valid activity type. + factory ActivityType.parse(int value) => ActivityType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown activity type', value), + ); + + @override + String toString() => 'ActivityType($value)'; +} + +/// {@template activity_timestamps} +/// Information about an [Activity]'s timings. +/// {@endtemplate} +class ActivityTimestamps with ToStringHelper { + /// The time at which the activity starts. + final DateTime? start; + + /// The time at which the activity ends. + final DateTime? end; + + /// {@macro activity_timestamps} + ActivityTimestamps({required this.start, required this.end}); +} + +/// {@template activity_party} +/// Information about an [Activity]'s party. +/// {@endtemplate} +class ActivityParty with ToStringHelper { + /// The ID of the party. + final String? id; + + /// The current size of the party. + final int? currentSize; + + /// The maximum size of the party. + final int? maxSize; + + /// {@macro activity_party} + ActivityParty({required this.id, required this.currentSize, required this.maxSize}); +} + +/// {@template activity_assets} +/// Information about an [Activity]'s displayed assets. +/// {@endtemplate} +class ActivityAssets with ToStringHelper { + /// The activity's large image. + // TODO: Make a proper class for this, or parse it to e.g a Uri + final String? largeImage; + + /// The text displayed when hovering over the large image. + final String? largeText; + + /// The activity's small image. + // TODO: See above + final String? smallImage; + + /// The test displayed when hovering over the small image. + final String? smallText; + + /// {@macro activity_assets} + ActivityAssets({ + required this.largeImage, + required this.largeText, + required this.smallImage, + required this.smallText, + }); +} + +/// {@template activity_secrets} +/// Information about an [Activity]'s secrets. +/// {@endtemplate} +class ActivitySecrets with ToStringHelper { + /// The join secret. + final String? join; + + /// The spectate secret. + final String? spectate; + + /// The match secret. + final String? match; + + /// {@macro activity_secrets} + ActivitySecrets({required this.join, required this.spectate, required this.match}); +} + +/// Information about the data in an [Activity] instance. +class ActivityFlags extends Flags { + /// The activity is an instanced game session. + static const instance = Flag.fromOffset(0); + + /// The activity can be joined. + static const canJoin = Flag.fromOffset(1); + + /// The activity can be spectated. + static const spectate = Flag.fromOffset(2); + + /// The client can request to join the activity. + static const joinRequest = Flag.fromOffset(3); + static const sync = Flag.fromOffset(4); + static const play = Flag.fromOffset(5); + static const partyPrivacyFriends = Flag.fromOffset(6); + static const partyPrivacyVoiceChannel = Flag.fromOffset(7); + static const embedded = Flag.fromOffset(8); + + /// Create a new [ActivityFlags]. + ActivityFlags(super.value); + + /// Whether this [ActivityFlags] has the [instance] flag set. + bool get hasInstance => has(instance); + + /// Whether this [ActivityFlags] has the [canJoin] flag set. + bool get hasCanJoin => has(canJoin); + + /// Whether this [ActivityFlags] has the [spectate] flag set. + bool get hasSpectate => has(spectate); + + /// Whether this [ActivityFlags] has the [joinRequest] flag set. + bool get hasJoinRequest => has(joinRequest); + + /// Whether this [ActivityFlags] has the [sync] flag set. + bool get hasSync => has(sync); + + /// Whether this [ActivityFlags] has the [play] flag set. + bool get hasPlay => has(play); + + /// Whether this [ActivityFlags] has the [partyPrivacyFriends] flag set. + bool get hasPartyPrivacyFriends => has(partyPrivacyFriends); + + /// Whether this [ActivityFlags] has the [partyPrivacyVoiceChannel] flag set. + bool get hasPartyPrivacyVoiceChannel => has(partyPrivacyVoiceChannel); + + /// Whether this [ActivityFlags] has the [embedded] flag set. + bool get isEmbedded => has(embedded); +} + +/// {@template activity_button} +/// A button displayed in an activity. +/// {@endtemplate} +class ActivityButton with ToStringHelper { + /// This button's label. + final String label; + + /// The URL opened when this button is clicked. + final Uri url; + + /// {@macro activity_button} + ActivityButton({required this.label, required this.url}); +} diff --git a/lib/src/models/role.dart b/lib/src/models/role.dart new file mode 100644 index 000000000..f02e05677 --- /dev/null +++ b/lib/src/models/role.dart @@ -0,0 +1,100 @@ +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/managers/role_manager.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/commands/application_command_option.dart'; +import 'package:nyxx/src/models/discord_color.dart'; +import 'package:nyxx/src/models/permissions.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// A partial [Role]. +class PartialRole extends WritableSnowflakeEntity { + @override + final RoleManager manager; + + /// Create a new [PartialRole]. + PartialRole({required super.id, required this.manager}); +} + +/// {@template role} +/// A role in a [Guild]. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/topics/permissions#role-object +/// {@endtemplate} +class Role extends PartialRole implements CommandOptionMentionable { + /// The name of this role. + final String name; + + /// The color of this role. + /// + /// If the value of this color is `0`, this role does not have a visible color. + final DiscordColor color; + + /// Whether this role is displayed separately from others in the member list. + final bool isHoisted; + + /// The hash string of this role's icon. + final String? iconHash; + + /// The unicode emoji for this role. + final String? unicodeEmoji; + + /// The position of this role. + final int position; + + /// The permissions granted to this role at a guild level. + final Permissions permissions; + + /// Whether this role is mentionable. + final bool isMentionable; + + /// The tags associated with this role. + final RoleTags? tags; + + /// {@macro role} + Role({ + required super.id, + required super.manager, + required this.name, + required this.color, + required this.isHoisted, + required this.iconHash, + required this.unicodeEmoji, + required this.position, + required this.permissions, + required this.isMentionable, + required this.tags, + }); + + /// This role's icon. + CdnAsset? get icon => iconHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..roleIcons(id: id.toString()), + hash: iconHash!, + ); +} + +/// {@template role_tags} +/// Additional metadata associated with a role. +/// {@endtemplate} +class RoleTags with ToStringHelper { + /// The ID of the bot this role belongs to. + final Snowflake? botId; + + /// The ID of the integration this role belongs to. + final Snowflake? integrationId; + + /// The ID of this role's subscription sku and listing. + final Snowflake? subscriptionListingId; + + /// {@macro role_tags} + RoleTags({ + required this.botId, + required this.integrationId, + required this.subscriptionListingId, + }); +} diff --git a/lib/src/models/snowflake.dart b/lib/src/models/snowflake.dart new file mode 100644 index 000000000..8486c9904 --- /dev/null +++ b/lib/src/models/snowflake.dart @@ -0,0 +1,151 @@ +/// A unique ID used to identify objects in the API. +/// +/// {@template snowflake} +/// Snowflakes are generally unique across the API except in some cases where children share their +/// parent's IDs. +/// +/// {@template snowflake_ordering} +/// Snowflakes are ordered first by their [timestamp], then by [workerId], [processId] and +/// [increment]. The last three fields are only used internally by Discord so the only ordering +/// visible through the API is by [timestamp]. +/// {@endtemplate} +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/reference#snowflakes +/// {@endtemplate} +class Snowflake implements Comparable { + /// A [DateTime] representing the start of the Discord epoch. + /// + /// This is used as the epoch for [millisecondsSinceEpoch]. + static final epoch = DateTime.utc(2015, 1, 1, 0, 0, 0); + + /// The duration after which bulk delete operations are no longer valid. + static const bulkDeleteLimit = Duration(days: 14); + + /// A snowflake with a value of 0. + static const zero = Snowflake(0); + + /// The value of this snowflake. + /// + /// While this is stored in a signed [int], Discord treats this as an unsigned value. + final int value; + + /// The time at which this snowflake was created. + DateTime get timestamp => epoch.add(Duration(milliseconds: millisecondsSinceEpoch)); + + /// The number of milliseconds since the [epoch]. + /// + /// Discord uses a non-standard epoch for their snowflakes. As such, + /// [DateTime.fromMillisecondsSinceEpoch] will not work with this value. Users should instead use + /// the [timestamp] getter. + int get millisecondsSinceEpoch => value >> 22; + + /// The internal worker ID for this snowflake. + /// + /// {@template internal_field} + /// This is an internal field and has no practical application. + /// {@endtemplate} + int get workerId => (value & 0x3E0000) >> 17; + + /// The internal process ID for this snowflake. + /// + /// {@macro internal_field} + int get processId => (value & 0x1F000) >> 12; + + /// The internal increment value for this snowflake. + /// + /// {@macro internal_field} + int get increment => value & 0xFFF; + + /// Whether this snowflake has a value of `0`. + bool get isZero => value == 0; + + /// Create a new snowflake from an integer value. + /// + /// {@macro snowflake} + const Snowflake(this.value); + + /// Parse a string or integer value to a snowflake. + /// + /// Both data types are accepted as Discord's Gateway can transmit Snowflakes as strings or integers when using the [GatewayPayloadFormat.etf] payload format. + /// + /// The [value] must be an [int] or a [String] parsable by [int.parse]. + /// + /// {@macro snowflake} + // TODO: This method will fail once snowflakes become larger than 2^63. + // We need to parse the unsigned [value] into a signed [int]. + factory Snowflake.parse(Object /* String | int */ value) { + assert(value is String || value is int); + + if (value is! int) { + value = int.parse(value.toString()); + } + + return Snowflake(value); + } + + /// Create a snowflake with a timestamp equal to the current time. + /// + /// {@macro snowflake} + factory Snowflake.now() => Snowflake.fromDateTime(DateTime.now()); + + /// Create a snowflake with a timestamp equal to [dateTime]. + /// + /// [dateTime] must be a [DateTime] which is at the same moment as or after [epoch]. + /// + /// {@macro snowflake} + factory Snowflake.fromDateTime(DateTime dateTime) { + assert( + dateTime.isAfter(epoch) || dateTime.isAtSameMomentAs(epoch), + 'Cannot create a Snowflake before the epoch.', + ); + + return Snowflake(dateTime.difference(epoch).inMilliseconds << 22); + } + + /// Create a snowflake representing the oldest time at which bulk delete operations will work. + /// + /// {@macro snowflake} + factory Snowflake.firstBulk() => Snowflake.fromDateTime(DateTime.now().subtract(bulkDeleteLimit)); + + /// Return `true` if this snowflake has a [timestamp] before [other]'s timestamp. + bool isBefore(Snowflake other) => timestamp.isBefore(other.timestamp); + + /// Return `true` if this snowflake has a [timestamp] after [other]'s timestamp. + bool isAfter(Snowflake other) => timestamp.isAfter(other.timestamp); + + /// Return `true` if this snowflake has a [timestamp] at the same time as [other]'s timestamp. + bool isAtSameMomentAs(Snowflake other) => timestamp.isAtSameMomentAs(other.timestamp); + + /// Return a snowflake [duration] after this snowflake. + /// + /// The returned snowflake has no [workerId], [processId] or [increment]. + Snowflake operator +(Duration duration) => Snowflake.fromDateTime(timestamp.add(duration)); + + /// Return a snowflake [duration] before this snowflake. + /// + /// The returned snowflake has no [workerId], [processId] or [increment]. + Snowflake operator -(Duration duration) => Snowflake.fromDateTime(timestamp.subtract(duration)); + + @override + int compareTo(Snowflake other) => value.compareTo(other.value); + + /// Whether this snowflake is before [other]. + /// + /// See [isBefore] for details. + bool operator <(Snowflake other) => isBefore(other); + + /// Whether this snowflake is after [other]. + /// + /// See [isAfter] for details. + bool operator >(Snowflake other) => isAfter(other); + + @override + bool operator ==(Object other) => identical(this, other) || (other is Snowflake && other.value == value); + + @override + int get hashCode => value.hashCode; + + @override + String toString() => value.toString(); +} diff --git a/lib/src/models/snowflake_entity/snowflake_entity.dart b/lib/src/models/snowflake_entity/snowflake_entity.dart new file mode 100644 index 000000000..85e5110f4 --- /dev/null +++ b/lib/src/models/snowflake_entity/snowflake_entity.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// The base class for all entities in the API identified by a [Snowflake]. +abstract class SnowflakeEntity> with ToStringHelper { + /// The id of this entity. + final Snowflake id; + + /// Create a new [SnowflakeEntity]. + SnowflakeEntity({required this.id}); + + /// If this entity exists in the manager's cache, return the cached instance. Otherwise, [fetch] + /// this entity and return it. + Future get(); + + /// Fetch this entity from the API. + Future fetch(); + + @override + bool operator ==(Object other) => identical(this, other) || (other is SnowflakeEntity && other.id == id); + + @override + int get hashCode => id.hashCode; + + @override + String defaultToString() => '$T($id)'; +} + +/// The base class for all [SnowflakeEntity]'s that have a dedicated [ReadOnlyManager]. +abstract class ManagedSnowflakeEntity> extends SnowflakeEntity { + /// The manager for this entity. + ReadOnlyManager get manager; + + /// Create a new [ManagedSnowflakeEntity]; + ManagedSnowflakeEntity({required super.id}); + + @override + Future get() => manager.get(id); + + @override + Future fetch() => manager.fetch(id); +} + +/// The base class for all [SnowflakeEntity]'s that have a dedicated [Manager]. +abstract class WritableSnowflakeEntity> extends ManagedSnowflakeEntity { + @override + Manager get manager; + + /// Create a new [WritableSnowflakeEntity]. + WritableSnowflakeEntity({required super.id}); + + /// Update this entity using the provided builder and return the updated entity. + Future update(covariant UpdateBuilder builder) => manager.update(id, builder); + + /// Delete this entity. + Future delete() => manager.delete(id); +} diff --git a/lib/src/models/sticker/global_sticker.dart b/lib/src/models/sticker/global_sticker.dart new file mode 100644 index 000000000..80e8376d1 --- /dev/null +++ b/lib/src/models/sticker/global_sticker.dart @@ -0,0 +1,67 @@ +import 'package:nyxx/src/http/managers/sticker_manager.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/sticker/sticker.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +class PartialGlobalSticker extends ManagedSnowflakeEntity { + @override + final GlobalStickerManager manager; + + PartialGlobalSticker({required super.id, required this.manager}); +} + +/// {@template global_sticker} +/// A sticker that can be sent in messages. Represents global stickers (default stickers) +/// {@endtemplate} +class GlobalSticker extends PartialGlobalSticker with Sticker { + /// Name of the sticker + @override + final String name; + + /// Description of the sticker + @override + final String? description; + + /// Autocomplete/suggestion tags for the sticker (comma separated string) + @override + final String tags; + + /// Type of sticker + @override + final StickerType type; + + /// Type of sticker format + @override + final StickerFormatType formatType; + + /// Whether this guild sticker can be used, may be false due to loss of Server Boosts + @override + final bool available; + + /// The user that uploaded the guild sticker + @override + final PartialUser? user; + + /// The standard sticker's sort order within its pack + @override + final int? sortValue; + + /// For standard stickers, id of the pack the sticker is from + final Snowflake packId; + + /// {@macro global_sticker} + GlobalSticker({ + required super.id, + required super.manager, + required this.name, + required this.description, + required this.tags, + required this.type, + required this.formatType, + required this.available, + required this.packId, + required this.user, + required this.sortValue, + }); +} diff --git a/lib/src/models/sticker/guild_sticker.dart b/lib/src/models/sticker/guild_sticker.dart new file mode 100644 index 000000000..e809c653d --- /dev/null +++ b/lib/src/models/sticker/guild_sticker.dart @@ -0,0 +1,67 @@ +import 'package:nyxx/src/http/managers/sticker_manager.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/sticker/sticker.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +class PartialGuildSticker extends WritableSnowflakeEntity { + @override + final GuildStickerManager manager; + + PartialGuildSticker({required super.id, required this.manager}); +} + +/// {@template guild_sticker} +/// A sticker that can be sent in messages. Represent stickers added to guild +/// {@endtemplate} +class GuildSticker extends PartialGuildSticker with Sticker { + /// Name of the sticker + @override + final String name; + + /// Description of the sticker + @override + final String? description; + + /// Autocomplete/suggestion tags for the sticker (comma separated string) + @override + final String tags; + + /// Type of sticker + @override + final StickerType type; + + /// Type of sticker format + @override + final StickerFormatType formatType; + + /// Whether this guild sticker can be used, may be false due to loss of Server Boosts + @override + final bool available; + + /// Id of the guild that owns this sticker + final Snowflake guildId; + + /// The user that uploaded the guild sticker + @override + final PartialUser? user; + + /// The standard sticker's sort order within its pack + @override + final int? sortValue; + + /// {@macro guild_sticker} + GuildSticker({ + required super.id, + required super.manager, + required this.name, + required this.description, + required this.tags, + required this.type, + required this.formatType, + required this.available, + required this.guildId, + required this.user, + required this.sortValue, + }); +} diff --git a/lib/src/models/sticker/sticker.dart b/lib/src/models/sticker/sticker.dart new file mode 100644 index 000000000..1173eb2a8 --- /dev/null +++ b/lib/src/models/sticker/sticker.dart @@ -0,0 +1,96 @@ +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +enum StickerType { + standard._(1), + guild._(2); + + /// The value of this [StickerType]. + final int value; + + const StickerType._(this.value); + + /// Parse a [StickerType] from a [value]. + /// + /// The [value] must be a valid sticker type + factory StickerType.parse(int value) => StickerType.values.firstWhere( + (state) => state.value == value, + orElse: () => throw FormatException('Unknown sticker type', value), + ); + + @override + String toString() => 'StickerType($value)'; +} + +enum StickerFormatType { + png._(1), + apng._(2), + lottie._(3), + gif._(4); + + /// The value of this [StickerFormatType]. + final int value; + + const StickerFormatType._(this.value); + + /// Parse a [StickerFormatType] from a [value]. + /// + /// The [value] must be a valid sticker format type + factory StickerFormatType.parse(int value) => StickerFormatType.values.firstWhere( + (state) => state.value == value, + orElse: () => throw FormatException('Unknown sticker format type', value), + ); + + @override + String toString() => 'StickerFormatType($value)'; +} + +/// Mixin with shared properties with stickers +mixin Sticker { + /// Name of the sticker + String get name; + + /// Description of the sticker + String? get description; + + /// Autocomplete/suggestion tags for the sticker (comma separated string) + String get tags; + + /// Type of sticker + StickerType get type; + + /// Type of sticker format + StickerFormatType get formatType; + + /// Whether this guild sticker can be used, may be false due to loss of Server Boosts + bool get available; + + /// The user that uploaded the guild sticker + PartialUser? get user; + + /// The standard sticker's sort order within its pack + int? get sortValue; + + /// Returns tags in list format since [tags] field is comma-separated string + List getTags() => tags.split(','); +} + +/// {@template sticker_item} +/// A representation of a sticker with minimal information +/// {@endtemplate} +class StickerItem extends SnowflakeEntity { + /// Name of sticker + final String name; + + /// Format type of sticker + final StickerFormatType formatType; + + /// {@macro sticker_item} + StickerItem({required super.id, required this.name, required this.formatType}); + + @override + Future fetch() => get(); + + @override + Future get() async => this; +} diff --git a/lib/src/models/sticker/sticker_pack.dart b/lib/src/models/sticker/sticker_pack.dart new file mode 100644 index 000000000..64c170091 --- /dev/null +++ b/lib/src/models/sticker/sticker_pack.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:nyxx/src/http/managers/sticker_manager.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/sticker/global_sticker.dart'; + +/// {@template sticker_pack} +/// A Sticker Pack -- group of stickers that are gated behind Nitro. +/// {@endtemplate} +class StickerPack extends SnowflakeEntity { + /// Global sticker manager + final GlobalStickerManager manager; + + /// The stickers in the pack + final List stickers; + + /// Name of the sticker pack + final String name; + + /// Id of the pack's SKU + final Snowflake skuId; + + /// Id of a sticker in the pack which is shown as the pack's icon + final Snowflake? coverStickerId; + + /// Description of the sticker pack + final String description; + + /// Id of the sticker pack's banner image + final Snowflake? bannerAssetId; + + /// {@macro sticker_pack} + StickerPack( + {required super.id, + required this.manager, + required this.stickers, + required this.name, + required this.skuId, + required this.coverStickerId, + required this.description, + required this.bannerAssetId}); + + @override + Future fetch() async => manager.fetchStickerPack(id); + + @override + Future get() async => this; +} diff --git a/lib/src/models/team.dart b/lib/src/models/team.dart new file mode 100644 index 000000000..4c665b39e --- /dev/null +++ b/lib/src/models/team.dart @@ -0,0 +1,97 @@ +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/managers/application_manager.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template team} +/// A group of developers. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/topics/teams#data-models-team-object +/// {@endtemplate} +class Team with ToStringHelper { + /// The manager for this team. + final ApplicationManager manager; + + /// The hash of this team's icon. + final String? iconHash; + + /// This team's ID. + final Snowflake id; + + /// The members of this team. + final List members; + + /// The name of this team. + final String name; + + /// The ID of the owner of this team. + final Snowflake ownerId; + + /// {@macro team} + Team({ + required this.manager, + required this.iconHash, + required this.id, + required this.members, + required this.name, + required this.ownerId, + }); + + /// The owner of this team. + PartialUser get owner => manager.client.users[ownerId]; + + /// This team's icon. + CdnAsset? get icon => iconHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..teamIcons(id: id.toString()), + hash: iconHash!, + ); +} + +/// {@template team_member} +/// A member of a [Team]. +/// {@endtemplate} +class TeamMember with ToStringHelper { + /// This team member's membership status. + final TeamMembershipState membershipState; + + /// The ID of the team this member belongs to. + final Snowflake teamId; + + /// The user associated with this team member. + final PartialUser user; + + /// {@macro team_member} + TeamMember({ + required this.membershipState, + required this.teamId, + required this.user, + }); +} + +/// The status of a member in a [Team]. +enum TeamMembershipState { + invited._(1), + accepted._(2); + + /// The value of this [TeamMembershipState]. + final int value; + + const TeamMembershipState._(this.value); + + /// Parse a [TeamMembershipState] from a [value]. + /// + /// The [value] must be a valid team membership state. + factory TeamMembershipState.parse(int value) => TeamMembershipState.values.firstWhere( + (state) => state.value == value, + orElse: () => throw FormatException('Unknown team membership state', value), + ); + + @override + String toString() => 'TeamMembershipState($value)'; +} diff --git a/lib/src/models/user/application_role_connection.dart b/lib/src/models/user/application_role_connection.dart new file mode 100644 index 000000000..4012b7934 --- /dev/null +++ b/lib/src/models/user/application_role_connection.dart @@ -0,0 +1,25 @@ +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template application_role_connection} +/// A role connection an application has attached to a user. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/user#application-role-connection-object +/// {@endtemplate} +class ApplicationRoleConnection with ToStringHelper { + /// The vanity name of the platform a bot has connected. + final String? platformName; + + /// The username of the user on the platform a bot has connected. + final String? platformUsername; + + /// A mapping of [ApplicationRoleConnectionMetadata] keys to their stringified values. + final Map metadata; + + /// {@macro application_role_connection} + ApplicationRoleConnection({ + required this.platformName, + required this.platformUsername, + required this.metadata, + }); +} diff --git a/lib/src/models/user/connection.dart b/lib/src/models/user/connection.dart new file mode 100644 index 000000000..427bff1a5 --- /dev/null +++ b/lib/src/models/user/connection.dart @@ -0,0 +1,122 @@ +import 'package:nyxx/src/models/guild/integration.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// A link to an account on a service other than Discord. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/user#connection-object +class Connection with ToStringHelper { + /// The ID of the account on the target service. + final String id; + + /// The username of the account on the target service. + final String name; + + /// The type of connection. + final ConnectionType type; + + /// Whether the connection is revoked. + final bool? isRevoked; + + /// A list of integrations associated with this connection. + final List? integrations; + + /// Whether the connection is verified. + final bool isVerified; + + /// Whether friend sync is enabled for this connection. + final bool isFriendSyncEnabled; + + /// Whether activities related to this connection will be shown in presence updates. + final bool showActivity; + + /// Whether the connection has a corresponding third party OAuth2 token. + final bool isTwoWayLink; + + /// The visibility of this connection. + final ConnectionVisibility visibility; + + /// Create a new [Connection]. + Connection({ + required this.id, + required this.name, + required this.type, + required this.isRevoked, + required this.integrations, + required this.isVerified, + required this.isFriendSyncEnabled, + required this.showActivity, + required this.isTwoWayLink, + required this.visibility, + }); +} + +/// The type of a connection. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/user#connection-object-services +enum ConnectionType { + battleNet._('battlenet', 'Battle.net'), + ebay._('ebay', 'eBay'), + epicGames._('epicgames', 'Epic Games'), + facebook._('facebook', 'Facebook'), + github._('github', 'GitHub'), + instagram._('instagram', 'Instagram'), + leagueOfLegends._('leagueoflegends', 'League of Legends'), + paypal._('paypal', 'PayPal'), + playstation._('playstation', 'PlayStation Network'), + reddit._('reddit', 'Reddit'), + riotGames._('riotgames', 'Riot Games'), + spotify._('spotify', 'Spotify'), + skype._('skype', 'Skype'), + steam._('steam', 'Steam'), + tikTok._('tiktok', 'TikTok'), + twitch._('twitch', 'Twitch'), + twitter._('twitter', 'Twitter'), + xbox._('xbox', 'Xbox'), + youtube._('youtube', 'YouTube'); + + /// The value of this connection type. + final String value; + + /// A human-readable name for this connection type. + final String name; + + const ConnectionType._(this.value, this.name); + + /// Parse a string to a [ConnectionType]. + /// + /// The [value] must be a string containing a valid [ConnectionType.value]. + factory ConnectionType.parse(String value) => ConnectionType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown ConnectionType', value), + ); + + @override + String toString() => 'ConnectionType($name)'; +} + +/// The visibility level of a connection. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/user#connection-object-visibility-types +enum ConnectionVisibility { + none._(0), + everyone._(1); + + /// THe value of this connection visibility level. + final int value; + + const ConnectionVisibility._(this.value); + + /// Parse an integer value to a [ConnectionVisibility]. + /// + /// The [value] must be a valid [ConnectionVisibility]. + factory ConnectionVisibility.parse(int value) => ConnectionVisibility.values.firstWhere( + (visibility) => visibility.value == value, + orElse: () => throw FormatException('Unknown ConnectionVisibility', value), + ); + + @override + String toString() => 'ConnectionVisibility($name)'; +} diff --git a/lib/src/models/user/user.dart b/lib/src/models/user/user.dart new file mode 100644 index 000000000..09126ad82 --- /dev/null +++ b/lib/src/models/user/user.dart @@ -0,0 +1,237 @@ +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/managers/user_manager.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/commands/application_command_option.dart'; +import 'package:nyxx/src/models/discord_color.dart'; +import 'package:nyxx/src/models/locale.dart'; +import 'package:nyxx/src/models/message/author.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/utils/flags.dart'; + +/// A partial [User] object. +class PartialUser extends ManagedSnowflakeEntity { + @override + final UserManager manager; + + /// Create a new [PartialUser]. + PartialUser({required super.id, required this.manager}); +} + +/// {@template user} +/// A single user, outside of a [Guild]'s context. +/// +/// [User]s can be actual users, bots or teams. See [isBot] and [UserFlags.isTeamUser] to check for +/// the latter two. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/user#users-resource +/// {@endtemplate} +class User extends PartialUser implements MessageAuthor, CommandOptionMentionable { + /// The user's username. + @override + final String username; + + /// The user's discriminator. + final String discriminator; + + /// The user's global display name, if it is set. + final String? globalName; + + /// The user's avatar hash, if they have an avatar. + @override + final String? avatarHash; + + /// Whether the user is a bot. + final bool isBot; + + /// Whether the user is a system user. + final bool isSystem; + + /// Whether the user has two factor authentication enabled. + final bool hasMfaEnabled; + + /// The user's banner hash, if they have a banner. + final String? bannerHash; + + /// The user's accent color, if they have an accent color. + final DiscordColor? accentColor; + + /// The user's locale, if they have a locale. + final Locale? locale; + + /// The [UserFlags] on the user's account. + final UserFlags? flags; + + /// The [NitroType] on the user's account. + final NitroType nitroType; + + /// The public [UserFlags] on the user's account. + final UserFlags? publicFlags; + + /// {@macro user} + User({ + required super.manager, + required super.id, + required this.username, + required this.discriminator, + required this.globalName, + required this.avatarHash, + required this.isBot, + required this.isSystem, + required this.hasMfaEnabled, + required this.bannerHash, + required this.accentColor, + required this.locale, + required this.flags, + required this.nitroType, + required this.publicFlags, + }); + + /// This user's banner. + CdnAsset? get banner => bannerHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..banners(id: id.toString()), + hash: bannerHash!, + ); + + /// This user's default avatar. + CdnAsset get defaultAvatar => CdnAsset( + client: manager.client, + base: HttpRoute() + ..embed() + ..avatars(), + hash: ((id.value >> 22) % 6).toString(), + ); + + @override + CdnAsset get avatar => avatarHash == null + ? defaultAvatar + : CdnAsset( + client: manager.client, + base: HttpRoute()..avatars(id: id.toString()), + hash: avatarHash!, + ); +} + +/// A set of [Flags] a user can have. +class UserFlags extends Flags { + /// The user is a Discord employee. + static const staff = Flag.fromOffset(0); + + /// The user is a Partnered Server Owner. + static const partner = Flag.fromOffset(1); + + /// The user is a Hypesquad Events Member. + static const hypesquad = Flag.fromOffset(2); + + /// The user has the Bug Hunter level 1 badge. + static const bugHunter1 = Flag.fromOffset(3); + + /// The user is a House of Bravery Member. + static const hypesquadHouse1 = Flag.fromOffset(6); + + /// The user is a House of Brilliance Member. + static const hypesquadHouse2 = Flag.fromOffset(7); + + /// The user is a House of Balance Member. + static const hypesquadHouse3 = Flag.fromOffset(8); + + /// The user is an Early Nitro Supporter. + static const earlySupporter = Flag.fromOffset(9); + + /// The user is a pseudo-user for a [Team]. + static const teamUser = Flag.fromOffset(10); + + /// The user has the Bug Hunter level 2 badge. + static const bugHunter2 = Flag.fromOffset(14); + + /// The user is a verified bot. + static const verifiedBot = Flag.fromOffset(16); + + /// The user is an Early Verified Bot Developer. + static const verifiedDeveloper = Flag.fromOffset(17); + + /// The user is a Moderator Programs Alumni. + static const certifierModerator = Flag.fromOffset(18); + + /// The user is a bot which uses only HTTP interactions, and as such is shown as online in the + /// member list. + static const botHttpInteractions = Flag.fromOffset(19); + + /// The user is an Active Developer. + static const activeDeveloper = Flag.fromOffset(22); + + /// Whether the user is a Discord employee. + bool get isStaff => has(staff); + + /// Whether the user is a Partnered Server Owner. + bool get isPartner => has(partner); + + /// Whether the user is a Hypesquad Events Member. + bool get isHypesquad => has(hypesquad); + + /// Whether the user has the Bug Hunter level 1 badge. + bool get isBugHunter1 => has(bugHunter1); + + /// Whether the user is a House of Bravery Member. + bool get isHypesquadHouse1 => has(hypesquadHouse1); + + /// Whether the user is a House of Brilliance Member. + bool get isHypesquadHouse2 => has(hypesquadHouse2); + + /// Whether the user is a House of Balance Member. + bool get isHypesquadHouse3 => has(hypesquadHouse3); + + /// Whether the user is an Early Nitro Supporter. + bool get isEarlySupporter => has(earlySupporter); + + /// Whether the user is a pseudo-user for a [Team]. + bool get isTeamUser => has(teamUser); + + /// Whether the user has the Bug Hunter level 2 badge. + bool get isBugHunter2 => has(bugHunter2); + + /// Whether the user is a verified bot. + bool get isVerifiedBot => has(verifiedBot); + + /// Whether the user is an Early Verified Bot Developer. + bool get isVerifiedDeveloper => has(verifiedDeveloper); + + /// Whether the user is a Moderator Programs Alumni. + bool get isCertifierModerator => has(certifierModerator); + + /// Whether the user is a bot which uses only HTTP interactions. + bool get isBotHttpInteractions => has(botHttpInteractions); + + /// Whether the user is an Active Developer. + bool get isActiveDeveloper => has(activeDeveloper); + + /// Create a new [UserFlags]. + const UserFlags(super.value); +} + +/// The types of Discord Nitro subscription a user can have. +enum NitroType { + none._(0), + classic._(1), + nitro._(2), + basic._(3); + + /// The value of this [NitroType]. + final int value; + + const NitroType._(this.value); + + /// Parse an integer from the API to a [NitroType]. + /// + /// The [value] must be a valid nitro type. + factory NitroType.parse(int value) => NitroType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown NitroType', value), + ); + + @override + String toString() => 'NitroType($value)'; +} diff --git a/lib/src/models/voice/voice_region.dart b/lib/src/models/voice/voice_region.dart new file mode 100644 index 000000000..e15d2c108 --- /dev/null +++ b/lib/src/models/voice/voice_region.dart @@ -0,0 +1,35 @@ +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template voice_region} +/// A voice region. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/voice#voice-region-object +/// {@endtemplate} +class VoiceRegion with ToStringHelper { + /// This region's ID. + final String id; + + /// This region's name. + final String name; + + /// Whether this voice region is optimal based on the current client's position. + /// + /// This will be `true` on at most one region at a time. + final bool isOptimal; + + /// Whether this voice region is deprecated. + final bool isDeprecated; + + /// Whether this is a custom voice region. + final bool isCustom; + + /// {@macro voice_region} + VoiceRegion({ + required this.id, + required this.name, + required this.isOptimal, + required this.isDeprecated, + required this.isCustom, + }); +} diff --git a/lib/src/models/voice/voice_state.dart b/lib/src/models/voice/voice_state.dart new file mode 100644 index 000000000..809876360 --- /dev/null +++ b/lib/src/models/voice/voice_state.dart @@ -0,0 +1,92 @@ +import 'package:nyxx/src/http/managers/voice_manager.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/guild/member.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// {@template voice_state} +/// A user's voice connection state. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/voice#voice-state-object +/// {@endtemplate} +class VoiceState with ToStringHelper { + /// The manager for this [VoiceState]. + final VoiceManager manager; + + /// The ID of the guild this state is in. + final Snowflake? guildId; + + /// The ID of the channel the user is connected to. + final Snowflake? channelId; + + /// The ID of the user this state is for. + final Snowflake userId; + + final Member? member; + + /// This state's session ID. + final String sessionId; + + /// Whether the user is deafened by the server. + final bool isServerDeafened; + + /// Whether the user is muted by the server. + final bool isServerMuted; + + /// Whether the user has deafened themselves. + final bool isSelfDeafened; + + /// Whether the used has muted themselves. + final bool isSelfMuted; + + /// Whether the user is streaming. + final bool isStreaming; + + /// Whether the user's camera is enabled. + final bool isVideoEnabled; + + /// Whether the user is not permitted to speak. + final bool isSuppressed; + + /// The timestamp at which this user requested to speak. + final DateTime? requestedToSpeakAt; + + /// {@macro voice_state} + VoiceState({ + required this.manager, + required this.guildId, + required this.channelId, + required this.userId, + required this.member, + required this.sessionId, + required this.isServerDeafened, + required this.isServerMuted, + required this.isSelfDeafened, + required this.isSelfMuted, + required this.isStreaming, + required this.isVideoEnabled, + required this.isSuppressed, + required this.requestedToSpeakAt, + }); + + /// Whether this user is deafened. + bool get isDeafened => isServerDeafened || isSelfDeafened; + + /// Whether this user is muted. + bool get isMuted => isServerMuted || isSelfMuted; + + /// The key this voice state will have in the [NyxxRest.voice] cache. + Snowflake get cacheKey => Snowflake(Object.hash(guildId, userId)); + + /// The guild this voice state is in. + PartialGuild? get guild => guildId == null ? null : manager.client.guilds[guildId!]; + + /// The channel this voice state is in. + PartialChannel? get channel => channelId == null ? null : manager.client.channels[channelId!]; + + /// The user this voice state is for. + PartialUser get user => manager.client.users[userId]; +} diff --git a/lib/src/models/webhook.dart b/lib/src/models/webhook.dart new file mode 100644 index 000000000..a482e76ab --- /dev/null +++ b/lib/src/models/webhook.dart @@ -0,0 +1,195 @@ +import 'package:nyxx/src/builders/message/message.dart'; +import 'package:nyxx/src/builders/webhook.dart'; +import 'package:nyxx/src/http/cdn/cdn_asset.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/application.dart'; +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/message/author.dart'; +import 'package:nyxx/src/models/message/message.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/http/managers/webhook_manager.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +/// A partial [Webhook]. +class PartialWebhook extends WritableSnowflakeEntity { + @override + final WebhookManager manager; + + /// Create a new [PartialWebhook]. + PartialWebhook({required super.id, required this.manager}); + + /// Update this webhook, returning the updated webhook. + /// + /// External references: + /// * [WebhookManager.update] + /// * Discord API Reference: https://discord.com/developers/docs/resources/webhook#modify-webhook + @override + Future update(WebhookUpdateBuilder builder, {String? token}) => manager.update(id, builder, token: token); + + /// Delete this webhook. + /// + /// External references: + /// * [WebhookManager.delete] + /// * Discord API Reference: https://discord.com/developers/docs/resources/webhook#delete-webhook + @override + Future delete({String? token, String? auditLogReason}) => manager.delete(id, token: token, auditLogReason: auditLogReason); + + /// Execute this webhook using its [token]. + /// + /// If [wait] is `false`, `null` is returned and no errors are raised from the server. Otherwise, the created message is returned. + /// + /// If [threadId] is specified, the message is sent in that thread in this webhook's channel. + /// + /// External references: + /// * [WebhookManager.execute] + /// * Discord API Reference: https://discord.com/developers/docs/resources/webhook#execute-webhook + Future execute(MessageBuilder builder, {required String token, bool? wait, Snowflake? threadId}) => + manager.execute(id, builder, token: token, wait: wait, threadId: threadId); + + /// Fetch a message sent by this webhook using its [token]. + /// + /// If [threadId] is specified, the message is fetched in that thread in this webhook's channel. + /// + /// External references: + /// * [WebhookManager.fetchWebhookMessage] + /// * Discord API Reference: https://discord.com/developers/docs/resources/webhook#get-webhook-message + Future fetchMessage(Snowflake messageId, {required String token, Snowflake? threadId}) => + manager.fetchWebhookMessage(id, messageId, token: token, threadId: threadId); + + /// Update a message sent by this webhook using its [token]. + /// + /// If [threadId] is specified, the message is updated in that thread in this webhook's channel. + /// + /// External references: + /// * [WebhookManager.updateWebhookMessage] + /// * Discord API Reference: https://discord.com/developers/docs/resources/webhook#edit-webhook-message + Future updateMessage(Snowflake messageId, MessageUpdateBuilder builder, {required String token, Snowflake? threadId}) => + manager.updateWebhookMessage(id, messageId, builder, token: token); + + /// Delete a message sent by this webhook using its [token]. + /// + /// If [threadId] is specified, the message is deleted in that thread in this webhook's channel. + /// + /// External references: + /// * [WebhookManager.deleteWebhookMessage] + /// * Discord API Reference: https://discord.com/developers/docs/resources/webhook#delete-webhook-message + Future deleteMessage(Snowflake messageId, {required String token, Snowflake? threadId}) => + manager.deleteWebhookMessage(id, messageId, token: token, threadId: threadId); +} + +/// A partial [Webhook] sent as part of a [Message]. +class WebhookAuthor extends PartialWebhook implements MessageAuthor { + @override + final String? avatarHash; + + @override + final String username; + + /// Create a new [WebhookAuthor]. + WebhookAuthor({required super.id, required super.manager, required this.avatarHash, required this.username}); + + @override + CdnAsset? get avatar => avatarHash == null + ? null + : CdnAsset( + client: manager.client, + base: HttpRoute()..avatars(id: id.toString()), + hash: avatarHash!, + ); +} + +/// {@template webhook} +/// A non authenticated way to send messages to a Discord channel. +/// +/// External references: +/// * Discord API Reference: https://discord.com/developers/docs/resources/webhook#webhook-resource +/// {@endtemplate} +class Webhook extends PartialWebhook { + /// The type of this webhook. + final WebhookType type; + + /// The ID of the guild this webhook is for, if any. + final Snowflake? guildId; + + /// The ID of the channel this webhook is for, if any. + final Snowflake? channelId; + + /// The user this webhook was created by. + final User? user; + + /// The default name of this webhook. + final String? name; + + /// The hash of this webhook's default avatar image. + final String? avatarHash; + + /// If this is a [WebhookType.incoming] webhook, this webhook's token. + final String? token; + + /// The ID of the application that created this webhook. + final Snowflake? applicationId; + + final PartialGuild? sourceGuild; + + /// If this is a [WebhookType.channelFollower], this webhook's source channel. + final PartialChannel? sourceChannel; + + /// The URL to use to execute the webhook. + final Uri? url; + + /// {@macro webhook} + Webhook({ + required super.id, + required super.manager, + required this.type, + required this.guildId, + required this.channelId, + required this.user, + required this.name, + required this.avatarHash, + required this.token, + required this.applicationId, + required this.sourceGuild, + required this.sourceChannel, + required this.url, + }); + + /// The guild this webhook is for, if any. + PartialGuild? get guild => guildId == null ? null : manager.client.guilds[guildId!]; + + /// The channel this webhook is for, if any. + PartialChannel? get channel => channelId == null ? null : manager.client.channels[channelId!]; + + /// The application that created this webhook. + PartialApplication? get application => applicationId == null ? null : manager.client.applications[applicationId!]; +} + +/// The type of a [Webhook]. +enum WebhookType { + /// A webhook which sends messages to a channel using a [Webhook.token]. + incoming._(1), + + /// An internal webhook used to manage Channel Followers. + channelFollower._(2), + + /// A webhook for use with interactions. + application._(3); + + /// The value of this webhook type. + final int value; + + const WebhookType._(this.value); + + /// Parse a [WebhookType] from a [value]. + /// + /// The [value] must be a valid webhook type. + factory WebhookType.parse(int value) => WebhookType.values.firstWhere( + (type) => type.value == value, + orElse: () => throw FormatException('Unknown webhook type', value), + ); + + @override + String toString() => 'WebhookType($value)'; +} diff --git a/lib/src/nyxx.dart b/lib/src/nyxx.dart deleted file mode 100644 index 83d167032..000000000 --- a/lib/src/nyxx.dart +++ /dev/null @@ -1,491 +0,0 @@ -import 'dart:async'; - -import 'package:logging/logging.dart'; -import 'package:nyxx/src/client_options.dart'; -import 'package:nyxx/src/core/channel/invite.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/application/client_oauth2_application.dart'; -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/guild/client_user.dart'; -import 'package:nyxx/src/core/guild/guild.dart'; -import 'package:nyxx/src/core/guild/guild_preview.dart'; -import 'package:nyxx/src/core/guild/webhook.dart'; -import 'package:nyxx/src/core/message/sticker.dart'; -import 'package:nyxx/src/core/user/user.dart'; -import 'package:nyxx/src/events/ready_event.dart'; -import 'package:nyxx/src/internal/cache/cache.dart'; -import 'package:nyxx/src/internal/cdn_http_endpoints.dart'; -import 'package:nyxx/src/internal/connection_manager.dart'; -import 'package:nyxx/src/internal/constants.dart'; -import 'package:nyxx/src/internal/event_controller.dart'; -import 'package:nyxx/src/internal/exceptions/unrecoverable_nyxx_error.dart'; -import 'package:nyxx/src/internal/http/http_response.dart'; -import 'package:nyxx/src/internal/http_endpoints.dart'; -import 'package:nyxx/src/internal/exceptions/missing_token_error.dart'; -import 'package:nyxx/src/internal/http/http_handler.dart'; -import 'package:nyxx/src/internal/interfaces/disposable.dart'; -import 'package:nyxx/src/internal/shard/shard_manager.dart'; -import 'package:nyxx/src/plugin/plugin.dart'; -import 'package:nyxx/src/plugin/plugin_manager.dart'; -import 'package:nyxx/src/utils/builders/guild_builder.dart'; -import 'package:nyxx/src/utils/builders/presence_builder.dart'; -import 'package:nyxx/src/typedefs.dart'; - -abstract class NyxxFactory { - static INyxxRest createNyxxRest(String token, int intents, Snowflake appId, {ClientOptions? options, CacheOptions? cacheOptions}) => - NyxxRest(token, intents, appId, options: options, cacheOptions: cacheOptions); - - static INyxxWebsocket createNyxxWebsocket(String token, int intents, {ClientOptions? options, CacheOptions? cacheOptions}) => - NyxxWebsocket(token, intents, options: options, cacheOptions: cacheOptions); -} - -/// Generic interface for Nyxx. Represents basic functionality of Nyxx that are always available. -abstract class INyxx implements Disposable, IPluginManager { - /// Reference to HttpHandler - HttpHandler get httpHandler; - - /// Returns handler for all available REST API action. - IHttpEndpoints get httpEndpoints; - - /// Returns handler for all available CDN endpoints for Discord. - ICdnHttpEndpoints get cdnHttpEndpoints; - - /// Can be used to edit options after client initialised. Used by Nyxx.interactions to enable raw events - ClientOptions get options; - - /// Options for cache handling in nyxx - CacheOptions get cacheOptions; - - /// Token of instance - String get token; - - /// All of the guilds the bot is in. Can be empty or can miss guilds on (READY_EVENT). - SnowflakeCache get guilds; - - /// All of the channels the bot can see. - SnowflakeCache get channels; - - /// All of the users the bot can see. Does not have offline users - /// without `forceFetchUsers` enabled. - SnowflakeCache get users; - - /// Datetime when bot has started - DateTime get startTime; - - /// True if client is ready. - bool get ready; - - /// Id of bots application - Snowflake get appId; - - /// Emitted when client is ready - Stream get onReady; - - Future connect({bool propagateReady = true}); -} - -abstract class INyxxRest implements INyxx { - /// When identifying to the gateway, you have to specify an intents parameter which - /// allows you to conditionally subscribe to pre-defined "intents", groups of events defined by Discord. - /// If you do not specify a certain intent, you will not receive any of the gateway events that are batched into that group. - /// Since api v8 its required upon connecting to gateway. - int get intents; - - /// The current bot user. - IClientUser get self; - - /// The bot"s OAuth2 app. - IClientOAuth2Application get app; - - /// The current version of `nyxx` - String get version; - - /// Gets an bot invite link with zero permissions - String get inviteLink; - - /// Reference of event controller - IRestEventController get eventsRest; -} - -/// Lightweight client which do not start ws connections. -class NyxxRest extends INyxxRest { - @override - final String token; - - @override - late final ClientOptions options; - - @override - late final CacheOptions cacheOptions; - - @override - late final HttpHandler httpHandler; - - @override - late final IHttpEndpoints httpEndpoints; - - @override - late final ICdnHttpEndpoints cdnHttpEndpoints; - - /// When identifying to the gateway, you have to specify an intents parameter which - /// allows you to conditionally subscribe to pre-defined "intents", groups of events defined by Discord. - /// If you do not specify a certain intent, you will not receive any of the gateway events that are batched into that group. - /// Since api v8 its required upon connecting to gateway. - @override - final int intents; - - /// The current bot user. - @override - late IClientUser self; - - /// The bot"s OAuth2 app. - @override - late IClientOAuth2Application app; - - /// All of the guilds the bot is in. Can be empty or can miss guilds on (READY_EVENT). - @override - late final SnowflakeCache guilds; - - /// All of the channels the bot can see. - @override - late final SnowflakeCache channels; - - /// All of the users the bot can see. Does not have offline users - /// without `forceFetchUsers` enabled. - @override - late final SnowflakeCache users; - - /// True if client is ready. - @override - bool ready = false; - - /// The current version of `nyxx` - @override - String get version => Constants.version; - - /// Gets an bot invite link with zero permissions - @override - String get inviteLink => app.getInviteUrl(); - - @override - late final RestEventController eventsRest; - - /// Date time when bot was started - @override - final DateTime startTime = DateTime.now(); - - @override - late final Stream onReady = onReadyController.stream; - late final StreamController onReadyController = StreamController.broadcast(); - - @override - Snowflake get appId => _appId; - - @override - Iterable get plugins => _plugins; - - final Snowflake _appId; - final Logger _logger = Logger("Client"); - final List _plugins = []; - - /// Creates and logs in a new client. If [ignoreExceptions] is true (by default is) - /// isolate will ignore all exceptions and continue to work. - NyxxRest(this.token, this.intents, this._appId, {ClientOptions? options, CacheOptions? cacheOptions}) { - if (token.isEmpty) { - throw MissingTokenError(); - } - - this.options = options ?? ClientOptions(); - this.cacheOptions = cacheOptions ?? CacheOptions(); - - guilds = SnowflakeCache(); - channels = SnowflakeCache(); - users = SnowflakeCache(); - - eventsRest = RestEventController(); - } - - @override - Future connect({bool propagateReady = true}) async { - _logger.config([ - 'Token: $token', - 'Intents: $intents', - 'Application ID: $_appId', - ].join('\n')); - - httpHandler = HttpHandler(this); - httpEndpoints = HttpEndpoints(this); - cdnHttpEndpoints = CdnHttpEndpoints(); - - if (propagateReady) { - onReadyController.add(ReadyEvent(this)); - - for (final plugin in _plugins) { - await plugin.onBotStart(this, plugin.logger); - } - } - } - - @override - Future dispose() async { - _logger.info('Disposing and closing client...'); - - for (final plugin in _plugins) { - await plugin.onBotStop(this, plugin.logger); - } - - await eventsRest.dispose(); - - onReadyController.close(); - - await guilds.dispose(); - await users.dispose(); - await channels.dispose(); - } - - @override - void registerPlugin(T pluginInstance) { - pluginInstance.onRegister(this, pluginInstance.logger); - _plugins.add(pluginInstance); - } -} - -abstract class INyxxWebsocket implements INyxxRest { - /// Event controller for websocket events - IWebsocketEventController get eventsWs; - - /// Current client"s shard - IShardManager get shardManager; - - /// This endpoint is only for public guilds if bot is not int the guild. - Future fetchGuildPreview(Snowflake guildId); - - /// Returns guild with given [guildId] - /// If [withCounts] is set to true, then guild will have [IGuild.approximateMemberCount] and [IGuild.approximatePresenceCount] present. - Future fetchGuild(Snowflake guildId, {bool? withCounts = true}); - - /// Creates a guild. - /// - /// **⚠️ This endpoint can only be used by bots that are in ten guilds or fewer.** - /// ```dart - /// var gb = GuildBuilder("Test Guild"); - /// var guild = await client.createGuild(gb); - /// ``` - Future createGuild(GuildBuilder builder); - - /// Returns channel with specified id. - /// ``` - /// var channel = await client.fetchChannel(Snowflake("473853847115137024")); - /// ``` - Future fetchChannel(Snowflake channelId); - - /// Get user instance with specified id. - /// ```dart - /// var user = await client.fetchUser(Snowflake("302359032612651009")); - /// ``` - Future fetchUser(Snowflake userId); - - /// Gets a webhook by its id and/or token. - /// If token is supplied authentication is not needed. - Future fetchWebhook(Snowflake id, {String token = ""}); - - /// Gets an [Invite] object with given code. - /// If the [code] is in cache - it will be taken from it, otherwise API will be called. - /// - /// ``` - /// var inv = client.getInvite("YMgffU8"); - /// ``` - Future getInvite(String code); - - /// Returns number of shards - int get shards; - - /// Sets presence for bot. - /// - /// Code below will display bot presence as `Playing Super duper game`: - /// ```dart - /// bot.setPresence( - /// PresenceBuilder.of( - /// activity: ActivityBuilder.game("Super duper game"), - /// ), - /// ); - /// ``` - /// - /// Bots cannot set custom status - only game, listening and stream available. - /// - /// To set bot presence to streaming use: - /// ```dart - /// bot.setPresence( - /// PresenceBuilder.of( - /// activity: ActivityBuilder.streaming("Super duper game", "https://twitch.tv/l7ssha"), - /// ), - /// ); - /// ``` - void setPresence(PresenceBuilder presenceBuilder); - - /// Join [ThreadChannel] with given [channelId] - Future joinThread(Snowflake channelId) => httpEndpoints.joinThread(channelId); - - /// Gets standard sticker with given id - Future getSticker(Snowflake id); - - /// List all nitro stickers packs - Stream listNitroStickerPacks(); -} - -/// The main place to start with interacting with the Discord API and creating discord bot. -/// From there you can subscribe to various [Stream]s to listen to [Events](https://github.com/l7ssha/nyxx/wiki/EventList) -/// and fetch data from API with provided methods or get cached data. -/// -/// Creating new instance of bot: -/// ``` -/// Nyxx(""); -/// ``` -/// After initializing nyxx you can subscribe to events: -/// ``` -/// client.onReady.listen((e) => print("Ready!")); -/// -/// client.onRoleCreate.listen((e) { -/// print("Role created with name: ${e.role.name}); -/// }); -/// ``` -/// or setup `CommandsFramework` and `Voice`. -class NyxxWebsocket extends NyxxRest implements INyxxWebsocket { - late final ConnectionManager ws; - - /// Current client"s shard - @override - late final IShardManager shardManager; - - @override - late final IWebsocketEventController eventsWs; - - @override - Snowflake get appId => app.id; - - /// Creates and logs in a new client. If [ignoreExceptions] is true (by default is) - /// isolate will ignore all exceptions and continue to work. - NyxxWebsocket(String token, int intents, {ClientOptions? options, CacheOptions? cacheOptions}) - : super(token, intents, Snowflake.zero(), options: options, cacheOptions: cacheOptions) { - eventsWs = WebsocketEventController(this); - } - - @override - Future connect({bool propagateReady = true}) async { - await super.connect(propagateReady: false); - - final httpResponse = await (httpEndpoints as HttpEndpoints).getMeApplication(); - if (httpResponse is HttpResponseSuccess) { - app = ClientOAuth2Application(httpResponse.jsonBody as RawApiMap, this); - } else { - throw UnrecoverableNyxxError("Cannot get bot identity: `${httpResponse.toString()}`"); - } - - ws = ConnectionManager(this); - await ws.connect(); - - if (propagateReady) { - onReadyController.add(ReadyEvent(this)); - - for (final plugin in _plugins) { - await plugin.onBotStart(this, plugin.logger); - } - } - } - - /// This endpoint is only for public guilds if bot is not int the guild. - @override - Future fetchGuildPreview(Snowflake guildId) async => httpEndpoints.fetchGuildPreview(guildId); - - /// Returns guild with given [guildId] - /// If [withCounts] is set to true, then guild will have [IGuild.approximateMemberCount] and [IGuild.approximatePresenceCount] present. - @override - Future fetchGuild(Snowflake guildId, {bool? withCounts = true}) => httpEndpoints.fetchGuild(guildId, withCounts: withCounts); - - /// Returns channel with specified id. - /// ``` - /// var channel = await client.fetchChannel(Snowflake("473853847115137024")); - /// ``` - @override - Future fetchChannel(Snowflake channelId) => httpEndpoints.fetchChannel(channelId); - - /// Get user instance with specified id. - /// ``` - /// var user = client.getUser(Snowflake("302359032612651009")); - /// ``` - @override - Future fetchUser(Snowflake userId) => httpEndpoints.fetchUser(userId); - - /// Creates a guild. - /// - /// **⚠️ This endpoint can only be used by bots that are in ten guilds or fewer.** - /// ```dart - /// var gb = GuildBuilder("Test Guild"); - /// var guild = await client.createGuild(gb); - /// ``` - @override - Future createGuild(GuildBuilder builder) => httpEndpoints.createGuild(builder); - - /// Gets a webhook by its id and/or token. - /// If token is supplied authentication is not needed. - @override - Future fetchWebhook(Snowflake id, {String token = ""}) => httpEndpoints.fetchWebhook(id, token: token); - - /// Gets an [Invite] object with given code. - /// If the [code] is in cache - it will be taken from it, otherwise API will be called. - /// - /// ``` - /// var inv = client.getInvite("YMgffU8"); - /// ``` - @override - Future getInvite(String code) => httpEndpoints.fetchInvite(code); - - /// Returns number of shards - @override - int get shards => shardManager.shards.length; - - /// Sets presence for bot. - /// - /// Code below will display bot presence as `Playing Super duper game`: - /// ```dart - /// bot.setPresence(game: Activity.of("Super duper game")) - /// ``` - /// - /// Bots cannot set custom status - only game, listening and stream available. - /// - /// To set bot presence to streaming use: - /// ```dart - /// bot.setPresence(game: Activity.of("Super duper game", type: ActivityType.streaming, url: "https://twitch.tv/l7ssha")) - /// ``` - /// `url` property in `Activity` can be only set when type is set to `streaming` - @override - void setPresence(PresenceBuilder presenceBuilder) { - shardManager.setPresence(presenceBuilder); - } - - /// Join [ThreadChannel] with given [channelId] - @override - Future joinThread(Snowflake channelId) => httpEndpoints.joinThread(channelId); - - /// Gets standard sticker with given id - @override - Future getSticker(Snowflake id) => httpEndpoints.getSticker(id); - - /// List all nitro stickers packs - @override - Stream listNitroStickerPacks() => httpEndpoints.listNitroStickerPacks(); - - @override - Future dispose() async { - await super.dispose(); - - if (options.shutdownHook != null) { - await options.shutdownHook!(this); - } - - await shardManager.dispose(); - await eventsWs.dispose(); - - _logger.info("Bot disposed."); - } -} diff --git a/lib/src/plugin/cli_integration.dart b/lib/src/plugin/cli_integration.dart new file mode 100644 index 000000000..b1ed05438 --- /dev/null +++ b/lib/src/plugin/cli_integration.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/client_options.dart'; +import 'package:nyxx/src/plugin/plugin.dart'; + +/// A global instance of the [CliIntegration] plugin. +final cliIntegration = CliIntegration(); + +/// A plugin that lets clients close their session gracefully when the process is terminated. +class CliIntegration extends NyxxPlugin { + @override + String get name => 'CliIntegration'; + + final Set _watchedClients = {}; + List>? _subscriptions; + + void _ensureListening() { + void closeClients(ProcessSignal signal) async { + await Future.wait(Set.of(_watchedClients).map((client) { + client.logger.info('Caught SIGINT or SIGTERM, closing'); + return client.close(); + })); + + // Our listeners will have been removed, send the signal again to either terminate the process or let + // other signal handlers handle it. + // This will end up calling other signal handlers twice. + Process.killPid(pid, signal); + } + + _subscriptions ??= [ + ProcessSignal.sigint.watch().listen(closeClients), + if (!Platform.isWindows) ProcessSignal.sigterm.watch().listen(closeClients), + ]; + } + + void _removeListenersIfNeeded() { + if (_subscriptions == null || _watchedClients.isNotEmpty) { + return; + } + + for (final subscription in _subscriptions!) { + subscription.cancel(); + } + } + + @override + Future connect(ApiOptions apiOptions, ClientOptions clientOptions, Future Function() connect) async { + _ensureListening(); + + try { + final client = await connect(); + _watchedClients.add(client); + client.logger.info('Listening for SIGINT or SIGTERM to safely close'); + return client; + } finally { + _removeListenersIfNeeded(); + } + } + + @override + Future close(Nyxx client, Future Function() close) async { + _watchedClients.remove(client); + _removeListenersIfNeeded(); + await close(); + } +} diff --git a/lib/src/plugin/ignore_exceptions.dart b/lib/src/plugin/ignore_exceptions.dart new file mode 100644 index 000000000..84443a0c2 --- /dev/null +++ b/lib/src/plugin/ignore_exceptions.dart @@ -0,0 +1,71 @@ +import 'dart:isolate'; + +import 'package:logging/logging.dart'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/client_options.dart'; +import 'package:nyxx/src/plugin/plugin.dart'; + +/// A global instance of the [IgnoreExceptions] plugin. +final ignoreExceptions = IgnoreExceptions(); + +/// A plugin that prevents errors from crashing the program, instead logging them to the console. +class IgnoreExceptions extends NyxxPlugin { + @override + String get name => 'IgnoreExceptions'; + + /// The logger used to report the errors. + Logger get logger => Logger('IgnoreExceptions'); + + static int _clients = 0; + ReceivePort? _errorPort; + + void _listenIfNeeded() { + if (_errorPort != null) { + return; + } + + _errorPort = ReceivePort(); + _errorPort!.listen((err) { + final stackTrace = err[1] != null ? StackTrace.fromString(err[1] as String) : null; + final message = err[0] as String; + + logger.shout('Unhandled exception was thrown', message, stackTrace); + }); + + Isolate.current.setErrorsFatal(false); + Isolate.current.addErrorListener(_errorPort!.sendPort); + } + + void _stopListeningIfNeeded() { + if (_clients > 0) { + return; + } + + _stopListening(); + } + + void _stopListening() { + Isolate.current.removeErrorListener(_errorPort!.sendPort); + Isolate.current.setErrorsFatal(true); + + _errorPort?.close(); + } + + @override + Future connect(ApiOptions apiOptions, ClientOptions clientOptions, Future Function() connect) async { + final client = await connect(); + + _clients++; + _listenIfNeeded(); + + return client; + } + + @override + Future close(Nyxx client, Future Function() close) async { + await close(); + _clients--; + _stopListeningIfNeeded(); + } +} diff --git a/lib/src/plugin/plugins/logging.dart b/lib/src/plugin/logging.dart similarity index 56% rename from lib/src/plugin/plugins/logging.dart rename to lib/src/plugin/logging.dart index ba9135fda..0e9a722f3 100644 --- a/lib/src/plugin/plugins/logging.dart +++ b/lib/src/plugin/logging.dart @@ -2,21 +2,35 @@ import 'dart:async'; import 'dart:io'; import 'package:logging/logging.dart'; -import 'package:nyxx/src/nyxx.dart'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/client_options.dart'; import 'package:nyxx/src/plugin/plugin.dart'; -class Logging extends BasePlugin { +/// A global instance of the [Logging] plugin. +final logging = Logging(); + +/// A plugin that outputs a client's logs to [stdout] and [stderr]. +class Logging extends NyxxPlugin { + @override + String get name => 'Logging'; + + /// The level above which logs are outputted to [stderr] instead of [stdout]. final Level stderrLevel; + + /// The level at which stack traces are automatically recorded. final Level stackTraceLevel; + + /// The level above which logs are outputted. final Level logLevel; + /// The number of characters after which log messages are truncated. final int? truncateLogsAt; + /// Whether to censor the token of clients this plugin is attached to. final bool censorToken; - @override - String get name => 'Logging'; - + /// Create a new instance of the [Logging] plugin. Logging({ this.stderrLevel = Level.WARNING, this.stackTraceLevel = Level.SEVERE, @@ -25,12 +39,18 @@ class Logging extends BasePlugin { this.censorToken = true, }); + static int _clients = 0; + + final List _tokens = []; Level? _oldStacktraceLevel; Level? _oldLogLevel; StreamSubscription? _subscription; - @override - void onRegister(INyxx nyxx, Logger logger) { + void _listenIfNeeded() { + if (_subscription != null) { + return; + } + _oldStacktraceLevel = recordStackTraceAtLevel; _oldLogLevel = Logger.root.level; @@ -70,7 +90,9 @@ class Logging extends BasePlugin { var messageString = message.toString(); if (censorToken) { - messageString = messageString.replaceAll(nyxx.token, ''); + for (final token in _tokens) { + messageString = messageString.replaceAll(token, ''); + } } final outSink = rec.level > stderrLevel ? stderr : stdout; @@ -78,10 +100,35 @@ class Logging extends BasePlugin { }); } - @override - void onBotStop(INyxx nyxx, Logger logger) { + void _stopListeningIfNeeded() { + if (_clients > 0) { + return; + } + recordStackTraceAtLevel = _oldStacktraceLevel!; Logger.root.level = _oldLogLevel!; - _subscription!.cancel(); + _subscription?.cancel(); + } + + @override + Future connect(ApiOptions apiOptions, ClientOptions clientOptions, Future Function() connect) async { + if (apiOptions is RestApiOptions) { + _tokens.add(apiOptions.token); + } + + _clients++; + _listenIfNeeded(); + return await connect(); + } + + @override + Future close(Nyxx client, Future Function() close) async { + await close(); + _clients--; + _stopListeningIfNeeded(); + + if (client is NyxxRest) { + _tokens.remove(client.apiOptions.token); + } } } diff --git a/lib/src/plugin/plugin.dart b/lib/src/plugin/plugin.dart index e22abfcc8..8be3db9c6 100644 --- a/lib/src/plugin/plugin.dart +++ b/lib/src/plugin/plugin.dart @@ -1,17 +1,19 @@ -import 'dart:async'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/client_options.dart'; -import 'package:logging/logging.dart'; -import 'package:nyxx/nyxx.dart'; +/// Provides access to the connection and closing process for implementing plugins. +abstract class NyxxPlugin { + /// The name of this plugin. + String get name; -abstract class BasePlugin { - Logger get logger => Logger(name); - String get name => runtimeType.toString(); + /// Perform the connection operation. + /// + /// The function passed as an argument should be called to obtain the underlying client. + Future connect(ApiOptions apiOptions, ClientOptions clientOptions, Future Function() connect) => connect(); - FutureOr onRegister(INyxx nyxx, Logger logger) async {} - - FutureOr onBotStart(INyxx nyxx, Logger logger) async {} - FutureOr onBotStop(INyxx nyxx, Logger logger) async {} - - FutureOr onConnectionClose(INyxx nyxx, Logger logger, int closeCode, String? closeReason) async {} - FutureOr onConnectionError(INyxx nyxx, Logger logger, String errorMessage) async {} + /// Perform the close operation. + /// + /// The function passed as an argument should be called to close the underlying client. + Future close(Nyxx client, Future Function() close) => close(); } diff --git a/lib/src/plugin/plugin_manager.dart b/lib/src/plugin/plugin_manager.dart deleted file mode 100644 index f9d72e362..000000000 --- a/lib/src/plugin/plugin_manager.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -abstract class IPluginManager { - Iterable get plugins; - - void registerPlugin(T pluginInstance); -} diff --git a/lib/src/plugin/plugins/cli_integration.dart b/lib/src/plugin/plugins/cli_integration.dart deleted file mode 100644 index 94f6f6f60..000000000 --- a/lib/src/plugin/plugins/cli_integration.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:logging/logging.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/plugin/plugin.dart'; - -class CliIntegration extends BasePlugin { - @override - String get name => 'CliIntegration'; - - StreamSubscription? _sigtermSubscription; - StreamSubscription? _sigintSubscription; - - @override - void onRegister(INyxx nyxx, Logger logger) { - if (!Platform.isWindows) { - _sigtermSubscription = ProcessSignal.sigterm.watch().listen((event) => nyxx.dispose()); - } - - _sigintSubscription = ProcessSignal.sigint.watch().listen((event) => nyxx.dispose()); - - logger.info("Starting bot with pid: $pid. To stop the bot gracefully send SIGTERM or SIGKILL"); - } - - @override - void onBotStop(INyxx nyxx, Logger logger) { - _sigintSubscription?.cancel(); - _sigtermSubscription?.cancel(); - } -} diff --git a/lib/src/plugin/plugins/ignore_exception.dart b/lib/src/plugin/plugins/ignore_exception.dart deleted file mode 100644 index 13234fe03..000000000 --- a/lib/src/plugin/plugins/ignore_exception.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'dart:isolate'; - -import 'package:logging/logging.dart'; -import 'package:nyxx/src/internal/exceptions/unrecoverable_nyxx_error.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/plugin/plugin.dart'; - -class IgnoreExceptions extends BasePlugin { - @override - String get name => 'IgnoreExceptions'; - - late final ReceivePort _errorsPort; - - @override - void onRegister(INyxx nyxx, Logger logger) { - _errorsPort = _getErrorPort(logger); - - Isolate.current.setErrorsFatal(false); - Isolate.current.addErrorListener(_errorsPort.sendPort); - } - - @override - void onBotStop(INyxx nyxx, Logger logger) => _stop(); - - ReceivePort _getErrorPort(Logger logger) { - final errorsPort = ReceivePort(); - errorsPort.listen((err) { - final stackTrace = err[1] != null ? StackTrace.fromString(err[1] as String) : null; - final message = err[0] as String; - - logger.shout('Unhandled exception was thrown', message, stackTrace); - - if (message.startsWith('UnrecoverableNyxxError')) { - _stop(); - throw UnrecoverableNyxxError(message); - } - }); - - return errorsPort; - } - - void _stop() { - Isolate.current.removeErrorListener(_errorsPort.sendPort); - Isolate.current.setErrorsFatal(true); - - _errorsPort.close(); - } -} diff --git a/lib/src/typedefs.dart b/lib/src/typedefs.dart deleted file mode 100644 index 9605fb79a..000000000 --- a/lib/src/typedefs.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// Typedef of default json deserialization -typedef RawApiMap = Map; - -/// Typedef of default json list deserialization -typedef RawApiList = List; - -/// Typedef of default json list of raw api maps -typedef RawApiListOfMaps = List; diff --git a/lib/src/utils/builders/attachment_builder.dart b/lib/src/utils/builders/attachment_builder.dart deleted file mode 100644 index c933ba5ec..000000000 --- a/lib/src/utils/builders/attachment_builder.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart' as http; -import 'package:nyxx/nyxx.dart'; -import 'package:path/path.dart' as path_utils; - -class AttachmentMetadataBuilder implements Builder { - Snowflake id; - String filename; - late String description; - - AttachmentMetadataBuilder(this.id, this.filename, [String? description]) { - this.description = description ?? filename; - } - - @override - RawApiMap build() => { - 'id': id.toString(), - 'filename': filename, - 'description': description, - }; -} - -/// Helper for sending attachment in messages. Allows to create attachment from path, [File] or bytes. -class AttachmentBuilder { - final List _bytes; - - late String _name; - late bool _spoiler; - - AttachmentBuilder._new(this._bytes, this._name, bool? spoiler) { - _spoiler = spoiler ?? false; - - if (_spoiler) { - _name = "SPOILER_$_name"; - } - } - - /// Generate [Attachment] string - String get attachUrl => "attachment://$_name"; - - /// Open file at [path] then read it's contents and prepare to send. Name will be automatically extracted from path if no name provided. - factory AttachmentBuilder.path(String path, {String? name, bool? spoiler}) => AttachmentBuilder.file(File(path), name: name, spoiler: spoiler); - - /// Create attachment from specified file instance. Name will be automatically extracted from path if no name provided. - factory AttachmentBuilder.file(File file, {String? name, bool? spoiler}) { - final bytes = file.readAsBytesSync(); - final fileName = name ?? path_utils.basename(file.path); - - return AttachmentBuilder._new(bytes, fileName, spoiler); - } - - /// Creates attachment from provided bytes - factory AttachmentBuilder.bytes(List bytes, String name, {bool? spoiler}) => AttachmentBuilder._new(bytes, name, spoiler); - - /// creates instance of MultipartFile from attachment - http.MultipartFile getMultipartFile([int? index]) => - http.MultipartFile(index != null ? "file[$index]" : _name, Stream.value(_bytes), _bytes.length, filename: _name); - - /// Returns attachment encoded in Data URI scheme format - /// See: https://discord.com/developers/docs/reference#image-data - String getBase64({String defaultFormat = 'png'}) { - final encodedData = base64Encode(_bytes); - final fileExtension = path_utils.extension(_name); - final extension = fileExtension.isNotEmpty ? fileExtension.substring(1) : defaultFormat; - return "data:image/$extension;base64,$encodedData"; - } -} diff --git a/lib/src/utils/builders/auto_moderation_builder.dart b/lib/src/utils/builders/auto_moderation_builder.dart deleted file mode 100644 index 02648b76d..000000000 --- a/lib/src/utils/builders/auto_moderation_builder.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:nyxx/src/core/guild/auto_moderation.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -class AutoModerationRuleBuilder implements Builder { - /// The name of the rule. - String name; - - /// The event type of the rule. - EventTypes eventType; - - /// The trigger type. - TriggerTypes triggerType; - - /// The actions which will execute when the rule is triggered - List actions; - - /// The trigger metadata. - /// - /// Can be omitted as it is based on [triggerType]. - TriggerMetadataBuilder? triggerMetadata; - - /// Whether this rule is enabled. `false` by default. - bool? enabled; - - /// The role IDs that should not be affected by the rule. (Maximum of 20). - List? ignoredRoles; - - /// The channel ids that should not be affected by the rule. (Maximum of 50). - List? ignoredChannels; - - AutoModerationRuleBuilder( - this.name, { - required this.eventType, - required this.triggerType, - required this.actions, - this.triggerMetadata, - this.enabled, - this.ignoredChannels, - this.ignoredRoles, - }); - - @override - RawApiMap build() => { - 'name': name, - 'event_type': eventType.value, - 'trigger_type': triggerType.value, - 'actions': actions.map((a) => a.build()).toList(), - if (triggerMetadata != null) 'trigger_metadata': triggerMetadata!.build(), - if (enabled != null) 'enabled': enabled, - if (ignoredRoles != null) 'exempt_roles': ignoredRoles!.map((s) => s.toString()).toList(), - if (ignoredChannels != null) 'exempt_channels': ignoredChannels!.map((s) => s.toString()).toList(), - }; -} - -class ActionStructureBuilder implements Builder { - /// The type for this action. - ActionTypes actionType; - - /// Additional metadata needed during execution for this specific action type - ActionMetadataBuilder metadata; - - ActionStructureBuilder(this.actionType, this.metadata); - - @override - RawApiMap build() => { - 'type': actionType.value, - 'metadata': metadata.build(), - }; -} - -class ActionMetadataBuilder implements Builder { - /// Channel to which messages content should be logged. - /// - /// (Works only when it's action type is [ActionTypes.sendAlertMessage]). - Snowflake? channelId; - - /// The duration of the timeout. - /// This cannot exceed 4 weeks! - /// - /// (Works only when it's action type is [ActionTypes.timeout]). - Duration? duration; - - ActionMetadataBuilder({ - this.channelId, - this.duration, - }); - - @override - RawApiMap build() => { - if (channelId != null) 'channel_id': channelId.toString(), - if (duration != null) 'duration_seconds': duration!.inSeconds, - }; -} - -class TriggerMetadataBuilder implements Builder { - /// Substrings which will be searched for in content. - List? keywordFilter; - - /// The internally pre-defined wordsets which will be searched for in content. - List? presets; - - /// Substrings which will be exempt from triggering the preset trigger type. - List? allowList; - - /// The total number of mentions (either role and user) allowed per message. - /// (Maximum of 50) - int? mentionLimit; - - /// Regular expression patterns which will be matched against content. - ///(Maximum of 10) - List? regexPatterns; - - TriggerMetadataBuilder({ - this.allowList, - this.keywordFilter, - this.mentionLimit, - this.presets, - }); - - @override - RawApiMap build() => { - if (keywordFilter != null) 'keyword_filter': keywordFilter, - if (presets != null) 'presets': presets!.map((e) => e.value).toList(), - if (allowList != null) 'allow_list': allowList, - if (mentionLimit != null) 'mention_total_limit': mentionLimit, - if (regexPatterns != null) 'regex_patterns': regexPatterns - }; -} diff --git a/lib/src/utils/builders/builder.dart b/lib/src/utils/builders/builder.dart deleted file mode 100644 index 6663dce37..000000000 --- a/lib/src/utils/builders/builder.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; - -/// Provides abstraction for builders -// ignore: one_member_abstracts -abstract class Builder { - /// Returns built response for api - RawApiMap build(); -} diff --git a/lib/src/utils/builders/channel_builder.dart b/lib/src/utils/builders/channel_builder.dart deleted file mode 100644 index 9d3b9572d..000000000 --- a/lib/src/utils/builders/channel_builder.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:nyxx/src/core/channel/channel.dart'; -import 'package:nyxx/src/core/channel/guild/forum/forum_channel.dart'; -import 'package:nyxx/src/core/channel/guild/voice_channel.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/message/unicode_emoji.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; -import 'package:nyxx/src/utils/builders/forum_thread_builder.dart'; -import 'package:nyxx/src/utils/builders/permissions_builder.dart'; - -/// Builder for creating mini channel instance -abstract class ChannelBuilder implements Builder { - /// Name of the channel (1-100 characters) - String? name; - - /// Id of the channel. - /// When using the `channels` parameter on [GuildBuilder], this field within each channel object may be set to an integer placeholder, and will be replaced by the API upon consumption. - /// Its purpose is to allow you to create `GUILD_CATEGORY` channels by setting the [parentChannel.id] field on any children to the category's id field. Category channels must be listed before any children. - Snowflake? id; - - /// Type of channel - ChannelType? type; - - /// Sorting position of the channel - int? position; - - /// Id of the parent category for a channel - SnowflakeEntity? parentChannel; - - /// The channel's permission overwrites - List? permissionOverrides; - - ChannelBuilder._({ - this.id, - this.name, - this.parentChannel, - this.permissionOverrides, - this.position, - this.type, - }); - - @override - RawApiMap build() => { - if (name != null) "name": name, - if (id != null) "id": id!.toString(), - if (type != null) "type": type!.value, - if (position != null) "position": position, - if (parentChannel != null) "parent_id": parentChannel!.id.toString(), - if (permissionOverrides != null) "permission_overwrites": permissionOverrides!.map((e) => e.build()).toList(), - }; -} - -class VoiceChannelBuilder extends ChannelBuilder { - /// Type of channel - @override - // ignore: overridden_fields - ChannelType? type = ChannelType.voice; - - /// The bitrate (in bits) of the voice channel (voice only) - int? bitrate; - - /// The user limit of the voice channel (voice only) - int? userLimit; - - /// Amount of seconds a user has to wait before sending another message (0-21600); - /// bots, as well as users with the permission manage_messages or manage_channel, are unaffected - int? rateLimitPerUser; - - /// Channel voice region id, automatic when set to null - String? rtcRegion = ""; - - VoiceChannelBuilder({ - super.id, - super.name, - super.parentChannel, - super.permissionOverrides, - super.position, - this.bitrate, - this.rateLimitPerUser, - this.rtcRegion, - this.userLimit, - }) : super._(); - - @override - RawApiMap build() => { - ...super.build(), - if (bitrate != null) "bitrate": bitrate, - if (userLimit != null) "user_limit": userLimit, - if (rateLimitPerUser != null) "rate_limit_per_user": rateLimitPerUser, - if (rtcRegion != "") "rtc_region": rtcRegion, - }; -} - -class TextChannelBuilder extends ChannelBuilder { - /// Type of channel - @override - // ignore: overridden_fields - ChannelType? type = ChannelType.text; - - /// Channel topic (0-1024 characters) - String? topic; - - /// Whether the channel is nsfw - bool? nsfw; - - VideoQualityMode? videoQualityMode; - - TextChannelBuilder({ - super.id, - super.name, - super.parentChannel, - super.permissionOverrides, - super.position, - this.nsfw, - this.topic, - }) : super._(); - factory TextChannelBuilder.create(String name) { - final builder = TextChannelBuilder(); - builder.name = name; - return builder; - } - - @override - RawApiMap build() => { - ...super.build(), - if (topic != null) "topic": topic, - if (nsfw != null) "nsfw": nsfw, - if (videoQualityMode != null) "video_quality_mode": videoQualityMode!.value, - }; -} - -class ForumChannelBuilder extends TextChannelBuilder { - /// Type of channel - @override - // ignore: overridden_fields - ChannelType? type = ChannelType.forumChannel; - - /// The default sort order type used to order posts in GUILD_FORUM channels. - /// Defaults to null, which indicates a preferred sort order hasn't been set by a channel admin - ForumSortOrder? defaultSortOrder; - - /// The emoji to show in the add reaction button on a thread in a GUILD_FORUM channel - IEmoji? defaultReactionEmoji; - - /// Tags available to assign to forum posts - List? availableTags; - - @override - RawApiMap build() => { - ...super.build(), - if (defaultSortOrder != null) "default_sort_order": defaultSortOrder!.value, - if (defaultReactionEmoji != null) - "default_reaction_emoji": { - if (defaultReactionEmoji is UnicodeEmoji) "emoji_name": defaultReactionEmoji!.encodeForAPI(), - if (defaultReactionEmoji is BaseGuildEmoji) "emoji_id": (defaultReactionEmoji as BaseGuildEmoji).id - }, - if (availableTags != null) "available_tags": availableTags!.map((e) => e.build()).toList() - }; -} diff --git a/lib/src/utils/builders/embed_author_builder.dart b/lib/src/utils/builders/embed_author_builder.dart deleted file mode 100644 index d32fa68db..000000000 --- a/lib/src/utils/builders/embed_author_builder.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:nyxx/src/internal/exceptions/embed_builder_argument_exception.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -/// Build new instance of author which can be used in [EmbedBuilder] -class EmbedAuthorBuilder extends Builder { - /// Author name - String? name; - - /// Author url - String? url; - - /// Author icon url - String? iconUrl; - - /// Returns length of embeds author section - int? get length => name?.length; - - /// Create empty [EmbedAuthorBuilder] - EmbedAuthorBuilder({ - this.iconUrl, - this.name, - this.url, - }); - - /// Builds object to Map() instance; - @override - RawApiMap build() { - if (name == null || name!.isEmpty) { - throw EmbedBuilderArgumentException("Author name cannot be null or empty"); - } - - if (length! > 256) { - throw EmbedBuilderArgumentException("Author name is too long. (256 characters limit)"); - } - - return {"name": name, if (url != null) "url": url, if (iconUrl != null) "icon_url": iconUrl}; - } -} diff --git a/lib/src/utils/builders/embed_builder.dart b/lib/src/utils/builders/embed_builder.dart deleted file mode 100644 index d0ef933d6..000000000 --- a/lib/src/utils/builders/embed_builder.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:nyxx/src/core/discord_color.dart'; -import 'package:nyxx/src/internal/exceptions/embed_builder_argument_exception.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; -import 'package:nyxx/src/utils/builders/embed_author_builder.dart'; -import 'package:nyxx/src/utils/builders/embed_field_builder.dart'; -import 'package:nyxx/src/utils/builders/embed_footer_builder.dart'; - -/// Builds up embed object. -class EmbedBuilder extends Builder { - /// Embed title - String? title; - - /// Embed type - String? type; - - /// Embed description. - String? description; - - /// Url of Embed - String? url; - - /// Color code of the embed - DiscordColor? color; - - /// Timestamp of embed content - DateTime? timestamp; - - /// Embed Footer - EmbedFooterBuilder? footer; - - /// Image Url - String? imageUrl; - - /// Thumbnail Url - String? thumbnailUrl; - - /// Author of embed - EmbedAuthorBuilder? author; - - /// Embed custom fields; - late List fields; - - /// Creates clean instance [EmbedBuilder] - EmbedBuilder({ - this.author, - this.color, - this.description, - this.fields = const [], - this.footer, - this.imageUrl, - this.thumbnailUrl, - this.timestamp, - this.title, - this.type, - this.url, - }) { - fields = []; - } - - /// Adds author to embed. - void addAuthor(void Function(EmbedAuthorBuilder author) builder) { - author = EmbedAuthorBuilder(); - builder(author!); - } - - /// Adds footer to embed - void addFooter(void Function(EmbedFooterBuilder footer) builder) { - footer = EmbedFooterBuilder(); - builder(footer!); - } - - /// Adds field to embed. [name] and [content] fields are required. Inline is set to false by default. - void addField({dynamic name, dynamic content, bool inline = false, Function(EmbedFieldBuilder field)? builder, EmbedFieldBuilder? field}) { - fields.add(_constructEmbedFieldBuilder(name: name, content: content, builder: builder, field: field, inline: inline)); - } - - /// Replaces field where [name] witch provided new field. - void replaceField({dynamic name, dynamic content, bool inline = false, Function(EmbedFieldBuilder field)? builder, EmbedFieldBuilder? field}) { - final index = fields.indexWhere((element) => element.name == name); - fields[index] = _constructEmbedFieldBuilder(name: name, content: content, builder: builder, field: field, inline: inline); - } - - EmbedFieldBuilder _constructEmbedFieldBuilder( - {dynamic name, dynamic content, bool inline = false, Function(EmbedFieldBuilder field)? builder, EmbedFieldBuilder? field}) { - if (field != null) { - return field; - } - - if (builder != null) { - final tmp = EmbedFieldBuilder(); - builder(tmp); - return tmp; - } - - return EmbedFieldBuilder(name, content, inline); - } - - /// Total length of all text fields of embed - int get length => - (title?.length ?? 0) + - (description?.length ?? 0) + - (footer?.length ?? 0) + - (author?.length ?? 0) + - (fields.isEmpty ? 0 : fields.map((embed) => embed.length).reduce((f, s) => f + s)); - - @override - - /// Builds object to Map() instance; - RawApiMap build() { - if (title != null && title!.length > 256) { - throw EmbedBuilderArgumentException("Embed title is too long (256 characters limit)"); - } - - if (description != null && description!.length > 2048) { - throw EmbedBuilderArgumentException("Embed description is too long (2048 characters limit)"); - } - - if (fields.length > 25) { - throw EmbedBuilderArgumentException("Embed cannot contain more than 25 fields"); - } - - if (length > 6000) { - throw EmbedBuilderArgumentException("Total length of embed cannot exceed 6000 characters"); - } - - return { - if (title != null) "title": title, - if (type != null) "type": type, - if (description != null) "description": description, - if (url != null) "url": url, - if (timestamp != null) "timestamp": timestamp!.toUtc().toIso8601String(), - if (color != null) "color": color!.value, - if (footer != null) "footer": footer!.build(), - if (imageUrl != null) "image": {"url": imageUrl}, - if (thumbnailUrl != null) "thumbnail": {"url": thumbnailUrl}, - if (author != null) "author": author!.build(), - if (fields.isNotEmpty) "fields": fields.map((builder) => builder.build()).toList() - }; - } -} diff --git a/lib/src/utils/builders/embed_field_builder.dart b/lib/src/utils/builders/embed_field_builder.dart deleted file mode 100644 index dec0faffa..000000000 --- a/lib/src/utils/builders/embed_field_builder.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:nyxx/src/internal/exceptions/embed_builder_argument_exception.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -/// Builder for embed Field. -class EmbedFieldBuilder extends Builder { - /// Field name/title - Object? name; - - /// Field content - Object? content; - - /// Whether or not this field should display inline - bool? inline; - - /// Constructs new instance of Field - EmbedFieldBuilder([this.name, this.content, this.inline]); - - /// Constructs new instance of Field - EmbedFieldBuilder.named({this.name, this.content, this.inline}); - - /// Length of current field - int get length => name.toString().length + content.toString().length; - - @override - - /// Builds object to Map() instance; - RawApiMap build() { - if (name.toString().length > 256) { - throw EmbedBuilderArgumentException("Field name is too long. (256 characters limit)"); - } - - if (content.toString().length > 1024) { - throw EmbedBuilderArgumentException("Field content is too long. (1024 characters limit)"); - } - - return { - "name": name != null ? name.toString() : "\u200B", - "value": content != null ? content.toString() : "\u200B", - "inline": inline ?? false, - }; - } -} diff --git a/lib/src/utils/builders/embed_footer_builder.dart b/lib/src/utils/builders/embed_footer_builder.dart deleted file mode 100644 index 80b4d315f..000000000 --- a/lib/src/utils/builders/embed_footer_builder.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:nyxx/src/internal/exceptions/embed_builder_argument_exception.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -/// Build new instance of Embed's footer -class EmbedFooterBuilder extends Builder { - /// Footer text - String? text; - - /// Url of footer icon. Supports only http(s) for now - String? iconUrl; - - /// Length of footer - int? get length => text?.length; - - /// Create empty [EmbedFooterBuilder] - EmbedFooterBuilder({this.iconUrl, this.text}); - - @override - - /// Builds object to Map() instance; - RawApiMap build() { - if (text != null && length! > 2048) { - throw EmbedBuilderArgumentException("Footer text is too long. (1024 characters limit)"); - } - - return {if (text != null) "text": text, if (iconUrl != null) "icon_url": iconUrl}; - } -} diff --git a/lib/src/utils/builders/forum_thread_builder.dart b/lib/src/utils/builders/forum_thread_builder.dart deleted file mode 100644 index a8a6e9b43..000000000 --- a/lib/src/utils/builders/forum_thread_builder.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:nyxx/src/core/channel/guild/forum/forum_tag.dart'; -import 'package:nyxx/src/core/message/emoji.dart'; -import 'package:nyxx/src/core/message/guild_emoji.dart'; -import 'package:nyxx/src/core/message/unicode_emoji.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; -import 'package:nyxx/src/utils/builders/thread_builder.dart'; - -class ForumTagBuilder { - Snowflake id; - - ForumTagBuilder(this.id); - - factory ForumTagBuilder.fromForumTag(ForumTag forumTag) => ForumTagBuilder(forumTag.id); -} - -class AvailableTagBuilder implements Builder { - /// The name of the tag (0-20 characters) - String name; - - /// Whether this tag can only be added to or removed from threads by a member with the MANAGE_THREADS permission - bool moderated; - - /// Emoji for tag - IEmoji? emoji; - - AvailableTagBuilder(this.name, this.moderated); - - @override - RawApiMap build() => { - "name": name, - "moderated": moderated, - if (emoji is UnicodeEmoji) "emoji_name": emoji!.encodeForAPI(), - if (emoji is BaseGuildEmoji) "emoji_name": (emoji as BaseGuildEmoji).id - }; -} - -class ForumThreadBuilder implements Builder { - /// The name of the thread - String name; - - /// First message in thread - MessageBuilder? message; - - /// Amount of seconds a user has to wait before sending another message (0-21600); - /// bots, as well as users with the permission manage_messages, manage_thread, or manage_channel, are unaffected - int? rateLimitPerUser; - - /// The time after which the thread is automatically archived. - ThreadArchiveTime? archiveAfter; - - /// Set of tags that have been applied to a thread - Iterable? appliedTags; - - ForumThreadBuilder( - this.name, { - this.message, - this.appliedTags, - this.archiveAfter, - this.rateLimitPerUser, - }); - - @override - RawApiMap build() { - return { - "name": name, - if (message != null) "message": message!.build(), - if (archiveAfter != null) "auto_archive_duration": archiveAfter!.value, - if (rateLimitPerUser != null) 'rate_limit_per_user': rateLimitPerUser!, - if (appliedTags != null) 'applied_tags': appliedTags!.map((e) => e.id.toString()).toList() - }; - } -} diff --git a/lib/src/utils/builders/guild_builder.dart b/lib/src/utils/builders/guild_builder.dart deleted file mode 100644 index caad5be79..000000000 --- a/lib/src/utils/builders/guild_builder.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:nyxx/src/core/discord_color.dart'; -import 'package:nyxx/src/core/guild/system_channel_flags.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; -import 'package:nyxx/src/utils/builders/channel_builder.dart'; -import 'package:nyxx/src/utils/builders/permissions_builder.dart'; - -/// Allows to build guild object for creating new one or modifying existing -class GuildBuilder extends Builder { - /// Name of Guild - final String name; - - /// The 128x128 icon for the guild - AttachmentBuilder? icon; - - /// Verification level - int? verificationLevel; - - /// Default message notification level - int? defaultMessageNotifications; - - /// Explicit content filter level - int? explicitContentFilter; - - /// List of roles to create at guild creation. - /// When using this parameter, the first member of the list is the `@everyone` role - So all the permissions that you give to this role will be applied to all the members of the guild. - List? roles; - - /// List of channel to create at guild creation - /// When using this field, the `position` field of the channel is ignored. - /// And none of the default channels are created. - List? channels; - - /// The channel id to use for the afk channel - /// The id provided sould be the same of a given id in [channels]. - Snowflake? afkChannelId; - - /// The afk timeout in seconds - int? afkTimeout; - - /// The id of the system channel - /// The id provided sould be the same of a given id in [channels]. - Snowflake? systemChannelId; - - /// The [SystemChannelFlags] to apply - SystemChannelFlags? systemChannelFlags; - - /// Create new instance of [GuildBuilder] - GuildBuilder( - this.name, { - this.afkChannelId, - this.afkTimeout, - this.channels, - this.defaultMessageNotifications, - this.explicitContentFilter, - this.icon, - this.roles, - this.systemChannelFlags, - this.systemChannelId, - this.verificationLevel, - }); - - @override - RawApiMap build() => { - "name": name, - if (icon != null) "icon": icon!.getBase64(), - if (verificationLevel != null) "verification_level": verificationLevel, - if (defaultMessageNotifications != null) "default_message_notifications": defaultMessageNotifications, - if (explicitContentFilter != null) "explicit_content_filter": explicitContentFilter, - if (roles != null) "roles": _genIterable(roles!).toList(), - if (channels != null) "channels": _genIterable(channels!).toList(), - if (afkChannelId != null) "afk_channel_id": afkChannelId!.toString(), - if (afkTimeout != null) "afk_timeout": afkTimeout, - if (systemChannelId != null) "system_channel_id": systemChannelId.toString(), - if (systemChannelFlags != null) "system_channel_flags": systemChannelFlags!.value, - }; - - Iterable _genIterable(List list) sync* { - for (final e in list) { - yield e.build(); - } - } -} - -/// Creates role -class RoleBuilder extends Builder { - /// Name of role - String name; - - /// When using the `roles` parameter in [GuildBuilder], this field is required. It is a [Snowflake] placeholder for the role and will be replaced by the API consumption. - /// - /// Its purpose is to allow overwrite a role's permission in a channel when also passing the `channels` list. - Snowflake? id; - - /// Integer representation of hexadecimal color code - DiscordColor? color; - - /// If this role is pinned in the user listing - bool? hoist; - - /// Position of role - int? position; - - /// Permission object for role - PermissionsBuilder? permission; - - /// Whether role is mentionable - bool? mentionable; - - /// Role icon attachment - AttachmentBuilder? roleIcon; - - /// Role icon emoji - String? roleIconEmoji; - - /// Creates role - RoleBuilder( - this.name, { - this.color, - this.hoist, - this.id, - this.mentionable, - this.permission, - this.position, - this.roleIcon, - this.roleIconEmoji, - }); - - @override - RawApiMap build() => { - "name": name, - if (color != null) "color": color!.value, - if (hoist != null) "hoist": hoist, - if (position != null) "position": position, - if (permission != null) "permissions": permission!.calculatePermissionValue().toString(), - if (mentionable != null) "mentionable": mentionable, - if (roleIcon != null) "icon": roleIcon!.getBase64(), - if (roleIconEmoji != null) "unicode_emoji": roleIconEmoji, - if (id != null) "id": id!.id, - }; -} diff --git a/lib/src/utils/builders/guild_event_builder.dart b/lib/src/utils/builders/guild_event_builder.dart deleted file mode 100644 index 1704eab42..000000000 --- a/lib/src/utils/builders/guild_event_builder.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:nyxx/src/core/guild/scheduled_event.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -class GuildEventBuilder implements Builder { - /// The channel id of the scheduled event, set to null if changing entity type to EXTERNAL - Snowflake? channelId = Snowflake.zero(); - - /// The entity metadata of the scheduled event - EntityMetadataBuilder? metadata; - - /// The name of the scheduled event - String? name; - - /// The privacy level of the scheduled event - GuildEventPrivacyLevel? privacyLevel; - - /// The time to schedule the scheduled event - DateTime? startDate; - - /// The time when the scheduled event is scheduled to end - DateTime? endDate; - - /// The description of the scheduled event - String? description; - - /// The entity type of the scheduled event - GuildEventType? type; - - /// The status of the scheduled event - GuildEventStatus? status; - - GuildEventBuilder({ - this.channelId, - this.description, - this.endDate, - this.metadata, - this.name, - this.privacyLevel, - this.startDate, - this.status, - this.type, - }); - - @override - RawApiMap build() => { - if (channelId?.id != 0) "channel_id": channelId.toString(), - if (metadata != null) 'entity_metadata': metadata!.build(), - if (name != null) 'name': name, - if (privacyLevel != null) 'privacy_level': privacyLevel!.value, - if (startDate != null) 'scheduled_start_time': startDate!.toIso8601String(), - if (endDate != null) 'scheduled_end_time': endDate!.toIso8601String(), - if (description != null) 'description': description, - if (type != null) 'entity_type': type!.value, - if (status != null) 'status': status!.value - }; -} - -class EntityMetadataBuilder implements Builder { - String? location; - - EntityMetadataBuilder(this.location); - - @override - RawApiMap build() => {if (location != null) 'location': location}; -} diff --git a/lib/src/utils/builders/member_builder.dart b/lib/src/utils/builders/member_builder.dart deleted file mode 100644 index 18507de2a..000000000 --- a/lib/src/utils/builders/member_builder.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -class MemberBuilder implements Builder { - /// Value to set user's nickname to - String? nick; - - /// Array of role ids the member is assigned - List? roles; - - /// Whether the user is muted in voice channels. - bool? mute; - - /// Whether the user is deafened in voice channels. - bool? deaf; - - /// Id of channel to move user to (if they are connected to voice) - Snowflake? channel = Snowflake.zero(); - - /// When the user's timeout will expire and the user will be able to communicate in the guild again (up to 28 days in the future), set to null to remove timeout - DateTime? timeoutUntil = DateTime.fromMillisecondsSinceEpoch(0); - - /// The [flags](https://discord.com/developers/docs/resources/guild#guild-member-object-guild-member-flags) to add/remove from the member. - MemberFlagsBuilder? flags; - - MemberBuilder({ - this.channel = const Snowflake.zero(), - this.deaf, - this.mute, - this.nick, - this.roles, - DateTime? timeoutUntil, - this.flags, - }) : timeoutUntil = timeoutUntil ?? DateTime.fromMicrosecondsSinceEpoch(0); - - @override - RawApiMap build() => { - if (nick != null) 'nick': nick, - if (roles != null) 'roles': roles!.map((e) => e.toString()).toList(), - if (mute != null) 'mute': mute, - if (deaf != null) 'deaf': deaf, - if (channel != Snowflake.zero()) 'channel_id': channel?.toString(), - if (timeoutUntil?.millisecondsSinceEpoch != 0) 'communication_disabled_until': timeoutUntil?.toIso8601String(), - if (flags != null) 'flags': flags!.toBitField(), - }; -} - -/// Flags that can be applied or removed from a member. -class MemberFlagsBuilder { - final bool bypassesVerification; - final bool startedOnBoarding; - - const MemberFlagsBuilder({ - this.bypassesVerification = false, - this.startedOnBoarding = false, - }); - - int toBitField() { - var bitField = 0; - - if (bypassesVerification) { - bitField |= 1 << 2; - } - - if (startedOnBoarding) { - bitField |= 1 << 3; - } - - return bitField; - } - - @override - String toString() => 'PatchableMemberFlags(bypassesVerification: $bypassesVerification, startedOnBoarding: $startedOnBoarding)'; -} diff --git a/lib/src/utils/builders/message_builder.dart b/lib/src/utils/builders/message_builder.dart deleted file mode 100644 index 0cbb06c0c..000000000 --- a/lib/src/utils/builders/message_builder.dart +++ /dev/null @@ -1,271 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:http/http.dart' as http; -import 'package:nyxx/src/core/allowed_mentions.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/core/message/message_time_stamp.dart'; -import 'package:nyxx/src/internal/interfaces/send.dart'; -import 'package:nyxx/src/internal/interfaces/mentionable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; -import 'package:nyxx/src/utils/enum.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; -import 'package:nyxx/src/utils/builders/embed_builder.dart'; -import 'package:nyxx/src/utils/builders/reply_builder.dart'; - -class MessageFlagBuilder implements Builder { - bool suppressNotifications = false; - bool suppressEmbeds = false; - - @override - RawApiMap build() { - var bitset = 0; - - if (suppressNotifications) { - bitset |= (1 << 12); - } - - if (suppressEmbeds) { - bitset |= (1 << 2); - } - - return {if (bitset > 0) "flags": bitset}; - } -} - -/// Allows to create pre built custom messages which can be passed to classes which inherits from [ISend]. -class MessageBuilder { - /// Clear character which can be used to skip first line in message body or sanitize message content - static const clearCharacter = "‎"; - - /// Set to true if message should be TTS - bool? tts; - - /// List of files to send with message - List? files; - - /// Allows to create message that replies to another message - ReplyBuilder? replyBuilder; - - /// Embed to include in message - List? embeds; - - /// [AllowedMentions] object to control mentions in message - AllowedMentions? allowedMentions; - - /// A nonce that can be used for optimistic message sending (up to 25 characters) - /// You will be able to identify that message when receiving it through gateway - String? nonce; - - /// List of attachments to send with message - List? attachments; - - /// Flags to attach to message - MessageFlagBuilder? flags; - - final _content = StringBuffer(); - - /// Clears current content of message and sets new - set content(Object? content) { - _content.clear(); - _content.write(content); - } - - /// Returns current content of message - String get content => _content.toString(); - - /// Generic constructor for [MessageBuilder] - MessageBuilder({ - String? content = '', - this.allowedMentions, - this.attachments, - this.embeds, - this.files, - this.nonce, - this.replyBuilder, - this.tts, - }) { - this.content = content; - } - - /// Creates [MessageBuilder] with only content - factory MessageBuilder.content(String content) => MessageBuilder()..content = content; - - /// Creates [MessageBuilder] with content of empty character - factory MessageBuilder.empty() => MessageBuilder()..appendClearCharacter(); - - /// Creates [MessageBuilder] with only embed - factory MessageBuilder.embed(EmbedBuilder embed) => MessageBuilder()..embeds = [embed]; - - /// Creates [MessageBuilder] with only specified files - factory MessageBuilder.files(List files) => MessageBuilder()..files = files; - - /// Creates [MessageBuilder] from [Message]. - /// Copies content, tts and first embed of target [message] - factory MessageBuilder.fromMessage(IMessage message) => MessageBuilder() - ..content = message.content - ..tts = message.tts - ..embeds = message.embeds.map((e) => e.toBuilder()).toList() - ..replyBuilder = message.referencedMessage?.toBuilder(); - - /// Allows to add embed to message. - /// Warning: Completes future synchronously! - FutureOr addEmbed(FutureOr Function(EmbedBuilder embed) builder) async { - embeds ??= []; - - final e = EmbedBuilder(); - await builder(e); - embeds!.add(e); - } - - /// Appends clear character. Can be used to skip first line in message body. - void appendClearCharacter() => _content.write(clearCharacter); - - /// Appends empty line to message - void appendNewLine() => _content.writeln(); - - /// Allows to append - void append(Object text) => _content.write(text); - - /// Appends spoiler to message - void appendSpoiler(Object text) => appendWithDecoration(text, MessageDecoration.spoiler); - - /// Appends italic text to message - void appendItalics(Object text) => appendWithDecoration(text, MessageDecoration.italics); - - // TODO: bug: when placed next to italics additional space should be generated - /// Appends bold text to message - void appendBold(Object text) => appendWithDecoration(text, MessageDecoration.bold); - - /// Appends strikeout text to message - void appendStrike(Object text) => appendWithDecoration(text, MessageDecoration.strike); - - /// Appends simple code to message - void appendCodeSimple(Object text) => appendWithDecoration(text, MessageDecoration.codeSimple); - - /// Appends code block to message - void appendCode(Object language, Object code) { - appendNewLine(); - appendWithDecoration("$language\n$code", MessageDecoration.codeLong); - } - - /// Appends formatted text to message - void appendWithDecoration(Object text, MessageDecoration decoration) { - _content.write("$decoration$text$decoration"); - } - - /// Appends [Mentionable] object to message - void appendMention(Mentionable mentionable) => append(mentionable.mention); - - /// Appends timestamp to message from [dateTime] - void appendTimestamp(DateTime dateTime, {TimeStampStyle style = TimeStampStyle.def}) => append(style.format(dateTime)); - - /// Limits the length of the content of the builder to [length]. - /// - /// If [content] is shorter than [length], this method does nothing. Else, it truncates content and appends [ellipsis] (if non-null) in a way that the new - /// content length equals [length]. - void limitLength({int length = 2000, String? ellipsis = '...'}) { - if (_content.length < length) { - return; - } - - ellipsis ??= ''; - - final cutContent = content.substring(0, length - ellipsis.length); - content = cutContent + ellipsis; - } - - /// Add attachment - void addAttachment(AttachmentBuilder attachment) { - files ??= []; - - files!.add(attachment); - } - - /// Add attachment from specified file - void addFileAttachment(File file, {String? name, bool spoiler = false}) { - addAttachment(AttachmentBuilder.file(file, name: name, spoiler: spoiler)); - } - - /// Add attachment from specified bytes - void addBytesAttachment(List bytes, String name, {bool spoiler = false}) { - addAttachment(AttachmentBuilder.bytes(bytes, name, spoiler: spoiler)); - } - - /// Add attachment at specified path - void addPathAttachment(String path, {String? name, bool spoiler = false}) { - addAttachment(AttachmentBuilder.path(path, name: name, spoiler: spoiler)); - } - - /// Sends message - Future send(ISend entity) => entity.sendMessage(this); - - /// Returns if this instance of message builder can be used when editing message - bool canBeUsedAsNewMessage() => content.isNotEmpty || (embeds != null && embeds!.isNotEmpty) || (files != null && files!.isNotEmpty); - - RawApiMap build([AllowedMentions? defaultAllowedMentions]) { - allowedMentions ??= defaultAllowedMentions; - - return { - ...?flags?.build(), - "content": content.toString(), - if (embeds != null) "embeds": [for (final e in embeds!) e.build()], - if (allowedMentions != null) "allowed_mentions": allowedMentions!.build(), - if (replyBuilder != null) "message_reference": replyBuilder!.build(), - if (tts != null) "tts": tts, - if (nonce != null) "nonce": nonce, - if (attachments != null) "attachments": [for (final attachmentBuilder in attachments!) attachmentBuilder.build()], - }; - } - - bool hasFiles() => files != null && files!.isNotEmpty; - - Iterable getMappedFiles() { - if (!hasFiles()) { - return []; - } - - return mapMessageBuilderAttachments(files!); - } -} - -/// Specifies formatting of String appended with [MessageBuilder] -class MessageDecoration extends IEnum { - /// Italic text is surrounded with `*` - static const MessageDecoration italics = MessageDecoration._new("*"); - - /// Bold text is surrounded with `**` - static const MessageDecoration bold = MessageDecoration._new("**"); - - /// Spoiler text is surrounded with `||`. In discord client will render as clickable box to reveal text. - static const MessageDecoration spoiler = MessageDecoration._new("||"); - - /// Strike text is surrounded with `~~` - static const MessageDecoration strike = MessageDecoration._new("~~"); - - /// Inline code text is surrounded with `` ` `` - static const MessageDecoration codeSimple = MessageDecoration._new("`"); - - /// Multiline code block is surrounded with `` ``` `` - static const MessageDecoration codeLong = MessageDecoration._new("```"); - - /// Underlined text is surrounded with `__` - static const MessageDecoration underline = MessageDecoration._new("__"); - - const MessageDecoration._new(String value) : super(value); - - @override - String toString() => value; - - /// Creates formatted string - String format(String text) => "$value$text$value"; -} - -Iterable mapMessageBuilderAttachments(List files) sync* { - for (var i = 0; i < files.length; i++) { - final file = files[i]; - - yield file.getMultipartFile(i); - } -} diff --git a/lib/src/utils/builders/permissions_builder.dart b/lib/src/utils/builders/permissions_builder.dart deleted file mode 100644 index d5d392112..000000000 --- a/lib/src/utils/builders/permissions_builder.dart +++ /dev/null @@ -1,330 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/permissions/permissions.dart'; - -/// Set of permissions ints -class _PermissionsSet extends Builder { - int allow = 0; - int deny = 0; - - @override - RawApiMap build() => {"allow": allow, "deny": deny}; -} - -/// Builder for manipulating [PermissionsOverrides]. Created from existing override or manually by passing [type] and [id] of enttiy. -class PermissionOverrideBuilder extends PermissionsBuilder { - /// Type of permission override either `role` or `member` - final int type; - - /// Id of entity of permission override. - final Snowflake id; - - /// Create builder manually from known data. Id is id of entity. [type] can be either 0 for `role` or 1 for `member`. - PermissionOverrideBuilder.from(this.type, this.id, Permissions permissions) : super.from(permissions); - - /// Create empty permission builder. - PermissionOverrideBuilder(this.type, this.id) : super(); - - /// Create [PermissionsOverrides] for given [entity]. Entity have to be either [Role] or [Member] - PermissionOverrideBuilder.of(SnowflakeEntity entity) - : type = entity is IRole ? 0 : 1, - id = entity.id, - super(); - - @override - RawApiMap build() => { - ...super.build(), - "id": id.toString(), - "type": type, - }; -} - -/// Builder for permissions. -class PermissionsBuilder extends Builder { - /// The raw permission code. - int? raw; - - /// True if user can create InstantInvite. - bool? createInstantInvite; - - /// True if user can kick members. - bool? kickMembers; - - /// True if user can ban members. - bool? banMembers; - - /// True if user is administrator. - bool? administrator; - - /// True if user can manager channels. - bool? manageChannels; - - /// True if user can manager guilds. - bool? manageGuild; - - /// Allows to add reactions. - bool? addReactions; - - /// Allows for using priority speaker in a voice channel. - bool? prioritySpeaker; - - /// Allow to view audit logs. - bool? viewAuditLog; - - /// Allow viewing channels (OLD READ_MESSAGES) - bool? viewChannel; - - /// True if user can send messages. - bool? sendMessages; - - /// True if user can send messages in threads. - bool? sendMessagesInThreads; - - /// True if user can send TTF messages. - bool? sendTtsMessages; - - /// True if user can manage messages. - bool? manageMessages; - - /// True if user can send links in messages. - bool? embedLinks; - - /// True if user can attach files in messages. - bool? attachFiles; - - /// True if user can read messages history. - bool? readMessageHistory; - - /// True if user can mention everyone. - bool? mentionEveryone; - - /// True if user can use external emojis. - bool? useExternalEmojis; - - /// Allows the usage of custom stickers from other servers. - bool? useExternalStickers; - - /// Allows members to use application commands, including slash commands and context menu commands. - bool? useSlashCommands; - - /// True if user can connect to voice channel. - bool? connect; - - /// True if user can speak. - bool? speak; - - /// True if user can mute members. - bool? muteMembers; - - /// True if user can deafen members. - bool? deafenMembers; - - /// True if user can move members. - bool? moveMembers; - - /// Allows for using voice-activity-detection in a voice channel. - bool? useVad; - - /// True if user can change nick. - bool? changeNickname; - - /// True if user can manager others nicknames. - bool? manageNicknames; - - /// True if user can manage server's roles. - bool? manageRoles; - - /// True if user can manage webhooks. - bool? manageWebhooks; - - /// Allows management and editing of emojis & stickers. - bool? manageEmojisAndStickers; - - /// Allows for requesting to speak in stage channels. (This permission is under active development and may be changed or removed.). - bool? requestToSpeak; - - /// Allows the user to go live. - bool? stream; - - /// Allows for viewing guild insights. - bool? viewGuildInsights; - - /// Allows for deleting and archiving threads, and viewing all private threads. - bool? manageThreads; - - /// Allows for creating and participating in threads. - bool? createPublicThreads; - - /// Allows for creating and participating in private threads. - bool? createPrivateThreads; - - /// Allows for creating, editing, and deleting scheduled events. - bool? manageEvents; - - /// Allows for timing out users to prevent them from sending or reacting to messages in chat and threads, and from speaking in voice and stage channels. - bool? moderateMembers; - - PermissionsBuilder({ - this.addReactions, - this.administrator, - this.attachFiles, - this.banMembers, - this.changeNickname, - this.connect, - this.createInstantInvite, - this.createPrivateThreads, - this.createPublicThreads, - this.deafenMembers, - this.embedLinks, - this.kickMembers, - this.manageChannels, - this.manageEmojisAndStickers, - this.manageEvents, - this.manageGuild, - this.manageMessages, - this.manageNicknames, - this.manageRoles, - this.manageThreads, - this.manageWebhooks, - this.mentionEveryone, - this.moderateMembers, - this.moveMembers, - this.muteMembers, - this.prioritySpeaker, - this.readMessageHistory, - this.sendMessages, - this.requestToSpeak, - this.sendMessagesInThreads, - this.sendTtsMessages, - this.speak, - this.stream, - this.useExternalEmojis, - this.useExternalStickers, - this.useSlashCommands, - this.useVad, - this.viewAuditLog, - this.viewChannel, - this.viewGuildInsights, - }); - - /// Permission builder from existing [Permissions] object. - PermissionsBuilder.from(Permissions permissions) { - this - ..createInstantInvite = permissions.createInstantInvite - ..kickMembers = permissions.kickMembers - ..banMembers = permissions.banMembers - ..administrator = permissions.administrator - ..manageChannels = permissions.manageChannels - ..manageGuild = permissions.manageGuild - ..addReactions = permissions.addReactions - ..viewAuditLog = permissions.viewAuditLog - ..viewChannel = permissions.viewChannel - ..sendMessages = permissions.sendMessages - ..sendMessagesInThreads = permissions.sendMessagesInThreads - ..prioritySpeaker = permissions.prioritySpeaker - ..sendTtsMessages = permissions.sendTtsMessages - ..manageMessages = permissions.manageMessages - ..embedLinks = permissions.embedLinks - ..attachFiles = permissions.attachFiles - ..readMessageHistory = permissions.readMessageHistory - ..mentionEveryone = permissions.mentionEveryone - ..useExternalEmojis = permissions.useExternalEmojis - ..connect = permissions.connect - ..speak = permissions.speak - ..muteMembers = permissions.muteMembers - ..deafenMembers = permissions.deafenMembers - ..moveMembers = permissions.moveMembers - ..useVad = permissions.useVad - ..changeNickname = permissions.changeNickname - ..manageNicknames = permissions.manageNicknames - ..manageRoles = permissions.manageRoles - ..manageWebhooks = permissions.manageWebhooks - ..manageEmojisAndStickers = permissions.manageEmojisAndStickers - ..stream = permissions.stream - ..viewGuildInsights = permissions.viewGuildInsights - ..manageThreads = permissions.manageThreads - ..createPublicThreads = permissions.createPublicThreads - ..createPrivateThreads = permissions.createPrivateThreads - ..moderateMembers = permissions.moderateMembers - ..useSlashCommands = permissions.useSlashCommands - ..requestToSpeak = permissions.requestToSpeak - ..manageEvents = permissions.manageEvents - ..useExternalStickers = permissions.useExternalStickers; - } - - /// Calculates permission int. - int calculatePermissionValue() { - final set = _calculatePermissionSet(); - - return set.allow & ~set.deny; - } - - _PermissionsSet _calculatePermissionSet() { - final permissionSet = _PermissionsSet(); - - _apply(permissionSet, createInstantInvite, PermissionsConstants.createInstantInvite); - _apply(permissionSet, kickMembers, PermissionsConstants.kickMembers); - _apply(permissionSet, banMembers, PermissionsConstants.banMembers); - _apply(permissionSet, administrator, PermissionsConstants.administrator); - _apply(permissionSet, manageChannels, PermissionsConstants.manageChannels); - _apply(permissionSet, addReactions, PermissionsConstants.addReactions); - _apply(permissionSet, viewAuditLog, PermissionsConstants.viewAuditLog); - _apply(permissionSet, viewChannel, PermissionsConstants.viewChannel); - _apply(permissionSet, manageGuild, PermissionsConstants.manageGuild); - _apply(permissionSet, sendMessages, PermissionsConstants.sendMessages); - _apply(permissionSet, sendTtsMessages, PermissionsConstants.sendTtsMessages); - _apply(permissionSet, manageMessages, PermissionsConstants.manageMessages); - _apply(permissionSet, embedLinks, PermissionsConstants.embedLinks); - _apply(permissionSet, attachFiles, PermissionsConstants.attachFiles); - _apply(permissionSet, readMessageHistory, PermissionsConstants.readMessageHistory); - _apply(permissionSet, mentionEveryone, PermissionsConstants.mentionEveryone); - _apply(permissionSet, useExternalEmojis, PermissionsConstants.useExternalEmojis); - _apply(permissionSet, connect, PermissionsConstants.connect); - _apply(permissionSet, speak, PermissionsConstants.speak); - _apply(permissionSet, muteMembers, PermissionsConstants.muteMembers); - _apply(permissionSet, deafenMembers, PermissionsConstants.deafenMembers); - _apply(permissionSet, moveMembers, PermissionsConstants.moveMembers); - _apply(permissionSet, useVad, PermissionsConstants.useVad); - _apply(permissionSet, changeNickname, PermissionsConstants.changeNickname); - _apply(permissionSet, manageNicknames, PermissionsConstants.manageNicknames); - _apply(permissionSet, manageRoles, PermissionsConstants.manageRoles); - _apply(permissionSet, manageWebhooks, PermissionsConstants.manageWebhooks); - _apply(permissionSet, viewGuildInsights, PermissionsConstants.viewGuildInsights); - _apply(permissionSet, stream, PermissionsConstants.stream); - _apply(permissionSet, manageEmojisAndStickers, PermissionsConstants.manageEmojisAndStickers); - _apply(permissionSet, manageThreads, PermissionsConstants.manageThreads); - _apply(permissionSet, createPublicThreads, PermissionsConstants.createPublicThreads); - _apply(permissionSet, createPrivateThreads, PermissionsConstants.createPrivateThreads); - _apply(permissionSet, moderateMembers, PermissionsConstants.moderateMembers); - _apply(permissionSet, useExternalStickers, PermissionsConstants.useExternalStickers); - _apply(permissionSet, useSlashCommands, PermissionsConstants.useSlashCommands); - _apply(permissionSet, manageEvents, PermissionsConstants.manageEvents); - _apply(permissionSet, requestToSpeak, PermissionsConstants.requestToSpeak); - _apply(permissionSet, prioritySpeaker, PermissionsConstants.prioritySpeaker); - _apply(permissionSet, sendMessagesInThreads, PermissionsConstants.sendMessagesInThreads); - - return permissionSet; - } - - @override - RawApiMap build() { - _PermissionsSet permissionSet = _calculatePermissionSet(); - - return { - "allow": permissionSet.allow.toString(), - "deny": permissionSet.deny.toString(), - }; - } - - void _apply(_PermissionsSet perm, bool? applies, int constant) { - if (applies == null) { - return; - } - - if (applies) { - perm.allow |= constant; - } else { - perm.deny |= constant; - } - } -} diff --git a/lib/src/utils/builders/presence_builder.dart b/lib/src/utils/builders/presence_builder.dart deleted file mode 100644 index a664605c3..000000000 --- a/lib/src/utils/builders/presence_builder.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:nyxx/src/core/guild/status.dart'; -import 'package:nyxx/src/core/user/presence.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -/// Allows to change status and presence of bot -class ActivityBuilder implements Builder { - /// The activity name. - late final String name; - - /// The activity type. - late final ActivityType type; - - /// The game URL, if provided. - String? url; - - /// Creates new instance of [ActivityBuilder] - ActivityBuilder(this.name, this.type, {this.url}); - - /// Sets activity to game - factory ActivityBuilder.game(String name) => ActivityBuilder(name, ActivityType.game); - - /// Sets activity to streaming - factory ActivityBuilder.streaming(String name, String url) => ActivityBuilder(name, ActivityType.streaming, url: url); - - /// Sets activity to listening - factory ActivityBuilder.listening(String name) => ActivityBuilder(name, ActivityType.listening); - - /// Sets activity to watching - factory ActivityBuilder.watching(String name) => ActivityBuilder(name, ActivityType.watching); - - @override - RawApiMap build() => { - "name": name, - "type": type.value, - if (type == ActivityType.streaming) "url": url, - }; -} - -/// Allows to build object of user presence used later when setting user presence. -class PresenceBuilder extends Builder { - /// Status of user. - UserStatus? status; - - /// If is afk - bool? afk; - - /// Type of activity. - ActivityBuilder? activity; - - /// WHen activity was started - DateTime? since; - - /// Empty constructor to when setting all values manually. - PresenceBuilder(); - - /// Default builder constructor. - factory PresenceBuilder.of({UserStatus? status, ActivityBuilder? activity}) => PresenceBuilder() - ..status = status - ..activity = activity; - - /// Sets client status to idle. [since] indicates how long client is afking - factory PresenceBuilder.idle({required DateTime since}) => PresenceBuilder() - ..since = since - ..afk = true - ..status = UserStatus.idle; - - @override - RawApiMap build() => { - "status": (status != null) ? status.toString() : UserStatus.online.toString(), - "afk": (afk != null) ? afk : false, - if (activity != null) - "activities": [ - activity!.build(), - ], - "since": (since != null) ? since!.millisecondsSinceEpoch : null - }; -} diff --git a/lib/src/utils/builders/reply_builder.dart b/lib/src/utils/builders/reply_builder.dart deleted file mode 100644 index a6e0197f5..000000000 --- a/lib/src/utils/builders/reply_builder.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/message/message.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -/// Builder for replying to message -class ReplyBuilder extends Builder { - /// Id of message you reply to - final Snowflake messageId; - - /// True if reply should fail if target message does not exist - final bool failIfNotExists; - - /// Constructs reply builder for given message in channel - ReplyBuilder(this.messageId, [this.failIfNotExists = false]); - - /// Constructs message reply from given message - factory ReplyBuilder.fromMessage(IMessage message, [bool failIfNotExists = false]) => ReplyBuilder(message.id, failIfNotExists); - - /// Constructs message reply from cacheable of message and channel - factory ReplyBuilder.fromCacheable(Cacheable messageCacheable, [bool failIfNotExists = false]) => - ReplyBuilder(messageCacheable.id, failIfNotExists); - - @override - RawApiMap build() => {"message_id": messageId.id.toString(), "fail_if_not_exists": failIfNotExists}; -} diff --git a/lib/src/utils/builders/sticker_builder.dart b/lib/src/utils/builders/sticker_builder.dart deleted file mode 100644 index 47aef360f..000000000 --- a/lib/src/utils/builders/sticker_builder.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -/// Create a new sticker for the guild -class StickerBuilder implements Builder { - /// Name of the sticker (2-30 characters) - late String name; - - /// Description of the sticker (empty or 2-100 characters) - late String description; - - /// The Discord name of a unicode emoji representing the sticker's expression (2-200 characters) - late String tags; - - /// File that Sticker should be added to sticker - late final AttachmentBuilder file; - - StickerBuilder({this.description = '', required this.file, this.name = '', this.tags = ''}); - - @override - RawApiMap build() => { - "name": name, - "description": description, - "tags": tags, - 'file': file.getBase64(), - }; -} diff --git a/lib/src/utils/builders/thread_builder.dart b/lib/src/utils/builders/thread_builder.dart deleted file mode 100644 index d3100fc1f..000000000 --- a/lib/src/utils/builders/thread_builder.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:nyxx/src/typedefs.dart'; -import 'package:nyxx/src/utils/enum.dart'; -import 'package:nyxx/src/utils/builders/builder.dart'; - -class ThreadBuilder extends Builder { - /// The name for the thread - String? name; - - /// Whether or not the thread is private - bool? private; - - /// Whether the thread is archived - bool? archived; - - /// Whether the thread is locked; when a thread is locked, only users with MANAGE_THREADS can unarchive it - bool? locked; - - /// Whether non-moderators can add other non-moderators to a thread; only available on private threads - bool? invitable; - - /// Amount of seconds a user has to wait before sending another message (0-21600); - /// bots, as well as users with the permission manage_messages, manage_thread, or manage_channel, are unaffected - int? rateLimitPerUser; - - /// The time after which the thread is automatically archived. - ThreadArchiveTime? archiveAfter; - - /// Create a public thread - ThreadBuilder( - this.name, { - this.archiveAfter, - this.archived, - this.invitable, - this.locked, - this.private, - this.rateLimitPerUser, - }); - - /// Create a private thread - ThreadBuilder.private( - this.name, { - this.archiveAfter, - this.archived, - this.invitable, - this.locked, - this.rateLimitPerUser, - }) { - private = true; - } - - @override - RawApiMap build() => { - if (archiveAfter != null) "auto_archive_duration": archiveAfter!.value, - if (name != null) "name": name, - if (private != null) "type": private! ? 12 : 11, - if (archived != null) "archived": archived!, - if (invitable != null) "invitable": invitable!, - if (rateLimitPerUser != null) 'rate_limit_per_user': rateLimitPerUser!, - if (locked != null) "locked": locked - }; -} - -/// Simplifies the process of setting an auto archive time. -class ThreadArchiveTime extends IEnum { - /// Creates an instance of [ThreadArchiveTime] - const ThreadArchiveTime(int value) : super(value); - - /// Archive after an hour - static const ThreadArchiveTime hour = ThreadArchiveTime(60); - - /// Archive after an day - static const ThreadArchiveTime day = ThreadArchiveTime(1440); - - /// Archive after 3 days - static const ThreadArchiveTime threeDays = ThreadArchiveTime(4320); - - /// Archive after an week - static const ThreadArchiveTime week = ThreadArchiveTime(10080); -} diff --git a/lib/src/utils/enum.dart b/lib/src/utils/enum.dart deleted file mode 100644 index e31772948..000000000 --- a/lib/src/utils/enum.dart +++ /dev/null @@ -1,29 +0,0 @@ -/// Abstract interface for enums in library -abstract class IEnum { - final T _value; - - /// Returns value of enum - T get value => _value; - - /// Creates enum with given value - const IEnum(this._value); - - @override - String toString() => _value.toString(); - - @override - int get hashCode => _value.hashCode; - - @override - bool operator ==(dynamic other) { - if (other is IEnum) { - return other._value == _value; - } - - if (other is T) { - return other == _value; - } - - return false; - } -} diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart deleted file mode 100644 index e7138ca0d..000000000 --- a/lib/src/utils/extensions.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/snowflake_entity.dart'; - -/// Extension on int -extension IntExtensions on int { - /// Converts int to [Snowflake] - Snowflake toSnowflake() => Snowflake(this); - - /// Converts int to [SnowflakeEntity] - SnowflakeEntity toSnowflakeEntity() => SnowflakeEntity(toSnowflake()); -} - -/// Extension on int -extension StringExtensions on String { - /// Converts String to [Snowflake] - Snowflake toSnowflake() => Snowflake(this); - - /// Converts String to [SnowflakeEntity] - SnowflakeEntity toSnowflakeEntity() => SnowflakeEntity(toSnowflake()); -} - -/// Extensions on Iterable of Snowflakes entities -extension SnowflakeEntityListExtensions on Iterable { - /// Returns Iterable of [SnowflakeEntity] as Iterable of IDs - Iterable asSnowflakes() => map((e) => e.id); -} diff --git a/lib/src/utils/flags.dart b/lib/src/utils/flags.dart new file mode 100644 index 000000000..c578f0f4f --- /dev/null +++ b/lib/src/utils/flags.dart @@ -0,0 +1,79 @@ +import 'dart:collection'; + +import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; + +/// A set of flags that can be either enabled or disabled. +class Flags> extends IterableBase> with ToStringHelper { + /// The integer value encoding the flags as a bitfield. + final int value; + + /// Create a new [Flags]. + const Flags(this.value); + + /// Returns `true` if this [Flags] has the [flag] enabled, `false` otherwise. + bool has(Flag flag) => value & flag.value != 0; + + @override + Iterator> get iterator => _FlagIterator(this); + + /// Return a set of flags that has all the flags set in either `this` or [other]. + Flags operator |(Flags other) => Flags(value | other.value); + + /// Return a set of flags that has all the flags set in both `this` and [other]. + Flags operator &(Flags other) => Flags(value & other.value); + + /// Return a set of flags that has all the flags set in `this` or in `other` but not in both. + Flags operator ^(Flags other) => Flags(value ^ other.value); + + /// Returns the opposite of this set of flags. + Flags operator ~() => Flags(~value); + + @override + bool operator ==(Object other) => identical(this, other) || (other is Flags && other.value == value); + + @override + int get hashCode => value.hashCode; + + @override + String defaultToString() => 'Flags<$T>($value)'; +} + +/// A flag within a set of [Flags]. +class Flag> extends Flags { + /// Create a new [Flag]. + const Flag(super.value); + + /// Create a new [Flag] from an offset into the bitfield. + const Flag.fromOffset(int offset) : super(1 << offset); + + @override + String toString() => 'Flag<$T>($value)'; +} + +class _FlagIterator> implements Iterator> { + final Flags source; + + _FlagIterator(this.source); + + int? offset; + + @override + bool moveNext() { + do { + if (offset == null) { + offset = 0; + } else { + offset = offset! + 1; + } + + if (offset! > source.value.bitLength) { + return false; + } + } while (!source.has(current)); + + return true; + } + + @override + Flag get current => Flag.fromOffset(offset!); +} diff --git a/lib/src/utils/iterable_extension.dart b/lib/src/utils/iterable_extension.dart new file mode 100644 index 000000000..4b712c90f --- /dev/null +++ b/lib/src/utils/iterable_extension.dart @@ -0,0 +1,25 @@ +/// An internal extension adding utility methods to [Iterable]. +extension IterableExtension on Iterable { + /// Same as [firstWhere], but returns `null` is no element is found. + /// + /// Also allows for [orElse] to return `null`. + T? firstWhereSafe(bool Function(T element) test, {T? Function()? orElse}) { + for (final element in this) { + if (test(element)) { + return element; + } + } + + if (orElse != null) { + return orElse(); + } + + return null; + } +} + +/// An internal extension adding utility methods to [Stream]. +extension StreamExtension on Stream { + /// Equivalent to [Iterable.whereType]. + Stream whereType() => where((event) => event is U).cast(); +} diff --git a/lib/src/utils/parsing_helpers.dart b/lib/src/utils/parsing_helpers.dart new file mode 100644 index 000000000..a2023c664 --- /dev/null +++ b/lib/src/utils/parsing_helpers.dart @@ -0,0 +1,74 @@ +import 'package:runtime_type/runtime_type.dart'; + +/// An internal helper which parses [object] using [parse] if it is not null. +/// +/// Prefer using this over a ternary (`raw['field'] == null ? null : parse(raw['field'])`). +T? maybeParse(dynamic object, T Function(U) parse) { + if (object == null) { + return null; + } + + if (object is! U) { + throw FormatException('Unexpected type (expected $U, got ${object.runtimeType})', object); + } + + return parse(object); +} + +/// An internal helper which parses each element of [objects] using [parse]. +/// +/// Prefer using this over an iterable map (`(raw['field'] as List).map(parse).toList()`) or a list literal +/// (`[for (final element in raw['field'] as List) parse(element)]`) +List parseMany(List objects, [T Function(U)? parse]) { + if (parse == null) { + assert( + !RuntimeType.allowingDynamic().isSubtypeOf(RuntimeType()), + 'Missing parse function ($U is not $T)', + ); + + parse = (value) => value as T; + } + + return List.generate( + objects.length, + (index) { + final raw = objects[index]; + + if (raw is! U) { + throw FormatException('Unexpected type (expected $U, got ${raw.runtimeType})', raw); + } + + return parse!(raw); + }, + ); +} + +/// An internal helper which parses each element of [object] using [parse] if it is not null. +List? maybeParseMany(dynamic object, [T Function(U)? parse]) => maybeParse, List>(object, (object) => parseMany(object, parse)); + +/// An internal helper which parses [object] using [parse] if it is non-null, and returns `null` if [parse] throws. +T? tryParse(dynamic object, [T Function(U)? parse]) { + if (parse == null) { + assert( + !RuntimeType.allowingDynamic().isSubtypeOf(RuntimeType()), + 'Missing parse function ($U is not $T)', + ); + + parse = (value) => value as T; + } + + try { + return maybeParse(object, parse); + } catch (_) { + return null; + } +} + +/// An internal helper which parses each element of [object] using [parse] if it is not null, and returns `null` if [parse] throws. +List? tryParseMany(dynamic object, [T Function(U)? parse]) { + try { + return maybeParseMany(object, parse); + } catch (_) { + return null; + } +} diff --git a/lib/src/utils/permissions.dart b/lib/src/utils/permissions.dart deleted file mode 100644 index 074a2cab5..000000000 --- a/lib/src/utils/permissions.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; -import 'package:nyxx/src/core/guild/role.dart'; -import 'package:nyxx/src/core/user/member.dart'; -import 'package:nyxx/src/utils/utils.dart'; - -/// Util function for manipulating permissions -class PermissionsUtils { - static IRole getMemberHighestRole(IMember member) { - var currentRole = member.roles.first.getFromCache(); - - if (currentRole == null) { - return member.guild.getFromCache()!.everyoneRole; - } - - for (final roleCacheable in member.roles.skip(1)) { - final nextRole = roleCacheable.getFromCache(); - - if (nextRole == null) { - continue; - } - - if (nextRole.position > currentRole!.position) { - currentRole = nextRole; - } - } - - return currentRole!; - } - - /// Allows to check if [issueMember] or [issueRole] can interact with [targetMember] or [targetRole]. - static bool canInteract({IMember? issueMember, IRole? issueRole, IMember? targetMember, IRole? targetRole}) { - bool canInter(IRole role1, IRole role2) => role1.position > role2.position; - - if (issueMember != null && targetMember != null) { - if (issueMember.guild != targetMember.guild) { - return false; - } - - return canInter(PermissionsUtils.getMemberHighestRole(issueMember), PermissionsUtils.getMemberHighestRole(targetMember)); - } - - if (issueMember != null && targetRole != null) { - if (issueMember.guild != targetRole.guild) { - return false; - } - - return canInter(PermissionsUtils.getMemberHighestRole(issueMember), targetRole); - } - - if (issueRole != null && targetRole != null) { - if (issueRole.guild != targetRole.guild) return false; - - return canInter(issueRole, targetRole); - } - - return false; - } - - /// Returns List of [channel] permissions overrides for given [member]. - static List getOverrides(IMember member, IGuildChannel channel) { - var allowRaw = 0; - var denyRaw = 0; - - final publicOverride = channel.permissionOverrides.firstWhereSafe((ov) => ov.id == member.guild.getFromCache()?.everyoneRole.id); - - if (publicOverride != null) { - allowRaw = publicOverride.allow; - denyRaw = publicOverride.deny; - } - - var allowRole = 0; - var denyRole = 0; - - for (final role in member.roles) { - final channelOverride = channel.permissionOverrides.firstWhereSafe((f) => f.id == role.id); - - if (channelOverride != null) { - denyRole |= channelOverride.deny; - allowRole |= channelOverride.allow; - } - } - - allowRaw = (allowRaw & ~denyRole) | allowRole; - denyRaw = (denyRaw & ~allowRole) | denyRole; - - final memberOverride = channel.permissionOverrides.firstWhereSafe((g) => g.id == member.id); - - if (memberOverride != null) { - allowRaw = (allowRaw & ~memberOverride.deny) | memberOverride.allow; - denyRaw = (denyRaw & ~memberOverride.allow) | memberOverride.deny; - } - - return [allowRaw, denyRaw]; - } - - /// Apply [deny] and [allow] to [permissions]. - static int apply(int permissions, int allow, int deny) { - permissions &= ~deny; - permissions |= allow; - - return permissions; - } - - /// Returns true if [permission] is applied to [permissions]. - static bool isApplied(int permissions, int permission) => (permissions & permission) == permission; -} diff --git a/lib/src/utils/to_string_helper/base_impl.dart b/lib/src/utils/to_string_helper/base_impl.dart new file mode 100644 index 000000000..dfee61cfb --- /dev/null +++ b/lib/src/utils/to_string_helper/base_impl.dart @@ -0,0 +1,12 @@ +/// An internal mixin containing a [toString] implementation when dart:mirrors is available. +/// +/// Override [defaultToString] to change the output when dart:mirrors is not enabled. +mixin ToStringHelper { + /// Same as [toString], but only called when dart:mirrors is not available. + /// + /// If dart:mirrors is available, it will be used to print a complete representation of this object. + String defaultToString() => super.toString(); + + @override + String toString() => defaultToString(); +} diff --git a/lib/src/utils/to_string_helper/mirrors_impl.dart b/lib/src/utils/to_string_helper/mirrors_impl.dart new file mode 100644 index 000000000..d2563e380 --- /dev/null +++ b/lib/src/utils/to_string_helper/mirrors_impl.dart @@ -0,0 +1,81 @@ +import 'dart:mirrors'; + +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:runtime_type/mirrors.dart'; +import 'package:runtime_type/runtime_type.dart'; + +/// An internal mixin containing a [toString] implementation when dart:mirrors is available. +/// +/// Override [defaultToString] to change the output when dart:mirrors is not enabled. +mixin ToStringHelper { + /// Same as [toString], but only called when dart:mirrors is not available. + /// + /// If dart:mirrors is available, it will be used to print a complete representation of this object. + String defaultToString() => super.toString(); + + @override + String toString() => stringifyInstance(reflect(this)); +} + +final _stringifyStack = []; + +/// An internal function used when dart:mirrors is available to stringify the instance reflected by +/// [mirror]. +String stringifyInstance(InstanceMirror mirror) { + final existingIndex = _stringifyStack.indexOf(mirror.reflectee); + if (existingIndex >= 0) { + return ''; + } + _stringifyStack.add(mirror.reflectee); + + final type = MirrorSystem.getName(mirror.type.simpleName); + + final buffer = StringBuffer('$type(\n'); + + final getters = mirror.type.instanceMembers.values.where((member) => member.isGetter); + const blockedGetters = [#manager, #hashCode, #runtimeType]; + + final outputtedGetters = List.of( + getters.where( + (getter) => + !getter.isPrivate && !blockedGetters.contains(getter.simpleName) && !getter.returnType.toRuntimeType().isSubtypeOf(RuntimeType()), + ), + ); + + if (outputtedGetters.isEmpty) { + _stringifyStack.removeLast(); + return 'Instance of $type'; + } + + outputtedGetters.sort((a, b) { + final aName = a.simpleName; + final bName = b.simpleName; + + if (aName == #id) { + return -1; + } + + if (bName == #id) { + return 1; + } + + return aName.toString().compareTo(bName.toString()); + }); + + for (final identifier in outputtedGetters.map((getter) => getter.simpleName)) { + late final String representation; + try { + representation = mirror.getField(identifier).reflectee.toString(); + } catch (e) { + representation = '<$e>'; + } + + buffer.write(' ${MirrorSystem.getName(identifier)}: '); + buffer.write(representation.replaceAll('\n', '\n ')); + buffer.writeln(','); + } + + buffer.write(')'); + _stringifyStack.removeLast(); + return buffer.toString(); +} diff --git a/lib/src/utils/to_string_helper/to_string_helper.dart b/lib/src/utils/to_string_helper/to_string_helper.dart new file mode 100644 index 000000000..5dc58b4ab --- /dev/null +++ b/lib/src/utils/to_string_helper/to_string_helper.dart @@ -0,0 +1 @@ +export 'base_impl.dart' if (dart.library.mirrors) 'mirrors_impl.dart'; diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart deleted file mode 100644 index b0f3f1a79..000000000 --- a/lib/src/utils/utils.dart +++ /dev/null @@ -1,29 +0,0 @@ -extension ListSafeFirstWhere on List { - E? firstWhereSafe(bool Function(E element) test, {E? Function()? orElse}) { - try { - return firstWhere(test); - } on StateError { - if (orElse != null) { - return orElse(); - } - - return null; - } - } - - Stream> chunk(int chunkSize) async* { - final len = length; - for (var i = 0; i < len; i += chunkSize) { - final size = i + chunkSize; - yield sublist(i, size > len ? len : size); - } - } - - /// Append [separator] as the last position. - /// ```dart - /// ['Hello', 'Dart'].and(); // Hello and Dart - /// ``` - String and({String separator = 'and'}) { - return '${sublist(0, length - 1).join(', ')} $separator $last'; - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 16e51955a..d720d429f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,23 +1,27 @@ name: nyxx -version: 5.0.0 -description: A Discord library for Dart. Simple, robust framework for creating discord bots for Dart language. +version: 6.0.0-dev.2 +description: A complete, robust and efficient wrapper around Discord's API for bots & applications. homepage: https://github.com/nyxx-discord/nyxx repository: https://github.com/nyxx-discord/nyxx documentation: https://nyxx.l7ssha.xyz issue_tracker: https://github.com/nyxx-discord/nyxx/issues environment: - sdk: '>=2.17.0 <4.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: - http: ^0.13.3 + http: ^1.0.0 logging: ^1.0.1 path: ^1.8.3 retry: ^3.1.0 eterl: ^1.0.1 + runtime_type: ^1.0.1 + meta: ^1.9.1 + oauth2: ^2.0.2 dev_dependencies: test: ^1.22.0 - mockito: ^5.0.16 - build_runner: ^2.3.0 lints: ^2.0.0 + nock: ^1.2.1 + mocktail: ^0.3.0 + matcher: ^0.12.16 diff --git a/test/function_completes.dart b/test/function_completes.dart new file mode 100644 index 000000000..841b71b95 --- /dev/null +++ b/test/function_completes.dart @@ -0,0 +1,22 @@ +import 'package:test/test.dart' hide completes; +import 'package:test/test.dart' as test show completes; +import 'package:matcher/src/expect/async_matcher.dart' show AsyncMatcher; + +/// A simple wrapper around [test.completes] that invokes functions and tests their result instead of failing. +const completes = _FunctionCompletes(); + +class _FunctionCompletes extends AsyncMatcher { + const _FunctionCompletes(); + + @override + matchAsync(item) { + if (item is Function()) { + item = item(); + } + + return (test.completes as AsyncMatcher).matchAsync(item); + } + + @override + Description describe(Description description) => test.completes.describe(description); +} diff --git a/test/integration/gateway_integration_test.dart b/test/integration/gateway_integration_test.dart new file mode 100644 index 000000000..c14aba122 --- /dev/null +++ b/test/integration/gateway_integration_test.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart' hide completes; + +import '../function_completes.dart'; + +void main() { + final testToken = Platform.environment['TEST_TOKEN']; + final testGuild = Platform.environment['TEST_GUILD']; + + group('Nyxx.connectGateway', skip: testToken != null ? false : 'No test token provided', () { + Future testClient(GatewayApiOptions options) async { + late NyxxGateway client; + + await expectLater(() async => client = await Nyxx.connectGatewayWithOptions(options), completes); + expect(client.gateway.messages.where((event) => event is ErrorReceived), emitsDone); + await expectLater(client.onEvent, emits(isA())); + await expectLater(client.close(), completes); + } + + test( + 'JSON (uncompressed)', + () => testClient(GatewayApiOptions( + token: testToken!, + intents: GatewayIntents.none, + compression: GatewayCompression.none, + payloadFormat: GatewayPayloadFormat.json, + )), + ); + + test( + 'JSON (payload compression)', + () => testClient(GatewayApiOptions( + token: testToken!, + intents: GatewayIntents.none, + compression: GatewayCompression.payload, + payloadFormat: GatewayPayloadFormat.json, + )), + ); + + test( + 'JSON (transport compression)', + () => testClient(GatewayApiOptions( + token: testToken!, + intents: GatewayIntents.none, + compression: GatewayCompression.transport, + payloadFormat: GatewayPayloadFormat.json, + )), + ); + + test( + 'ETF (uncompressed)', + () => testClient(GatewayApiOptions( + token: testToken!, + intents: GatewayIntents.none, + compression: GatewayCompression.none, + payloadFormat: GatewayPayloadFormat.etf, + )), + ); + + test( + 'ETF (transport compression)', + () => testClient(GatewayApiOptions( + token: testToken!, + intents: GatewayIntents.none, + compression: GatewayCompression.transport, + payloadFormat: GatewayPayloadFormat.etf, + )), + ); + }); + + group('NyxxGateway', skip: testToken != null ? false : 'No test token provided', () { + late NyxxGateway client; + + // Use setUpAll and tearDownAll to minimize the number of sessions opened on the test token. + setUpAll(() async { + client = await Nyxx.connectGateway(testToken!, GatewayIntents.allUnprivileged); + + if (testGuild != null) { + await client.onGuildCreate.firstWhere((event) => event is GuildCreateEvent && event.guild.id == Snowflake.parse(testGuild)); + } + }); + + tearDownAll(() async { + await client.close(); + }); + + test('listGuildMembers', skip: testGuild != null ? false : 'No test guild provided', timeout: Timeout(Duration(minutes: 10)), () async { + final guildId = Snowflake.parse(testGuild!); + + // We can't list all guild members since we don't have the GUILD_MEMBERS intent, so just search for the current user + final currentUser = await client.users.fetchCurrentUser(); + + await expectLater(client.gateway.listGuildMembers(guildId, query: currentUser.username).drain(), completes); + }); + + test('updatePresence', () async { + client.updatePresence(PresenceBuilder(status: CurrentUserStatus.dnd, isAfk: false)); + await Future.delayed(const Duration(seconds: 5)); + client.updatePresence(PresenceBuilder(status: CurrentUserStatus.online, isAfk: false)); + await Future.delayed(const Duration(seconds: 5)); + }); + + group('Gateway', () { + test('latency', timeout: Timeout(Duration(minutes: 2)), () async { + // Only wait if the client hasn't yet received a heartbeat ack. + if (client.gateway.latency == Duration.zero) { + await client.gateway.messages.firstWhere((element) => element is EventReceived && element.event is HeartbeatAckEvent); + } + + expect(client.gateway.latency, greaterThan(Duration.zero)); + }); + }); + }); +} diff --git a/test/integration/integration.dart b/test/integration/integration.dart deleted file mode 100644 index d4428c3a4..000000000 --- a/test/integration/integration.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:nyxx/nyxx.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -import 'package:nyxx/src/internal/event_controller.dart'; - -final testChannelSnowflake = Snowflake(846139169818017812); -final testGuildSnowflake = Snowflake(846136758470443069); -final testUserBotSnowflake = Snowflake(476603965396746242); -final testUserHumanSnowflake = Snowflake(302359032612651009); - -main() async { - final bot = NyxxFactory.createNyxxWebsocket(Platform.environment["TEST_TOKEN"]!, GatewayIntents.guildMessages) - ..registerPlugin(Logging()) - ..connect(); - - final random = Random(); - - late ITextChannel channel; - - await bot.eventsWs.onReady.first.then((value) async { - channel = await bot.fetchChannel(testChannelSnowflake); - - await channel.sendMessage(MessageBuilder.content(getChannelLogMessage())); - }); - - test('base nyxx', () { - expect(bot.appId, equals(Snowflake(846158316467650561))); - expect(bot.ready, isTrue); - expect(bot.startTime.isBefore(DateTime.now()), isTrue); - expect(bot.inviteLink, contains('846158316467650561')); - expect(bot.version, isA()); - expect(bot.shards, equals(1)); - - expect(bot.eventsWs, isA()); - expect(bot.eventsRest, isA()); - expect(bot.shardManager, isA()); - }); - - test('get invite', () async { - final invite = await bot.getInvite('nyxx'); - - expect(invite.code, equals('nyxx')); - expect(invite.guild?.id, isNotNull); - expect(invite.guild?.id, equals(Snowflake(846136758470443069))); - expect(invite.url, equals('https://discord.gg/nyxx')); - }); - - test('fetch guild preview', () async { - final guildPreview = await bot.fetchGuildPreview(testGuildSnowflake); - - expect(guildPreview.id, equals(testGuildSnowflake)); - expect(guildPreview.name, equals('nyxx')); - - expect(guildPreview.discoveryUrl(), isNull); - expect(guildPreview.splashUrl(), isNull); - expect(guildPreview.iconUrl(), isNotNull); - }); - - test("basic message functionality", () async { - final messageBuilder = MessageBuilder() - ..content = "Test content" - ..nonce = random.nextInt(1000000).toString(); - - final wsMessageFuture = bot.eventsWs.onMessageReceived.firstWhere((element) => element.message.nonce == messageBuilder.nonce); - - final message = await channel.sendMessage(messageBuilder); - final wsMessage = (await wsMessageFuture).message; - - expect(message.id, equals(wsMessage.id)); - expect(message.guild, isNull); - expect(wsMessage.guild, isNotNull); - - expect(message.isByWebhook, equals(false)); - expect(message.isCrossPosting, equals(false)); - expect(wsMessage.url, equals("https://discordapp.com/channels/${wsMessage.guild!.id}/${message.channel.id}/${message.id}")); - - final messageEditBuilder = MessageBuilder() - ..content = 'Edit test' - ..nonce = random.nextInt(1000000).toString(); - - final messageEditWsFuture = bot.eventsWs.onMessageUpdate.firstWhere((element) => element.messageId == message.id); - final messageEdit = await message.edit(messageEditBuilder); - final messageEditWs = await channel.fetchMessage((await messageEditWsFuture).messageId); - - expect(messageEdit.id, equals(messageEditWs.id)); - expect(messageEdit.content, equals("Edit test")); - expect(messageEditWs.content, equals("Edit test")); - - await messageEdit.createReaction(UnicodeEmoji("😂")); - await messageEdit.deleteSelfReaction(UnicodeEmoji("😂")); - - await messageEdit.suppressEmbeds(); - - await messageEdit.pinMessage(); - final pinnedMessages = await (await messageEdit.channel.getOrDownload()).fetchPinnedMessages().toList(); - expect(pinnedMessages, hasLength(1)); - expect(pinnedMessages.first.pinned, isTrue); - expect(pinnedMessages.first.id, messageEdit.id); - await messageEdit.unpinMessage(); - - await messageEdit.dispose(); // it does nothing - - expect(messageEdit.hashCode, equals(messageEdit.id.hashCode)); - expect(messageEdit, equals(messageEditWs)); - - final toBuilder = messageEdit.toBuilder(); - expect(toBuilder.content, equals("Edit test")); - - await messageEdit.delete(); - }, skip: 'Rate limits problems'); - - test("file upload tests", () async { - final messageBuilder = MessageBuilder.files([ - AttachmentBuilder.path('test/files/1.png'), - AttachmentBuilder.path('test/files/2.png'), - ]); - - final message = await channel.sendMessage(messageBuilder); - - expect(message.attachments, hasLength(2)); - - final editedMessage = await message.edit(MessageBuilder() - ..attachments = [message.attachments.first.toBuilder()] - ..files = [AttachmentBuilder.path('test/files/3.png')]); - - expect(editedMessage.attachments, hasLength(2)); - - await editedMessage.delete(); - }); - - test("user tests", () async { - final userBot = await bot.fetchUser(testUserBotSnowflake); - - expect(userBot.discriminator, equals(1759)); - expect(userBot.formattedDiscriminator, equals("1759")); - expect(userBot.bot, isTrue); - expect(userBot.mention, "<@!${testUserBotSnowflake.toString()}>"); - expect(userBot.tag, equals("Running on Dart#1759")); - expect(userBot.avatarUrl(), equals('https://cdn.discordapp.com/avatars/476603965396746242/be6107505d7b9d15292da4e54d88836e.webp')); - }); - - test('member and guild tests', () async { - final guild = await bot.fetchGuild(testGuildSnowflake); - - expect(guild.afkChannel, isNull); - expect(guild.name, 'nyxx'); - expect(guild.features, contains(GuildFeature.verified)); - - final memberBot = await guild.fetchMember(testUserBotSnowflake); - - expect(memberBot.guild.id, equals(guild.id)); - expect(memberBot.voiceState, isNull); - expect(memberBot.mention, "<@${memberBot.id.toString()}>"); - expect(memberBot.avatarUrl(), isNull); - - final effectivePermissions = await memberBot.effectivePermissions; - expect(effectivePermissions.sendMessages, isTrue); - }); - - test("guild events tests", () async { - final guild = await bot.fetchGuild(testGuildSnowflake); - - final events = await guild.fetchGuildEvents().toList(); - expect(events, isEmpty); - }, skip: 'Rete limits problems'); -} - -String getChannelLogMessage() { - final env = Platform.environment; - - if (env['GITHUB_RUN_NUMBER'] == null) { - return "Testing new local build. Nothing to worry about 😀"; - } - - return "Running `nyxx` job `#${env['GITHUB_RUN_NUMBER']}` started by `${env['GITHUB_ACTOR']}` on `${env['GITHUB_REF']}` on commit `${env['GITHUB_SHA']}`"; -} diff --git a/test/integration/rest_integration_test.dart b/test/integration/rest_integration_test.dart new file mode 100644 index 000000000..47b3b8601 --- /dev/null +++ b/test/integration/rest_integration_test.dart @@ -0,0 +1,323 @@ +import 'dart:io'; + +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart' hide completes; + +import '../function_completes.dart'; +import '../mocks/client.dart'; + +void main() { + final testToken = Platform.environment['TEST_TOKEN']; + final testTextChannel = Platform.environment['TEST_TEXT_CHANNEL']; + final testGuild = Platform.environment['TEST_GUILD']; + + test('Nyxx.connectRest', skip: testToken != null ? false : 'No test token provided', () async { + late NyxxRest client; + + await expectLater(() async => client = await Nyxx.connectRest(testToken!), completes); + await expectLater(client.close(), completes); + }); + + group('HttpHandler', () { + test('latency & realLatency', () async { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + final handler = HttpHandler(client); + final request = BasicRequest(HttpRoute(), method: 'HEAD'); + + for (int i = 0; i < 5; i++) { + await handler.execute(request); + } + + expect(handler.latency, greaterThan(Duration.zero)); + expect(handler.realLatency, greaterThan(Duration.zero)); + }); + }); + + group('NyxxRest', skip: testToken != null ? false : 'No test token provided', () { + late NyxxRest client; + + // package:test doesn't seem to re-run the body of the group for each test, so + // the tests end up conflicting & closing each other's clients since they all + // refer to the same variable if we use setUp and tearDown. use the All variants + // to mitigate this. + setUpAll(() async { + client = await Nyxx.connectRest(testToken!, options: RestClientOptions(applicationId: Snowflake.zero)); + }); + + tearDownAll(() async { + await client.close(); + }); + + test('applications', () async { + await expectLater(client.applications.fetchCurrentApplication(), completes); + }); + + test('users', () async { + await expectLater(client.users.fetchCurrentUser(), completes); + await expectLater(client.users.listCurrentUserGuilds(), completes); + await expectLater(client.users.fetchCurrentUserConnections(), completes); + }); + + test('channels', skip: testTextChannel != null ? false : 'No test channel provided', () async { + final channelId = Snowflake.parse(testTextChannel!); + + await expectLater(client.channels.fetch(channelId), completes); + }); + + test('messages', skip: testTextChannel != null ? false : 'No test channel provided', () async { + final channelId = Snowflake.parse(testTextChannel!); + final channel = await client.channels.get(channelId) as TextChannel; + + final env = Platform.environment; + + await expectLater( + channel.sendMessage(MessageBuilder( + content: env['GITHUB_RUN_NUMBER'] == null + ? "Testing new local build. Nothing to worry about 😀" + : "Running `nyxx` job `#${env['GITHUB_RUN_NUMBER']}` started by `${env['GITHUB_ACTOR']}` on `${env['GITHUB_REF']}` on commit `${env['GITHUB_SHA']}`", + )), + completes, + ); + + late Message message; + await expectLater( + () async => message = await channel.sendMessage(MessageBuilder(content: 'Test message')), + completes, + ); + + expect(message.content, equals('Test message')); + + await expectLater( + () async => message = await message.update(MessageUpdateBuilder(content: 'New content')), + completes, + ); + + expect(message.content, equals('New content')); + + await expectLater(message.pin(), completes); + await expectLater(message.unpin(), completes); + + await expectLater(message.delete(), completes); + + await expectLater( + () async => message = await channel.sendMessage(MessageBuilder( + attachments: [ + await AttachmentBuilder.fromFile(File('test/files/1.png')), + ], + )), + completes, + ); + + expect(message.attachments, hasLength(1)); + expect(message.attachments.first.fileName, equals('1.png')); + + await expectLater(message.delete(), completes); + + await expectLater( + () async => message = await channel.sendMessage( + MessageBuilder( + content: 'Components test', + components: [ + ActionRowBuilder(components: [ + ButtonBuilder(style: ButtonStyle.primary, label: 'Primary', customId: 'a'), + ButtonBuilder(style: ButtonStyle.secondary, label: 'Secondary', customId: 'b'), + ButtonBuilder(style: ButtonStyle.success, label: 'Success', customId: 'c'), + ButtonBuilder(style: ButtonStyle.danger, label: 'Danger', customId: 'd'), + ButtonBuilder(style: ButtonStyle.link, label: 'Primary', url: Uri.https('pub.dev', '/packages/nyxx')), + ]), + ActionRowBuilder(components: [ + ButtonBuilder(style: ButtonStyle.primary, label: 'Primary', customId: 'e', isDisabled: true), + ButtonBuilder(style: ButtonStyle.secondary, label: 'Secondary', customId: 'f', isDisabled: true), + ButtonBuilder(style: ButtonStyle.success, label: 'Success', customId: 'g', isDisabled: true), + ButtonBuilder(style: ButtonStyle.danger, label: 'Danger', customId: 'h', isDisabled: true), + ButtonBuilder(style: ButtonStyle.link, label: 'Primary', url: Uri.https('pub.dev', '/packages/nyxx'), isDisabled: true), + ]), + ActionRowBuilder(components: [ + SelectMenuBuilder( + type: MessageComponentType.stringSelect, + customId: 'i', + options: [ + SelectMenuOptionBuilder(label: 'One', value: '1'), + SelectMenuOptionBuilder( + label: 'Two', + value: '2', + emoji: TextEmoji( + id: Snowflake.zero, + manager: client.guilds[Snowflake.zero].emojis, + name: '❤️', + ), + ), + SelectMenuOptionBuilder(label: 'Three', value: '3'), + ], + ), + ]), + ], + ), + ), + completes, + ); + + await expectLater(message.delete(), completes); + }); + + test('webhooks', skip: testTextChannel != null ? false : 'No test channel provided', () async { + final channelId = Snowflake.parse(testTextChannel!); + + late Webhook webhook; + await expectLater( + () async => webhook = await client.webhooks.create(WebhookBuilder( + name: 'Test webhook', + channelId: channelId, + )), + completes, + ); + + expect(webhook.name, equals('Test webhook')); + expect(webhook.token, isNotNull); + + await expectLater( + () async => webhook = await webhook.update(WebhookUpdateBuilder(name: 'New name')), + completes, + ); + + expect(webhook.name, equals('New name')); + expect(webhook.token, isNotNull); + + final token = webhook.token!; + + late Message message; + await expectLater( + () async => message = (await webhook.execute(token: token, wait: true, MessageBuilder(content: 'Test webhook message')))!, + completes, + ); + + expect(message.content, equals('Test webhook message')); + expect(message.author.id, equals(webhook.id)); + + await expectLater( + () async => message = await webhook.updateMessage(message.id, token: token, MessageUpdateBuilder(content: 'New webhook content')), + completes, + ); + + expect(message.content, equals('New webhook content')); + + await expectLater( + webhook.deleteMessage(message.id, token: token), + completes, + ); + + await expectLater( + webhook.delete(), + completes, + ); + }); + + test('voice', () async { + await expectLater(client.voice.listRegions(), completes); + }); + + test('guilds', skip: testGuild != null ? false : 'No test guild provided', () async { + final guildId = Snowflake.parse(testGuild!); + + late Guild guild; + await expectLater(() async => guild = await client.guilds.fetch(guildId), completes); + + await expectLater(guild.fetchPreview(), completes); + await expectLater(guild.fetchChannels(), completes); + await expectLater(guild.listActiveThreads(), completes); + await expectLater(guild.listVoiceRegions(), completes); + + if (guild.isWidgetEnabled) { + await expectLater(guild.fetchWidget(), completes); + await expectLater(guild.fetchWidgetImage(), completes); + } + + if (guild.features.hasWelcomeScreenEnabled) { + await expectLater(guild.fetchWelcomeScreen(), completes); + } + + await expectLater(guild.fetchOnboarding(), completes); + }); + + test('members', skip: testGuild != null ? false : 'No test guild provided', () async { + final guildId = Snowflake.parse(testGuild!); + + final user = await client.users.fetchCurrentUser(); + await expectLater(client.guilds[guildId].members.fetch(user.id), completes); + }); + + test('roles', skip: testGuild != null ? false : 'No test guild provided', () async { + final guildId = Snowflake.parse(testGuild!); + + await expectLater(client.guilds[guildId].roles.list(), completes); + }); + + test('gateway', () async { + await expectLater(client.gateway.fetchGatewayBot(), completes); + await expectLater(client.gateway.fetchGatewayConfiguration(), completes); + }); + + test('scheduledEvents', skip: testGuild != null ? false : 'No test guild provided', () async { + final guildId = Snowflake.parse(testGuild!); + final guild = client.guilds[guildId]; + + await expectLater(guild.scheduledEvents.list(), completes); + }); + + test('emojis', skip: testGuild != null ? false : 'No test guild provided', () async { + final guildId = Snowflake.parse(testGuild!); + + await expectLater(client.guilds[guildId].emojis.list(), completes); + }); + + test('CDN assets', () async { + final user = await client.users.fetchCurrentUser(); + + await expectLater(user.avatar.fetch(), completes); + + if (user.banner != null) { + await expectLater(user.banner!.fetch(), completes); + } + + if (testGuild != null) { + final guildId = Snowflake.parse(testGuild); + final guild = await client.guilds[guildId].get(); + + final emoji = guild.emojiList.firstOrNull as GuildEmoji?; + if (emoji != null) { + await expectLater(emoji.image.fetch(), completes); + } + + final role = guild.roleList.firstOrNull; + if (role != null && role.icon != null) { + await expectLater(role.icon!.fetch(), completes); + } + + if (guild.icon != null) { + await expectLater(guild.icon!.fetch(), completes); + } + + if (guild.splash != null) { + await expectLater(guild.splash!.fetch(), completes); + } + + if (guild.discoverySplash != null) { + await expectLater(guild.discoverySplash!.fetch(), completes); + } + + if (guild.banner != null) { + await expectLater(guild.banner!.fetch(), completes); + } + + final member = await guild.members[user.id].get(); + if (member.avatar != null) { + await expectLater(member.avatar!.fetch(), completes); + } + } + }); + }); +} diff --git a/test/mocks/channel.mock.dart b/test/mocks/channel.mock.dart deleted file mode 100644 index a5221720b..000000000 --- a/test/mocks/channel.mock.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/fake.dart'; - -import 'message.mock.dart'; - -// Constants For DM Channel Testing. -final Snowflake testMessageId = 901383332011593750.toSnowflake(); -final Snowflake testChannelId = 896714099226992671.toSnowflake(); -const String testMessageContent = "Test Message."; - -class MockThreadChannel extends SnowflakeEntity with Fake implements IThreadChannel { - MockThreadChannel(Snowflake id) : super(id); -} - -class MockVoiceChannel extends SnowflakeEntity with Fake implements IVoiceGuildChannel { - MockVoiceChannel(Snowflake id) : super(id); -} - -class MockTextChannel extends SnowflakeEntity with Fake implements ITextChannel { - final bool shouldFail; - - MockTextChannel(Snowflake id, {this.shouldFail = false}) : super(id); - - @override - Future fetchMessage(Snowflake id) { - return Future.value(MockMessage({"content": testMessageContent}, id)); - } - - @override - IMessage? getMessage(Snowflake id) { - if (shouldFail) { - return null; - } - - return MockMessage({"content": testMessageContent}, id); - } - - @override - Future sendMessage(MessageBuilder builder) => Future.value(MockMessage({"content": builder.content}, testMessageId)); -} diff --git a/test/mocks/client.dart b/test/mocks/client.dart new file mode 100644 index 000000000..f37af154a --- /dev/null +++ b/test/mocks/client.dart @@ -0,0 +1,12 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx/src/manager_mixin.dart'; + +import 'gateway.dart'; + +class MockNyxx with Mock, ManagerMixin implements NyxxRest {} + +class MockNyxxGateway with Mock, ManagerMixin implements NyxxGateway { + @override + Gateway get gateway => MockGateway(); +} diff --git a/test/mocks/enum.mock.dart b/test/mocks/enum.mock.dart deleted file mode 100644 index 39581fd03..000000000 --- a/test/mocks/enum.mock.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/fake.dart'; - -class EnumMock extends IEnum with Fake { - EnumMock(String value) : super(value); -} diff --git a/test/mocks/gateway.dart b/test/mocks/gateway.dart new file mode 100644 index 000000000..9912d9f3a --- /dev/null +++ b/test/mocks/gateway.dart @@ -0,0 +1,4 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; + +class MockGateway with Mock implements Gateway {} diff --git a/test/mocks/handler.dart b/test/mocks/handler.dart new file mode 100644 index 000000000..3fc078849 --- /dev/null +++ b/test/mocks/handler.dart @@ -0,0 +1,4 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; + +class MockHttpHandler with Mock implements HttpHandler {} diff --git a/test/mocks/member.mock.dart b/test/mocks/member.mock.dart deleted file mode 100644 index 9b8766058..000000000 --- a/test/mocks/member.mock.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:mockito/mockito.dart'; -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; - -import 'nyxx_rest.mock.dart'; - -class MockMember extends SnowflakeEntity with Fake implements IMember { - @override - Cacheable get user => UserCacheable(NyxxRestEmptyMock(), Snowflake.zero()); - - // TODO: not ideal way of handling these kind of stuff. Should be moved to some kind of helper to maintain single logic for formatting everywhere - @override - String get mention => '<@$id>'; - - MockMember(Snowflake id) : super(id); -} diff --git a/test/mocks/message.mock.dart b/test/mocks/message.mock.dart deleted file mode 100644 index c61d100ad..000000000 --- a/test/mocks/message.mock.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:mockito/mockito.dart'; -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; - -import 'nyxx_rest.mock.dart'; - -class MockMessage extends SnowflakeEntity with Fake implements IMessage { - @override - late String content; - - @override - Cacheable? get guild => GuildCacheable(NyxxRestEmptyMock(), Snowflake.zero()); - - @override - IMember? get member => null; - - MockMessage(RawApiMap rawData, Snowflake id) : super(id) { - content = rawData["content"] as String; - } -} diff --git a/test/mocks/nyxx_rest.mock.dart b/test/mocks/nyxx_rest.mock.dart deleted file mode 100644 index eee327550..000000000 --- a/test/mocks/nyxx_rest.mock.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:mockito/mockito.dart'; -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx/src/internal/cdn_http_endpoints.dart'; -import 'package:nyxx/src/internal/event_controller.dart'; -import 'package:nyxx/src/internal/http/http_handler.dart'; -import 'package:nyxx/src/internal/http_endpoints.dart'; - -class NyxxRestEmptyMock extends Fake implements INyxxRest { - @override - SnowflakeCache get users => SnowflakeCache(); -} - -class NyxxRestMock extends Fake implements INyxxRest { - @override - IHttpEndpoints get httpEndpoints => HttpEndpoints(this); - - @override - HttpHandler get httpHandler => HttpHandler(this); - - @override - IRestEventController get eventsRest => RestEventController(); - - @override - ICdnHttpEndpoints get cdnHttpEndpoints => CdnHttpEndpoints(); - - @override - String get token => "test-token"; -} diff --git a/test/mocks/request.dart b/test/mocks/request.dart new file mode 100644 index 000000000..aa59aebfb --- /dev/null +++ b/test/mocks/request.dart @@ -0,0 +1,4 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; + +class MockHttpRequest with Mock implements HttpRequest {} diff --git a/test/mocks/response.dart b/test/mocks/response.dart new file mode 100644 index 000000000..45e09d1a7 --- /dev/null +++ b/test/mocks/response.dart @@ -0,0 +1,4 @@ +import 'package:http/http.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockPackageHttpResponse with Mock implements Response {} diff --git a/test/test_endpoint.dart b/test/test_endpoint.dart new file mode 100644 index 000000000..ee8d84495 --- /dev/null +++ b/test/test_endpoint.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:nock/nock.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import 'package:nock/src/interceptor.dart'; + +import 'mocks/client.dart'; + +Future testEndpoint( + Pattern endpointMatcher, + Future Function(NyxxRest) run, { + required Object? response, + String? name, + String method = 'get', +}) async { + group(name ?? endpointMatcher, () { + setUpAll(() => nock.init()); + tearDownAll(() => nock.cleanAll()); + + // This test ensures code uses HttpHandler.executeSafe instead of HttpHandler.execute + test('respects response status', () async { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + when(() => client.httpHandler).thenReturn(HttpHandler(client)); + + final interceptor = Interceptor(RequestMatcher( + method, + UriMatcher('https://discord.com/api/v${client.apiOptions.apiVersion}', endpointMatcher), + BodyMatcher((_, __) => true), + )) + ..reply(400, jsonEncode({'message': 'Intentional testing error', 'code': -1})); + + await expectLater(() => run(client), throwsA(isA())); + + expect(interceptor.isDone, isTrue, reason: 'endpoint call should call the API'); + }); + + test('works', () async { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + when(() => client.httpHandler).thenReturn(HttpHandler(client)); + + final interceptor = Interceptor(RequestMatcher( + method, + UriMatcher('https://discord.com/api/v${client.apiOptions.apiVersion}', endpointMatcher), + BodyMatcher((_) => true), + )) + ..reply(200, jsonEncode(response)); + + await run(client); + + expect(interceptor.isDone, isTrue, reason: 'endpoint call should call the API'); + }); + }); +} diff --git a/test/test_manager.dart b/test/test_manager.dart new file mode 100644 index 000000000..fc4d13024 --- /dev/null +++ b/test/test_manager.dart @@ -0,0 +1,294 @@ +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:nock/nock.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import 'package:nock/src/interceptor.dart'; + +import 'mocks/client.dart'; +import 'test_endpoint.dart'; + +class ParsingTest { + final String name; + + final V source; + final U Function(V) Function(T) parse; + final void Function(U) check; + + ParsingTest({ + required this.name, + required this.source, + required this.parse, + required this.check, + }); + + void runWithManager(T manager) { + final object = parse(manager)(source); + check(object); + } +} + +class EndpointTest { + final String name; + + final String method; + final Pattern urlMatcher; + final V source; + final Future Function(T) execute; + final void Function(U) check; + + EndpointTest({ + this.method = 'get', + required this.name, + required this.source, + required this.urlMatcher, + required this.execute, + required this.check, + }); + + Future runWithManager(T manager) async { + final entity = await execute(manager); + check(entity); + } +} + +Future testReadOnlyManager, U extends ReadOnlyManager>( + String name, + U Function(CacheConfig, NyxxRest) create, + Pattern baseUrlMatcher, { + required Map sampleObject, + required void Function(T) sampleMatches, + List>? additionalSampleObjects, + List? additionalSampleMatchers, + required List>? additionalParsingTests, + required List>? additionalEndpointTests, + void Function()? extraRun, + Object? fetchObjectOverride, +}) async { + assert( + additionalSampleMatchers?.length == additionalSampleObjects?.length, + 'Unbalanced sample object and matcher count', + ); + + group(name, () { + test('parse', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + final config = CacheConfig(); + + final manager = create(config, client); + + final object = manager.parse(sampleObject); + sampleMatches(object); + + for (int i = 0; i < (additionalSampleObjects?.length ?? 0); i++) { + final sample = additionalSampleObjects![i]; + final matcher = additionalSampleMatchers![i]; + + final object = manager.parse(sample); + matcher(object); + } + }); + + if (additionalParsingTests != null) { + for (final parsingTest in additionalParsingTests) { + test(parsingTest.name, () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + final config = CacheConfig(); + + final manager = create(config, client); + parsingTest.runWithManager(manager); + }); + } + } + + testEndpoint( + name: 'fetch', + baseUrlMatcher, + response: fetchObjectOverride ?? sampleObject, + (client) async { + final manager = create(CacheConfig(), client); + + final entity = await manager.fetch(Snowflake(1)); + sampleMatches(entity); + }, + ); + + test('fetch caches entity', () async { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + when(() => client.httpHandler).thenReturn(HttpHandler(client)); + + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get(baseUrlMatcher).reply(200, jsonEncode(fetchObjectOverride ?? sampleObject)); + + final manager = create(CacheConfig(), client); + final entity = await manager.fetch(Snowflake(1)); + + expect(manager.cache.containsKey(entity.id), isTrue); + }); + + for (int i = 0; i < (additionalSampleObjects?.length ?? 0); i++) { + final sample = additionalSampleObjects![i]; + final matcher = additionalSampleMatchers![i]; + + testEndpoint( + name: 'fetch ($i)', + baseUrlMatcher, + response: sample, + (client) async { + final manager = create(CacheConfig(), client); + + final entity = await manager.fetch(Snowflake(1)); + matcher(entity); + }, + ); + } + + if (additionalEndpointTests != null) { + for (final endpointTest in additionalEndpointTests) { + testEndpoint( + method: endpointTest.method, + name: endpointTest.name, + endpointTest.urlMatcher, + response: endpointTest.source, + (client) async { + final manager = create(CacheConfig(), client); + await endpointTest.runWithManager(manager); + }, + ); + } + } + + extraRun?.call(); + }); +} + +Future testManager, U extends Manager>( + String name, + U Function(CacheConfig, NyxxRest) create, + Pattern baseUrlMatcher, + Pattern createUrlMatcher, { + required Map sampleObject, + required void Function(T) sampleMatches, + List>? additionalSampleObjects, + List? additionalSampleMatchers, + required List>? additionalParsingTests, + required List>? additionalEndpointTests, + required CreateBuilder createBuilder, + required UpdateBuilder updateBuilder, + String createMethod = 'POST', + Object? fetchObjectOverride, +}) async { + await testReadOnlyManager( + name, + create, + baseUrlMatcher, + sampleObject: sampleObject, + sampleMatches: sampleMatches, + additionalSampleObjects: additionalSampleObjects, + additionalSampleMatchers: additionalSampleMatchers, + additionalParsingTests: additionalParsingTests, + additionalEndpointTests: additionalEndpointTests, + fetchObjectOverride: fetchObjectOverride, + extraRun: () { + testEndpoint( + name: 'create', + method: createMethod, + createUrlMatcher, + response: sampleObject, + (client) async { + final manager = create(CacheConfig(), client); + + final entity = await manager.create(createBuilder); + sampleMatches(entity); + }, + ); + + test('create caches entity', () async { + nock.init(); + + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + when(() => client.httpHandler).thenReturn(HttpHandler(client)); + + Interceptor(RequestMatcher( + createMethod, + UriMatcher('https://discord.com/api/v${client.apiOptions.apiVersion}', createUrlMatcher), + BodyMatcher((_, __) => true), + )).reply(200, jsonEncode(sampleObject)); + + final manager = create(CacheConfig(), client); + final entity = await manager.create(createBuilder); + + expect(manager.cache.containsKey(entity.id), isTrue); + + nock.cleanAll(); + }); + + testEndpoint( + name: 'update', + method: 'PATCH', + baseUrlMatcher, + response: sampleObject, + (client) async { + final manager = create(CacheConfig(), client); + + final entity = await manager.update(Snowflake(1), updateBuilder); + sampleMatches(entity); + }, + ); + + test('update caches entity', () async { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + when(() => client.httpHandler).thenReturn(HttpHandler(client)); + + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').patch(baseUrlMatcher, (_) => true).reply(200, jsonEncode(sampleObject)); + + final manager = create(CacheConfig(), client); + final entity = await manager.update(Snowflake(1), updateBuilder); + + expect(manager.cache.containsKey(entity.id), isTrue); + }); + + testEndpoint( + name: 'delete', + method: 'DELETE', + baseUrlMatcher, + response: null, + (client) async { + final manager = create(CacheConfig(), client); + + await manager.delete(Snowflake(1)); + }, + ); + + test('delete caches entity', () async { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + when(() => client.httpHandler).thenReturn(HttpHandler(client)); + + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').delete(baseUrlMatcher).reply(200, jsonEncode(sampleObject)); + + final manager = create(CacheConfig(), client); + final entity = manager.parse(sampleObject); + manager.cache[entity.id] = entity; + + await manager.delete(entity.id); + + await null; + + expect(manager.cache.containsKey(entity.id), isFalse); + }); + }, + ); +} diff --git a/test/unit/allowed_mention_test.dart b/test/unit/allowed_mention_test.dart deleted file mode 100644 index be4913e5c..000000000 --- a/test/unit/allowed_mention_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -main() { - test(".allow", () { - final testAllowed = AllowedMentions(); - - testAllowed.allow(reply: true, everyone: true, roles: true, users: true); - - final buildResult = testAllowed.build(); - final expectedResult = { - "parse": [ - "everyone", - "roles", - "users", - ], - "replied_user": true - }; - - expect(expectedResult, equals(buildResult)); - }); - - test('.suppressUser exception', () { - final testAllowed = AllowedMentions() - ..allow(users: false) - ..suppressUser(Snowflake(123)); - - expect(() => testAllowed.build(), throwsA(isA())); - }); - - test('.suppressUser', () { - final testAllowed = AllowedMentions() - ..allow(users: true) - ..suppressUser(Snowflake(123)) - ..suppressUsers([ - Snowflake(456), - Snowflake(789), - ]); - - final expectedResult = { - 'parse': ['users'], - 'replied_user': false, - 'users': [ - '123', - '456', - '789', - ] - }; - - expect(expectedResult, equals(testAllowed.build())); - }); - - test('.suppressRole exception', () { - final testAllowed = AllowedMentions() - ..allow(users: false) - ..suppressRole(Snowflake(123)); - - expect(() => testAllowed.build(), throwsA(isA())); - }); - - test('.suppressRole', () { - final testAllowed = AllowedMentions() - ..allow(roles: true) - ..suppressRole(Snowflake(123)) - ..suppressRoles([ - Snowflake(456), - Snowflake(789), - ]); - - final expectedResult = { - 'parse': ['roles'], - 'replied_user': false, - 'roles': [ - '123', - '456', - '789', - ] - }; - - expect(expectedResult, equals(testAllowed.build())); - }); -} diff --git a/test/unit/application_test.dart b/test/unit/application_test.dart deleted file mode 100644 index 0c37348cc..000000000 --- a/test/unit/application_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx/src/core/application/app_team.dart'; -import 'package:nyxx/src/core/application/client_oauth2_application.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -import '../mocks/nyxx_rest.mock.dart'; - -const exampleAppTeamPayload = { - "id": 567, - "icon": 'example_icon_hash', - 'name': 'this-is-app-team-name', - 'owner_user_id': 987654321, - "members": [ - { - "user": { - "username": "l7ssha", - "discriminator": "6712", - "id": 123456789, - }, - "membership_state": 10 - }, - { - "user": { - "username": "another_account", - "discriminator": "123", - "avatar": "this_is_new_hash", - "id": 987654321, - }, - "membership_state": 10 - } - ] -}; - -const exampleClientOAuth2ApplicationPayload = { - "flags": 0x0101, - "owner": { - 'username': 'l7ssha', - 'discriminator': '6712', - 'id': 123456789, - 'avatar': null, - 'bot': false, - 'system': null, - 'public_flags': 1 << 0, - 'banner': 'banner-hash', - 'accent_color': 0x808080, - }, - 'description': "this is example description", - 'name': 'this-is-app-name', - 'rpcOrigins': null, - 'id': 123456, - 'verify_key': 'aaaaaabbbb', - 'bot_public': false, - 'bot_require_code_grant': false, -}; - -main() { - test("test constructor AppTeam", () { - final resultEntity = AppTeam(exampleAppTeamPayload, NyxxRestMock()); - - expect(resultEntity.ownerMember.user.id, equals(resultEntity.ownerId)); - expect(resultEntity.iconUrl(), equals("https://cdn.${Constants.cdnHost}/team-icons/567/example_icon_hash.webp")); - }); - - test("test constructor OAuth2Info", () { - final resultEntity = ClientOAuth2Application(exampleClientOAuth2ApplicationPayload, NyxxRestMock()); - - expect(resultEntity, isA()); - expect(resultEntity.getInviteUrl(), equals("https://${Constants.host}/oauth2/authorize?client_id=123456&scope=bot%20applications.commands")); - expect( - resultEntity.getInviteUrl(10), equals("https://${Constants.host}/oauth2/authorize?client_id=123456&scope=bot%20applications.commands&permissions=10")); - expect(resultEntity.iconUrl(), isNull); - - Map cloneExampleClientOAuth2ApplicationPayload = Map.from(exampleClientOAuth2ApplicationPayload); - cloneExampleClientOAuth2ApplicationPayload['icon'] = 'test'; - cloneExampleClientOAuth2ApplicationPayload['cover_image'] = 'test_cover'; - - final resultEntityWithIcon = ClientOAuth2Application(cloneExampleClientOAuth2ApplicationPayload, NyxxRestMock()); - expect(resultEntityWithIcon.iconUrl(), "https://cdn.discordapp.com/app-icons/123456/test.webp"); - expect(resultEntityWithIcon.coverImageUrl(), 'https://cdn.discordapp.com/app-icons/123456/test_cover.webp'); - }); -} diff --git a/test/unit/builders/channel/forum_tag_test.dart b/test/unit/builders/channel/forum_tag_test.dart new file mode 100644 index 000000000..130a020f6 --- /dev/null +++ b/test/unit/builders/channel/forum_tag_test.dart @@ -0,0 +1,27 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + test('ForumTagBuilder', () { + final builder = ForumTagBuilder(name: 'test'); + + expect(builder.build(), equals({'name': 'test'})); + + final builder2 = ForumTagBuilder( + name: 'test', + emojiId: Snowflake.zero, + emojiName: 'test2', + isModerated: false, + ); + + expect( + builder2.build(), + equals({ + 'name': 'test', + 'emoji_id': '0', + 'emoji_name': 'test2', + 'moderated': false, + }), + ); + }); +} diff --git a/test/unit/builders/channel/group_dm_test.dart b/test/unit/builders/channel/group_dm_test.dart new file mode 100644 index 000000000..63515fbf9 --- /dev/null +++ b/test/unit/builders/channel/group_dm_test.dart @@ -0,0 +1,10 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + test('GroupDmUpdateBuilder', () { + final builder = GroupDmUpdateBuilder(name: 'test', icon: [0, 1, 2]); + + expect(builder.build(), equals({'name': 'test', 'icon': 'AAEC'})); + }); +} diff --git a/test/unit/builders/channel/guild_channel_test.dart b/test/unit/builders/channel/guild_channel_test.dart new file mode 100644 index 000000000..e1f209e29 --- /dev/null +++ b/test/unit/builders/channel/guild_channel_test.dart @@ -0,0 +1,130 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + test('GuildTextChannelUpdateBuilder', () { + final builder = GuildTextChannelUpdateBuilder( + name: 'test', + position: 10, + permissionOverwrites: [], + type: ChannelType.guildAnnouncement, + topic: 'foobar', + isNsfw: false, + rateLimitPerUser: Duration.zero, + parentId: Snowflake.zero, + defaultAutoArchiveDuration: Duration.zero, + defaultThreadRateLimitPerUser: Duration.zero, + ); + + expect( + builder.build(), + equals({ + 'name': 'test', + 'position': 10, + 'permission_overwrites': [], + 'type': 5, + 'topic': 'foobar', + 'nsfw': false, + 'rate_limit_per_user': 0, + 'parent_id': '0', + 'default_auto_archive_duration': 0, + 'default_thread_rate_limit_per_user': 0, + }), + ); + }); + + test('GuildAnnouncementChannelUpdateBuilder', () { + final builder = GuildAnnouncementChannelUpdateBuilder( + name: 'test', + position: 10, + permissionOverwrites: [], + type: ChannelType.guildText, + topic: 'foobar', + isNsfw: false, + parentId: Snowflake.zero, + defaultAutoArchiveDuration: Duration.zero, + ); + + expect( + builder.build(), + equals({ + 'name': 'test', + 'position': 10, + 'permission_overwrites': [], + 'type': 0, + 'topic': 'foobar', + 'nsfw': false, + 'parent_id': '0', + 'default_auto_archive_duration': 0, + }), + ); + }); + + test('ForumChannelUpdateBuilder', () { + final builder = ForumChannelUpdateBuilder( + name: 'test', + position: 10, + permissionOverwrites: [], + topic: 'foobar', + isNsfw: false, + rateLimitPerUser: Duration.zero, + parentId: Snowflake.zero, + defaultAutoArchiveDuration: Duration.zero, + flags: ChannelFlags.requireTag, + tags: [], + defaultReaction: null, + defaultThreadRateLimitPerUser: Duration.zero, + defaultSortOrder: ForumSort.creationDate, + defaultLayout: ForumLayout.galleryView, + ); + + expect( + builder.build(), + equals({ + 'name': 'test', + 'position': 10, + 'permission_overwrites': [], + 'topic': 'foobar', + 'nsfw': false, + 'rate_limit_per_user': 0, + 'parent_id': '0', + 'default_auto_archive_duration': 0, + 'flags': 1 << 4, + 'available_tags': [], + 'default_reaction_emoji': null, + 'default_thread_rate_limit_per_user': 0, + 'default_sort_order': 1, + 'default_forum_layout': 2, + }), + ); + }); + + test('GuildVoiceChannelUpdateBuilder', () { + final builder = GuildVoiceChannelUpdateBuilder( + name: 'test', + position: 10, + permissionOverwrites: [], + isNsfw: false, + bitRate: 100, + userLimit: 10, + parentId: Snowflake.zero, + rtcRegion: null, + videoQualityMode: VideoQualityMode.auto, + ); + + expect( + builder.build(), + equals({ + 'name': 'test', + 'position': 10, + 'permission_overwrites': [], + 'nsfw': false, + 'bitrate': 100, + 'user_limit': 10, + 'parent_id': '0', + 'rtc_region': null, + 'video_quality_mode': 1, + }), + ); + }); +} diff --git a/test/unit/builders/channel/thread_test.dart b/test/unit/builders/channel/thread_test.dart new file mode 100644 index 000000000..a6639d162 --- /dev/null +++ b/test/unit/builders/channel/thread_test.dart @@ -0,0 +1,90 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + test('ThreadFromMessageBuilder', () { + final builder = ThreadFromMessageBuilder( + name: 'test', + autoArchiveDuration: Duration(minutes: 1), + rateLimitPerUser: Duration(seconds: 2), + ); + + expect( + builder.build(), + equals({ + 'name': 'test', + 'auto_archive_duration': 1, + 'rate_limit_per_user': 2, + }), + ); + }); + + test('ThreadBuilder', () { + final builder = ThreadBuilder( + name: 'test', + type: ChannelType.publicThread, + autoArchiveDuration: Duration(minutes: 1), + rateLimitPerUser: Duration(seconds: 2), + invitable: false, + ); + + expect( + builder.build(), + equals({ + 'name': 'test', + 'auto_archive_duration': 1, + 'type': 11, + 'invitable': false, + 'rate_limit_per_user': 2, + }), + ); + }); + + test('ForumThreadBuilder', () { + final builder = ForumThreadBuilder( + name: 'test', + message: MessageBuilder(), + appliedTags: [Snowflake.zero], + autoArchiveDuration: Duration(minutes: 1), + rateLimitPerUser: Duration(seconds: 2), + ); + + expect( + builder.build(), + equals({ + 'name': 'test', + 'message': {}, + 'applied_tags': ['0'], + 'auto_archive_duration': 1, + 'rate_limit_per_user': 2, + }), + ); + }); + + test('ThreadUpdateBuilder', () { + final builder = ThreadUpdateBuilder( + name: 'test', + isArchived: true, + autoArchiveDuration: Duration.zero, + isLocked: false, + isInvitable: true, + rateLimitPerUser: null, + flags: ChannelFlags.pinned, + appliedTags: [Snowflake.zero], + ); + + expect( + builder.build(), + equals({ + 'name': 'test', + 'archived': true, + 'auto_archive_duration': 0, + 'locked': false, + 'invitable': true, + 'rate_limit_per_user': null, + 'flags': 2, + 'applied_tags': ['0'], + }), + ); + }); +} diff --git a/test/unit/builders/image_test.dart b/test/unit/builders/image_test.dart new file mode 100644 index 000000000..d65d0d4dd --- /dev/null +++ b/test/unit/builders/image_test.dart @@ -0,0 +1,15 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + group('ImageBuilder', () { + test('build', () { + final builder = ImageBuilder( + format: 'png', + data: [0, 0, 0, 255, 255, 255], + ); + + expect(builder.buildDataString(), equals('data:image/png;base64,AAAA////')); + }); + }); +} diff --git a/test/unit/builders/message/allowed_mentions_test.dart b/test/unit/builders/message/allowed_mentions_test.dart new file mode 100644 index 000000000..086052990 --- /dev/null +++ b/test/unit/builders/message/allowed_mentions_test.dart @@ -0,0 +1,104 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + group('AllowedMentions', () { + test('build', () { + final builder = AllowedMentions( + parse: ['a', 'b', 'c'], + repliedUser: false, + roles: [Snowflake.zero], + users: [Snowflake(1)], + ); + + expect( + builder.build(), + equals({ + 'parse': ['a', 'b', 'c'], + 'replied_user': false, + 'roles': ['0'], + 'users': ['1'], + }), + ); + + final builder2 = AllowedMentions.roles([Snowflake.zero]); + + expect( + builder2.build(), + equals({ + 'roles': ['0'], + }), + ); + + final builder3 = AllowedMentions.users(); + + expect( + builder3.build(), + equals({ + 'parse': ['users'], + }), + ); + }); + + test('operator |', () { + final a = AllowedMentions.users([Snowflake(1), Snowflake(2), Snowflake(3)]); + final b = AllowedMentions( + repliedUser: true, + users: [Snowflake(1)], + roles: [Snowflake.zero], + parse: ['everyone'], + ); + + final builder = a | b; + + expect(builder.parse, equals(['everyone'])); + expect(builder.users, equals([Snowflake(1), Snowflake(2), Snowflake(3)])); + expect(builder.roles, equals([Snowflake.zero])); + expect(builder.repliedUser, isTrue); + }); + + test('operator &', () { + final a = AllowedMentions.users([Snowflake(1), Snowflake(2), Snowflake(3)]); + final b = AllowedMentions( + repliedUser: true, + users: [Snowflake(1)], + roles: [Snowflake.zero], + parse: ['everyone'], + ); + + final builder = a & b; + + expect(builder.parse, []); + expect(builder.users, equals([Snowflake(1)])); + expect(builder.roles, []); + expect(builder.repliedUser, isFalse); + + final parseIntersect1 = AllowedMentions(parse: ['users', 'roles']); + final parseIntersect2 = AllowedMentions(parse: ['roles', 'everyone']); + + expect((parseIntersect1 & parseIntersect2).parse, equals(['roles'])); + + final user1 = AllowedMentions.users(); + final user2 = AllowedMentions(users: [Snowflake.zero]); + + expect((user1 & user2).parse, isEmpty); + expect((user1 & user2).users, equals([Snowflake.zero])); + + final user3 = AllowedMentions(); + + expect((user1 & user3).parse, isEmpty); + expect((user1 & user3).users, isNull); + + final role1 = AllowedMentions.roles(); + final role2 = AllowedMentions(roles: [Snowflake.zero]); + + expect((role1 & role2).parse, isEmpty); + expect((role1 & role2).roles, equals([Snowflake.zero])); + + final role3 = AllowedMentions(); + + expect((role1 & role3).parse, isEmpty); + expect((role1 & role3).roles, isNull); + }); + }); +} diff --git a/test/unit/builders/message/attachment_test.dart b/test/unit/builders/message/attachment_test.dart new file mode 100644 index 000000000..6b647c355 --- /dev/null +++ b/test/unit/builders/message/attachment_test.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + test('AttachmentBuilder', () { + final builder = AttachmentBuilder( + data: [1, 2, 3], + description: 'A test description', + fileName: 'foo.dart', + ); + + expect( + builder.build(), + equals({ + 'filename': 'foo.dart', + 'description': 'A test description', + }), + ); + }); + + test('AttachmentBuilder.fromFile', () async { + final builder = await AttachmentBuilder.fromFile(File('test/files/1.png')); + + expect(builder.data, equals(await File('test/files/1.png').readAsBytes())); + expect(builder.fileName, equals('1.png')); + }); +} diff --git a/test/unit/builders/message/message_test.dart b/test/unit/builders/message/message_test.dart new file mode 100644 index 000000000..f703e1188 --- /dev/null +++ b/test/unit/builders/message/message_test.dart @@ -0,0 +1,162 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + test('MessageBuilder', () { + final builder = MessageBuilder( + allowedMentions: AllowedMentions.users([Snowflake.zero]), + attachments: [ + AttachmentBuilder(data: [1, 2, 3], fileName: 'test.dart'), + ], + content: 'test content', + embeds: [ + EmbedBuilder( + title: 'test embed title', + description: 'A test embed description', + url: Uri.https('discord.com', '/channels/@me'), + timestamp: DateTime.utc(2022), + color: DiscordColor.fromRgb(0, 0, 0), + footer: EmbedFooterBuilder(text: 'footer text'), + image: EmbedImageBuilder( + url: Uri.parse('https://github.com/dart-lang/sdk/raw/main/docs/assets/Dart-platforms.svg'), + ), + thumbnail: EmbedThumbnailBuilder( + url: Uri.parse('https://github.com/dart-lang/sdk/raw/main/docs/assets/Dart-platforms.svg'), + ), + author: EmbedAuthorBuilder(name: 'test embed author'), + fields: [ + EmbedFieldBuilder(name: 'whoops', value: 'no error here', isInline: false), + ], + ), + ], + nonce: '1234', + replyId: null, + stickerIds: [Snowflake.zero], + suppressEmbeds: false, + suppressNotifications: true, + tts: false, + ); + + expect( + builder.build(), + equals({ + 'content': 'test content', + 'nonce': '1234', + 'tts': false, + 'embeds': [ + { + 'title': 'test embed title', + 'description': 'A test embed description', + 'url': 'https://discord.com/channels/@me', + 'timestamp': '2022-01-01T00:00:00.000Z', + 'color': 0, + 'footer': { + 'text': 'footer text', + }, + 'image': { + 'url': 'https://github.com/dart-lang/sdk/raw/main/docs/assets/Dart-platforms.svg', + }, + 'thumbnail': { + 'url': 'https://github.com/dart-lang/sdk/raw/main/docs/assets/Dart-platforms.svg', + }, + 'author': { + 'name': 'test embed author', + }, + 'fields': [ + { + 'name': 'whoops', + 'value': 'no error here', + 'inline': false, + }, + ], + }, + ], + 'allowed_mentions': { + 'users': ['0'], + }, + 'sticker_ids': ['0'], + 'attachments': [ + { + 'filename': 'test.dart', + } + ], + 'flags': 4096, + }), + ); + }); + + test('MessageUpdateBuilder', () { + final builder = MessageUpdateBuilder( + allowedMentions: AllowedMentions.users([Snowflake.zero]), + attachments: [ + AttachmentBuilder(data: [1, 2, 3], fileName: 'test.dart'), + ], + content: 'test content', + embeds: [ + EmbedBuilder( + title: 'test embed title', + description: 'A test embed description', + url: Uri.https('discord.com', '/channels/@me'), + timestamp: DateTime.utc(2022), + color: DiscordColor.fromRgb(0, 0, 0), + footer: EmbedFooterBuilder(text: 'footer text'), + image: EmbedImageBuilder( + url: Uri.parse('https://github.com/dart-lang/sdk/raw/main/docs/assets/Dart-platforms.svg'), + ), + thumbnail: EmbedThumbnailBuilder( + url: Uri.parse('https://github.com/dart-lang/sdk/raw/main/docs/assets/Dart-platforms.svg'), + ), + author: EmbedAuthorBuilder(name: 'test embed author'), + fields: [ + EmbedFieldBuilder(name: 'whoops', value: 'no error here', isInline: false), + ], + ), + ], + suppressEmbeds: true, + ); + + expect( + builder.build(), + equals({ + 'content': 'test content', + 'embeds': [ + { + 'title': 'test embed title', + 'description': 'A test embed description', + 'url': 'https://discord.com/channels/@me', + 'timestamp': '2022-01-01T00:00:00.000Z', + 'color': 0, + 'footer': { + 'text': 'footer text', + }, + 'image': { + 'url': 'https://github.com/dart-lang/sdk/raw/main/docs/assets/Dart-platforms.svg', + }, + 'thumbnail': { + 'url': 'https://github.com/dart-lang/sdk/raw/main/docs/assets/Dart-platforms.svg', + }, + 'author': { + 'name': 'test embed author', + }, + 'fields': [ + { + 'name': 'whoops', + 'value': 'no error here', + 'inline': false, + }, + ], + }, + ], + 'allowed_mentions': { + 'users': ['0'], + }, + 'attachments': [ + { + 'filename': 'test.dart', + } + ], + 'flags': 4, + }), + ); + }); +} diff --git a/test/unit/builders/permission_overwrite_test.dart b/test/unit/builders/permission_overwrite_test.dart new file mode 100644 index 000000000..67165395a --- /dev/null +++ b/test/unit/builders/permission_overwrite_test.dart @@ -0,0 +1,29 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + test('PermissionOverwriteBuilder', () { + final builder = PermissionOverwriteBuilder(id: Snowflake.zero, type: PermissionOverwriteType.member); + + expect( + builder.build(), + equals({'type': 1}), + ); + + final builder2 = PermissionOverwriteBuilder( + id: Snowflake.zero, + type: PermissionOverwriteType.role, + allow: Permissions.addReactions | Permissions.connect, + deny: Permissions.administrator, + ); + + expect( + builder2.build(), + equals({ + 'type': 0, + 'allow': '1048640', + 'deny': '8', + }), + ); + }); +} diff --git a/test/unit/builders/user_test.dart b/test/unit/builders/user_test.dart new file mode 100644 index 000000000..ae2ffa32c --- /dev/null +++ b/test/unit/builders/user_test.dart @@ -0,0 +1,26 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + group('UserUpdateBuilder', () { + test('build', () { + final builder = UserUpdateBuilder(username: 'foo', avatar: ImageBuilder.png([])); + expect( + builder.build(), + equals({ + 'username': 'foo', + 'avatar': 'data:image/png;base64,', + }), + ); + + final builder2 = UserUpdateBuilder(avatar: ImageBuilder.png([])); + expect(builder2.build(), equals({'avatar': 'data:image/png;base64,'})); + + final builder3 = UserUpdateBuilder(username: 'foo'); + expect(builder3.build(), equals({'username': 'foo'})); + + final builder4 = UserUpdateBuilder(); + expect(builder4.build(), equals({})); + }); + }); +} diff --git a/test/unit/builders_test.dart b/test/unit/builders_test.dart deleted file mode 100644 index 70e5fb6a9..000000000 --- a/test/unit/builders_test.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'package:nyxx/src/core/channel/text_channel.dart'; -import 'package:nyxx/src/core/guild/auto_moderation.dart'; -import 'package:nyxx/src/core/guild/status.dart'; -import 'package:nyxx/src/core/permissions/permissions.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/core/user/presence.dart'; -import 'package:nyxx/src/internal/cache/cacheable.dart'; -import 'package:nyxx/src/utils/builders/attachment_builder.dart'; -import 'package:nyxx/src/utils/builders/auto_moderation_builder.dart'; -import 'package:nyxx/src/utils/builders/channel_builder.dart'; -import 'package:nyxx/src/utils/builders/embed_builder.dart'; -import 'package:nyxx/src/utils/builders/forum_thread_builder.dart'; -import 'package:nyxx/src/utils/builders/member_builder.dart'; -import 'package:nyxx/src/utils/builders/message_builder.dart'; -import 'package:nyxx/src/utils/builders/permissions_builder.dart'; -import 'package:nyxx/src/utils/builders/presence_builder.dart'; -import 'package:nyxx/src/utils/builders/reply_builder.dart'; -import 'package:nyxx/src/utils/builders/sticker_builder.dart'; -import 'package:nyxx/src/utils/builders/thread_builder.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -import '../mocks/member.mock.dart'; -import '../mocks/message.mock.dart'; -import '../mocks/nyxx_rest.mock.dart'; - -main() { - test("ThreadBuilder", () { - final publicBuilder = ThreadBuilder('test name') - ..archiveAfter = ThreadArchiveTime.threeDays - ..private = false; - expect(publicBuilder.build(), equals({"auto_archive_duration": ThreadArchiveTime.threeDays.value, "name": 'test name', "type": 11})); - - final privateBuilder = ThreadBuilder("second name")..private = true; - expect(privateBuilder.build(), equals({"name": 'second name', "type": 12})); - }); - - test('StickerBuilder', () { - final builder = StickerBuilder(file: AttachmentBuilder.bytes([], 'foo')) - ..name = "this is name" - ..description = "this is description" - ..tags = "tags"; - - expect(builder.build(), equals({"name": "this is name", "description": "this is description", "tags": "tags", 'file': 'data:image/png;base64,'})); - }); - - test("ReplyBuilder", () { - final basicBuilder = ReplyBuilder(Snowflake.zero()); - expect(basicBuilder.build(), equals({"message_id": '0', "fail_if_not_exists": false})); - - final messageBuilder = ReplyBuilder.fromMessage(MockMessage({"content": "content"}, Snowflake(123))); - expect(messageBuilder.build(), equals({"message_id": '123', "fail_if_not_exists": false})); - - final messageCacheable = MessageCacheable(NyxxRestEmptyMock(), Snowflake(123), ChannelCacheable(NyxxRestEmptyMock(), Snowflake(456))); - final cacheableBuilder = ReplyBuilder.fromCacheable(messageCacheable, true); - expect(cacheableBuilder.build(), equals({"message_id": '123', "fail_if_not_exists": true})); - }); - - group("channel builder", () { - test('ChannelBuilder', () { - final builder = TextChannelBuilder.create("test"); - builder.permissionOverrides = [PermissionOverrideBuilder.from(0, Snowflake.zero(), Permissions.empty())]; - - final expectedResult = { - 'permission_overwrites': [ - {'allow': "0", 'deny': "1649267441663", 'id': '0', 'type': 0} - ], - 'type': 0, - 'name': 'test' - }; - expect(builder.build(), expectedResult); - }); - }); - - group('presence_builder.dart', () { - test('PresenceBuilder', () { - final activityBuilder = ActivityBuilder.game("test game name"); - - final ofBuilder = PresenceBuilder.of(status: UserStatus.dnd, activity: activityBuilder); - expect( - ofBuilder.build(), - equals({ - 'status': UserStatus.dnd.toString(), - 'activities': [ - { - "name": "test game name", - "type": ActivityType.game.value, - } - ], - 'afk': false, - 'since': null, - })); - - final now = DateTime.now(); - final idleBuilder = PresenceBuilder.idle(since: now); - - expect( - idleBuilder.build(), - equals({ - 'status': UserStatus.idle.toString(), - 'afk': true, - 'since': now.millisecondsSinceEpoch, - })); - }); - - test('ActivityBuilder', () { - final streamingBuilder = ActivityBuilder.streaming("test game name", 'https://twitch.tv'); - expect(streamingBuilder.build(), equals({"name": "test game name", "type": ActivityType.streaming.value, "url": 'https://twitch.tv'})); - - final listeningBuilder = ActivityBuilder.listening("test listening name"); - expect( - listeningBuilder.build(), - equals({ - "name": "test listening name", - "type": ActivityType.listening.value, - })); - }); - }); - - test('PermissionOverrideBuilder', () { - final builder = PermissionOverrideBuilder(0, Snowflake.zero()); - expect(builder.build(), equals({"allow": "0", "deny": "0", 'id': '0', 'type': 0})); - - final fromBuilder = PermissionOverrideBuilder.from(0, Snowflake.zero(), Permissions.empty()); - expect(fromBuilder.build(), equals({"allow": "0", "deny": "1649267441663", 'id': '0', 'type': 0})); - expect(fromBuilder.calculatePermissionValue(), equals(0)); - - final ofBuilder = PermissionOverrideBuilder.of(MockMember(Snowflake.zero())) - ..sendMessages = true - ..addReactions = false; - - expect(ofBuilder.build(), equals({"allow": (1 << 11).toString(), "deny": (1 << 6).toString(), 'id': '0', 'type': 1})); - expect(ofBuilder.calculatePermissionValue(), equals(1 << 11)); - }); - - group('MemberBuilder', () { - test('channel empty', () { - final builder = MemberBuilder()..channel = Snowflake.zero(); - - expect({}, builder.build()); - }); - - test('channel with value', () { - final builder = MemberBuilder()..channel = Snowflake(123); - - expect({'channel_id': '123'}, builder.build()); - }); - - test('timeout empty', () { - final now = DateTime.now(); - - final builder = MemberBuilder()..timeoutUntil = now; - - expect({'communication_disabled_until': now.toIso8601String()}, builder.build()); - }); - - test('roles serialization', () { - final builder = MemberBuilder()..roles = [Snowflake(1), Snowflake(2)]; - - expect({ - 'roles': ['1', '2'] - }, builder.build()); - }); - }); - - group('MessageBuilder', () { - test('clear character', () { - final builder = MessageBuilder.empty(); - expect(builder.content, equals(MessageBuilder.clearCharacter)); - }); - - test('embeds', () async { - final builder = MessageBuilder.embed(EmbedBuilder()..description = 'test1') - ..flags = (MessageFlagBuilder() - ..suppressEmbeds = true - ..suppressNotifications = true); - await builder.addEmbed((embed) => embed.description = 'test2'); - - final result = builder.build(); - - expect( - result, - equals({ - 'content': '', - 'embeds': [ - {'description': 'test1'}, - {'description': 'test2'} - ], - 'flags': 1 << 2 | 1 << 12 - })); - }); - - test('text', () { - final dateTime = DateTime(2000); - - final builder = MessageBuilder() - ..appendSpoiler('spoiler') - ..appendNewLine() - ..appendItalics('italics') - ..appendBold('bold') - ..appendStrike('strike') - ..appendCodeSimple('this is code simple') - ..appendMention(MockMember(Snowflake.zero())) - ..appendTimestamp(dateTime) - ..appendCode('dart', 'final int = 124;'); - - expect( - builder.build(), - equals({ - 'content': '||spoiler||\n' - '*italics***bold**~~strike~~`this is code simple`<@0>\n' - '```dart\n' - 'final int = 124;```' - })); - - expect(builder.getMappedFiles(), isEmpty); - expect(builder.canBeUsedAsNewMessage(), isTrue); - - expect(MessageDecoration.bold.format('test'), equals('**test**')); - }); - - test('limitLength', () { - final builder = MessageBuilder.content('abc' * 1000)..limitLength(ellipsis: null); - - expect(builder.content, equals(('abc' * 1000).substring(0, 2000))); - - builder.limitLength(length: 10, ellipsis: '...'); - - expect(builder.content, equals('abcabca...')); - }); - - test("ForumThreadBuilder", () { - final builder = ForumThreadBuilder("test", message: MessageBuilder.content("test")); - - expect( - builder.build(), - equals({ - 'name': 'test', - 'message': {'content': 'test'} - })); - }); - }); - - test('AutoModerationRuleBuilder', () { - final rb = AutoModerationRuleBuilder( - 'Super cool rule', - eventType: EventTypes.messageSend, - triggerType: TriggerTypes.keyword, - actions: [ - ActionStructureBuilder( - ActionTypes.timeout, - ActionMetadataBuilder( - duration: Duration( - days: 1, - ), - ), - ), - ], - ) - ..triggerMetadata = (TriggerMetadataBuilder() - ..keywordFilter = ['hey', '*looks', 'wildcards!!*'] - ..allowList = ['wow*', 'im', 'allowed!'] - ..presets = [KeywordPresets.slurs]) - ..ignoredChannels = [Snowflake.zero()] - ..ignoredRoles = [Snowflake.zero()] - ..enabled = true; - - expect(rb.build(), { - 'name': 'Super cool rule', - 'event_type': 1, - 'trigger_type': 1, - 'actions': [ - { - 'type': 3, - 'metadata': { - 'duration_seconds': 86400, - } - } - ], - 'trigger_metadata': { - 'keyword_filter': ['hey', '*looks', 'wildcards!!*'], - 'presets': [3], - 'allow_list': ['wow*', 'im', 'allowed!'] - }, - 'enabled': true, - 'exempt_channels': ['0'], - 'exempt_roles': ['0'] - }); - }); -} diff --git a/test/unit/cache/cache_test.dart b/test/unit/cache/cache_test.dart new file mode 100644 index 000000000..33285c64e --- /dev/null +++ b/test/unit/cache/cache_test.dart @@ -0,0 +1,119 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../mocks/client.dart'; + +class MockSnowflakeEntity extends ManagedSnowflakeEntity with Fake { + MockSnowflakeEntity({required super.id}); +} + +void main() { + group('Cache', () { + test('stores entities', () async { + final cache = Cache(MockNyxx(), 'test', CacheConfig()); + + final entity = MockSnowflakeEntity(id: Snowflake.zero); + + cache[entity.id] = entity; + + expect(cache[entity.id], same(entity)); + + await null; + + // The cache filter shouldn't evict this item + expect(cache[entity.id], same(entity)); + }); + + test('respects maximum size', () async { + final cache = Cache(MockNyxx(), 'test', CacheConfig(maxSize: 3)); + + final entity1 = MockSnowflakeEntity(id: Snowflake(1)); + final entity2 = MockSnowflakeEntity(id: Snowflake(2)); + final entity3 = MockSnowflakeEntity(id: Snowflake(3)); + final entity4 = MockSnowflakeEntity(id: Snowflake(4)); + final entity5 = MockSnowflakeEntity(id: Snowflake(5)); + + for (final entity in [entity1, entity2, entity3, entity4, entity5]) { + cache[entity.id] = entity; + } + + await null; + + expect(cache, hasLength(3)); + }); + + test('keeps most used items', () async { + final cache = Cache(MockNyxx(), 'test', CacheConfig(maxSize: 3)); + + final entity1 = MockSnowflakeEntity(id: Snowflake(1)); + final entity2 = MockSnowflakeEntity(id: Snowflake(2)); + final entity3 = MockSnowflakeEntity(id: Snowflake(3)); + final entity4 = MockSnowflakeEntity(id: Snowflake(4)); + final entity5 = MockSnowflakeEntity(id: Snowflake(5)); + + for (final entity in [entity1, entity2, entity3, entity4, entity5]) { + cache[entity.id] = entity; + } + + for (int i = 0; i < 10; i++) { + // Bump up access count + cache[entity1.id]; + cache[entity3.id]; + cache[entity5.id]; + } + + await null; + + expect(cache.containsKey(entity1.id), isTrue); + expect(cache.containsKey(entity3.id), isTrue); + expect(cache.containsKey(entity5.id), isTrue); + + expect(cache.containsKey(entity2.id), isFalse); + expect(cache.containsKey(entity4.id), isFalse); + }); + + test("doesn't cache items if a filter is provided", () { + final cache = Cache(MockNyxx(), 'test', CacheConfig(shouldCache: (e) => e.id.value > 3)); + + final entity1 = MockSnowflakeEntity(id: Snowflake(1)); + final entity2 = MockSnowflakeEntity(id: Snowflake(2)); + final entity3 = MockSnowflakeEntity(id: Snowflake(3)); + final entity4 = MockSnowflakeEntity(id: Snowflake(4)); + final entity5 = MockSnowflakeEntity(id: Snowflake(5)); + + for (final entity in [entity1, entity2, entity3, entity4, entity5]) { + cache[entity.id] = entity; + } + + expect(cache.containsKey(entity1.id), isFalse); + expect(cache.containsKey(entity2.id), isFalse); + expect(cache.containsKey(entity3.id), isFalse); + + expect(cache.containsKey(entity4.id), isTrue); + expect(cache.containsKey(entity5.id), isTrue); + }); + + test('shares resources with the same identifier', () { + final client = MockNyxx(); + + final cache1 = Cache(client, 'test', CacheConfig()); + final cache2 = Cache(client, 'test', CacheConfig()); + + final entity = MockSnowflakeEntity(id: Snowflake.zero); + + cache1[entity.id] = entity; + expect(cache2[entity.id], equals(entity)); + }); + + test("doesn't share resources across clients", () { + final cache1 = Cache(MockNyxx(), 'test', CacheConfig()); + final cache2 = Cache(MockNyxx(), 'test', CacheConfig()); + + final entity = MockSnowflakeEntity(id: Snowflake.zero); + + cache1[entity.id] = entity; + expect(cache2.containsKey(entity.id), isFalse); + }); + }); +} diff --git a/test/unit/cache_test.dart b/test/unit/cache_test.dart deleted file mode 100644 index a07c1c196..000000000 --- a/test/unit/cache_test.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -import '../mocks/channel.mock.dart'; -import '../mocks/member.mock.dart'; -import '../mocks/message.mock.dart'; - -IMessage createMockMessage(Snowflake id) => MockMessage({"content": "i dont care"}, id); - -main() { - group("cache", () { - test("Snowflake cache", () { - final firstMessage = createMockMessage(Snowflake(1)); - final secondMessage = createMockMessage(Snowflake(2)); - final thirdMessage = createMockMessage(Snowflake(3)); - - final cache = SnowflakeCache(2); - - cache[firstMessage.id] = firstMessage; - cache[secondMessage.id] = secondMessage; - - expect(cache, hasLength(2)); - expect(cache[firstMessage.id], isNotNull); - expect(cache[secondMessage.id], isNotNull); - - cache[thirdMessage.id] = thirdMessage; - - expect(cache[firstMessage.id], isNull); - expect(cache[thirdMessage.id], isNotNull); - - cache.clear(); - - expect(cache, hasLength(0)); - - final alwaysEmptyCache = SnowflakeCache(0); - expect(alwaysEmptyCache, hasLength(0)); - cache[firstMessage.id] = firstMessage; - expect(alwaysEmptyCache, hasLength(0)); - }); - }); - - group("cache policy", () { - test("CachePolicyLocation", () { - final cachePolicyLocationAll = CachePolicyLocation.all(); - - expect(cachePolicyLocationAll.objectConstructor, isTrue); - expect(cachePolicyLocationAll.event, isTrue); - expect(cachePolicyLocationAll.other, isTrue); - expect(cachePolicyLocationAll.http, isTrue); - - final cachePolicyLocationNone = CachePolicyLocation.none(); - - expect(cachePolicyLocationNone.objectConstructor, isFalse); - expect(cachePolicyLocationNone.event, isFalse); - expect(cachePolicyLocationNone.other, isFalse); - expect(cachePolicyLocationNone.http, isFalse); - }); - - test('MemberCachePolicy', () { - final member = MockMember(Snowflake.zero()); - - final memberCachePolicyNone = MemberCachePolicy.none; - expect(memberCachePolicyNone.canCache(member), isFalse); - - final memberCachePolicyAll = MemberCachePolicy.all; - expect(memberCachePolicyAll.canCache(member), isTrue); - - final memberCachePolicyOnline = MemberCachePolicy.online; - expect(memberCachePolicyOnline.canCache(member), isFalse); - }); - - test('ChannelCachePolicy', () { - final voiceChannel = MockVoiceChannel(Snowflake.zero()); - final threadChannel = MockThreadChannel(Snowflake.zero()); - final textChannel = MockTextChannel(Snowflake.zero()); - - final channelCachePolicy = ChannelCachePolicy.none; - expect(channelCachePolicy.canCache(voiceChannel), isFalse); - - final channelCachePolicyVoice = ChannelCachePolicy.voice; - expect(channelCachePolicyVoice.canCache(voiceChannel), isTrue); - expect(channelCachePolicyVoice.canCache(textChannel), isFalse); - - final channelCachePolicyText = ChannelCachePolicy.text; - expect(channelCachePolicyText.canCache(textChannel), isTrue); - expect(channelCachePolicyText.canCache(threadChannel), isTrue); - expect(channelCachePolicyText.canCache(voiceChannel), isFalse); - - final channelCachePolicyThread = ChannelCachePolicy.thread; - expect(channelCachePolicyThread.canCache(textChannel), isFalse); - expect(channelCachePolicyThread.canCache(threadChannel), isTrue); - expect(channelCachePolicyThread.canCache(voiceChannel), isFalse); - }); - - test('MessageCachePolicy', () { - final message = createMockMessage(Snowflake.zero()); - - final messageCachePolicy = MessageCachePolicy.none; - expect(messageCachePolicy.canCache(message), isFalse); - - final messageCachePolicyGuild = MessageCachePolicy.guildMessages; - expect(messageCachePolicyGuild.canCache(message), isTrue); - - final messageCachePolicyDm = MessageCachePolicy.dmMessages; - expect(messageCachePolicyDm.canCache(message), isFalse); - }); - }); -} diff --git a/test/unit/channel_test.dart b/test/unit/channel_test.dart deleted file mode 100644 index 8a80f4e98..000000000 --- a/test/unit/channel_test.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/test.dart'; - -import '../mocks/channel.mock.dart'; - -void main() { - group("Channel:Text", () { - test("Send message", () async { - final mockChannel = MockTextChannel(testChannelId); - - final msg = await mockChannel.sendMessage(MessageBuilder.content(testMessageContent)); - - expect(msg, isNotNull); - expect(msg.id, testMessageId); - expect(msg.content, testMessageContent); - }); - - group("Get Message", () { - test("In Cache", () { - final mockChannel = MockTextChannel(testChannelId); - final messageId = 901383853648801823.toSnowflake(); - - final msg = mockChannel.getMessage(messageId); - - expect(msg, isNotNull); - expect(msg!.id, messageId); - }); - - test("Not In Cache", () { - final mockChannel = MockTextChannel(testChannelId, shouldFail: true); - final messageId = 901383853648801823.toSnowflake(); - - final msg = mockChannel.getMessage(messageId); - - expect(msg, isNull); - }); - }); - - test("Fetch Message", () async { - final mockChannel = MockTextChannel(testChannelId); - final messageId = 901383024170631230.toSnowflake(); - - final msg = await mockChannel.fetchMessage(messageId); - - expect(msg, isNotNull); - expect(msg.id, messageId); - expect(msg.content, testMessageContent); - }); - }); -} diff --git a/test/unit/discord_color_test.dart b/test/unit/discord_color_test.dart deleted file mode 100644 index 29edfce21..000000000 --- a/test/unit/discord_color_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -main() { - test(".fromInt", () { - final firstColor = DiscordColor.fromInt(0); - final secondColor = DiscordColor.fromInt(8224125); - - expect(0, equals(firstColor.value)); - expect(8224125, equals(secondColor.value)); - }); - - test(".fromRgb", () { - final firstColor = DiscordColor.fromRgb(0, 0, 0); - final secondColor = DiscordColor.fromRgb(125, 125, 125); - - expect(0, equals(firstColor.value)); - expect(8224125, equals(secondColor.value)); - }); - - test(".fromDouble", () { - final firstColor = DiscordColor.fromDouble(0, 0, 0); - final secondColor = DiscordColor.fromDouble(1, 1, 1); - - expect(0, equals(firstColor.value)); - expect(16777215, equals(secondColor.value)); - }); - - test(".fromHexString", () { - expect(() => DiscordColor.fromHexString(''), throwsA(isA())); - - final firstColor = DiscordColor.fromHexString('#000000'); - final secondColor = DiscordColor.fromHexString('000000'); - - expect(0, equals(firstColor)); - expect(0, equals(secondColor)); - }); - - test("base values", () { - final firstColor = DiscordColor.fromHexString('#646464'); - - expect(100, equals(firstColor.r)); - expect(100, equals(firstColor.g)); - expect(100, equals(firstColor.b)); - }); - - test(".toString", () { - final firstColor = DiscordColor.fromHexString('#646464'); - final secondColor = DiscordColor.fromHexString('#000000'); - - expect('#646464', equals(firstColor.toString())); - expect('#000000', equals(secondColor.toString())); - }); -} diff --git a/test/unit/http/bucket_test.dart b/test/unit/http/bucket_test.dart new file mode 100644 index 000000000..38782bf6c --- /dev/null +++ b/test/unit/http/bucket_test.dart @@ -0,0 +1,109 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../mocks/handler.dart'; +import '../../mocks/request.dart'; +import '../../mocks/response.dart'; + +void main() { + group('HttpBucket', () { + group('fromResponse', () { + test('returns null on invalid rate limit headers', () { + final response = MockPackageHttpResponse(); + final handler = MockHttpHandler(); + + when(() => response.headers).thenReturn({ + 'foo': 'bar', + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitRemaining: '15', + }); + + expect(HttpBucket.fromResponse(handler, response), isNull); + }); + + test('parses rate limit headers correctly', () { + final response = MockPackageHttpResponse(); + final handler = MockHttpHandler(); + + when(() => response.headers).thenReturn({ + 'foo': 'bar', + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '15', + HttpBucket.xRateLimitReset: '10000000', + HttpBucket.xRateLimitResetAfter: '17.5', + }); + + final bucket = HttpBucket.fromResponse(handler, response); + + expect(bucket, isNot(isNull)); + expect(bucket?.id, equals('testBucketId')); + expect(bucket?.remaining, equals(15)); + + // We're testing code with timings. Assume that up to 500ms lag could occur between the two calls. + const expectedDuration = Duration(seconds: 17, milliseconds: 500); + expect(bucket?.resetAfter.inMilliseconds, closeTo(expectedDuration.inMilliseconds, 500)); + expect(bucket?.resetAt.millisecondsSinceEpoch, closeTo(DateTime.now().add(expectedDuration).millisecondsSinceEpoch, 500)); + }); + }); + + test('updates correctly with updateWith', () { + final bucket = HttpBucket(MockHttpHandler(), id: 'testBucketId', remaining: 100, resetAt: DateTime(2000)); + final response = MockPackageHttpResponse(); + + when(() => response.headers).thenReturn({ + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '10', + HttpBucket.xRateLimitReset: '10000000', + HttpBucket.xRateLimitResetAfter: '100', + }); + + bucket.updateWith(response); + + expect(bucket.remaining, equals(10)); + expect(bucket.resetAt.millisecondsSinceEpoch, closeTo(DateTime.now().add(const Duration(seconds: 100)).millisecondsSinceEpoch, 500)); + }); + + test('contains', () { + final bucket = HttpBucket(MockHttpHandler(), id: 'testBucketId', remaining: 100, resetAt: DateTime(2000)); + + final response = MockPackageHttpResponse(); + when(() => response.headers).thenReturn({HttpBucket.xRateLimitBucket: 'testBucketId'}); + + expect(bucket.contains(response), isTrue); + + final response2 = MockPackageHttpResponse(); + when(() => response2.headers).thenReturn({HttpBucket.xRateLimitBucket: 'aDifferentId'}); + + expect(bucket.contains(response2), isFalse); + }); + + test('accounts for in-flight requests', () { + final bucket = HttpBucket(MockHttpHandler(), id: 'testBucketId', remaining: 100, resetAt: DateTime(2000)); + + expect(bucket.remaining, equals(100)); + + final request1 = MockHttpRequest(); + final request2 = MockHttpRequest(); + final request3 = MockHttpRequest(); + + bucket.addInflightRequest(request1); + expect(bucket.remaining, equals(99)); + + bucket.addInflightRequest(request2); + bucket.addInflightRequest(request3); + expect(bucket.remaining, equals(97)); + + bucket.removeInflightRequest(request1); + expect(bucket.remaining, equals(98)); + + bucket.removeInflightRequest(request3); + expect(bucket.remaining, equals(99)); + + bucket.removeInflightRequest(request2); + expect(bucket.remaining, equals(100)); + }); + }); +} diff --git a/test/unit/http/handler_test.dart b/test/unit/http/handler_test.dart new file mode 100644 index 000000000..7eb346e23 --- /dev/null +++ b/test/unit/http/handler_test.dart @@ -0,0 +1,299 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:nock/nock.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../mocks/client.dart'; + +extension TestRoute on HttpRoute { + void test() => add(HttpRoutePart('test')); + void succeed() => add(HttpRoutePart('succeed')); + void fail() => add(HttpRoutePart('fail')); +} + +void main() { + setUpAll(() { + nock.init(); + }); + + setUp(() { + nock.cleanAll(); + }); + + group('HttpHandler', () { + group('execute', () { + test('can make basic requests', () async { + final client = MockNyxx(); + final handler = HttpHandler(client); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test token')); + when(() => client.options).thenReturn(RestClientOptions()); + + final interceptor = nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test')..reply(200, jsonEncode({'message': 'success'})); + + final route = HttpRoute()..test(); + final request = BasicRequest(route); + + final response = await handler.execute(request); + + expect(interceptor.isDone, isTrue); + + expect(response.statusCode, equals(200)); + expect(response.jsonBody, equals({'message': 'success'})); + }); + + test('returns the correct response type', () async { + final client = MockNyxx(); + final handler = HttpHandler(client); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test token')); + when(() => client.options).thenReturn(RestClientOptions()); + + final scope = nock('https://discord.com/api/v${client.apiOptions.apiVersion}'); + final successInterceptor = scope.get('/succeed')..reply(200, jsonEncode({'message': 'success'})); + final failureInterceptor = scope.get('/fail')..reply(400, jsonEncode({'message': 'failure'})); + + final successRequest = BasicRequest(HttpRoute()..succeed()); + final failureRequest = BasicRequest(HttpRoute()..fail()); + + final successResponse = await handler.execute(successRequest); + final failureResponse = await handler.execute(failureRequest); + + expect(successInterceptor.isDone, isTrue); + expect(failureInterceptor.isDone, isTrue); + + expect(successResponse, isA()); + expect(failureResponse, isA()); + }); + }); + + test('executeSafe throws on request failure', () async { + final client = MockNyxx(); + final handler = HttpHandler(client); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test token')); + when(() => client.options).thenReturn(RestClientOptions()); + + nock('https://discord.com/api/v${client.apiOptions.apiVersion}') + ..get('/succeed').reply(200, jsonEncode({'message': 'success'})) + ..get('/fail').reply(400, jsonEncode({'message': 'failure'})); + + final successRequest = BasicRequest(HttpRoute()..succeed()); + final failureRequest = BasicRequest(HttpRoute()..fail()); + + expect(handler.executeSafe(successRequest), completion(isA())); + expect(() => handler.executeSafe(failureRequest), throwsA(isA())); + }); + + group('rate limits', () { + test('creates buckets from headers', () async { + final client = MockNyxx(); + final handler = HttpHandler(client); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test token')); + when(() => client.options).thenReturn(RestClientOptions()); + + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test').reply( + 200, + jsonEncode({'message': 'success'}), + headers: { + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '15', + HttpBucket.xRateLimitReset: '10000', + HttpBucket.xRateLimitResetAfter: '10.5', + }, + ); + + final request = BasicRequest(HttpRoute()..test()); + + await handler.execute(request); + + expect(handler.buckets.length, equals(1)); + expect(handler.buckets.values.single.id, equals('testBucketId')); + }); + + test('hold requests when rate limit might be exceeded', () async { + final client = MockNyxx(); + final handler = HttpHandler(client); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test token')); + when(() => client.options).thenReturn(RestClientOptions()); + + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test').reply( + 200, + jsonEncode({'message': 'success'}), + headers: { + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '0', + HttpBucket.xRateLimitReset: '10000', + HttpBucket.xRateLimitResetAfter: '5', + }, + ); + + final request = BasicRequest(HttpRoute()..test()); + + await handler.execute(request); + + // Only add handler after 4 seconds. If the second request runs before this, an error will be thrown. + Timer(const Duration(seconds: 4), () { + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test').reply( + 200, + jsonEncode({'message': 'success'}), + headers: { + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '19', + HttpBucket.xRateLimitReset: '10000', + HttpBucket.xRateLimitResetAfter: '5', + }, + ); + }); + + expect(handler.onRateLimit.where((event) => event.isAnticipated), emits(predicate((_) => true))); + expect(handler.execute(request), completes); + }); + + test('update on 429 response', () async { + final client = MockNyxx(); + final handler = HttpHandler(client); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test token')); + when(() => client.options).thenReturn(RestClientOptions()); + + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test').reply( + 429, + jsonEncode({"message": "You are being rate limited.", "retry_after": 5.0, "global": false}), + headers: { + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '19', + HttpBucket.xRateLimitReset: '10000', + HttpBucket.xRateLimitResetAfter: '5', + }, + ); + + final request = BasicRequest(HttpRoute()..test()); + + Timer(const Duration(seconds: 4), () { + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test').reply( + 200, + jsonEncode({'message': 'success'}), + headers: { + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '19', + HttpBucket.xRateLimitReset: '10000', + HttpBucket.xRateLimitResetAfter: '5', + }, + ); + }); + + expect(handler.onRateLimit.where((event) => !event.isAnticipated), emits(predicate((_) => true))); + + await expectLater( + handler.execute(request), + completion( + allOf( + isA(), + predicate((response) => response.statusCode == 200), + ), + ), + ); + + expect(handler.globalReset, isNull); + }); + + test('handles global rate limit', () async { + final client = MockNyxx(); + final handler = HttpHandler(client); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test token')); + when(() => client.options).thenReturn(RestClientOptions()); + + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test').reply( + 429, + jsonEncode({"message": "You are being rate limited.", "retry_after": 5.0, "global": true}), + headers: { + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '19', + HttpBucket.xRateLimitReset: '10000', + HttpBucket.xRateLimitResetAfter: '5', + }, + ); + + final request = BasicRequest(HttpRoute()..test()); + + Timer(const Duration(seconds: 4), () { + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test').reply( + 200, + jsonEncode({'message': 'success'}), + headers: { + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '20', + HttpBucket.xRateLimitRemaining: '19', + HttpBucket.xRateLimitReset: '10000', + HttpBucket.xRateLimitResetAfter: '5', + }, + ); + }); + + expect(handler.onRateLimit.where((event) => event.isGlobal), emits(predicate((_) => true))); + + await expectLater( + handler.execute(request), + completion( + allOf( + isA(), + predicate((response) => response.statusCode == 200), + ), + ), + ); + + expect(handler.globalReset, isNot(isNull)); + }); + + test('handles batch request rate limits', () async { + final client = MockNyxx(); + final handler = HttpHandler(client); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test token')); + when(() => client.options).thenReturn(RestClientOptions()); + + for (final duration in [Duration.zero, Duration(seconds: 4), Duration(seconds: 9)]) { + Timer(duration, () { + // Accept 5 requests + for (int i = 0; i < 5; i++) { + nock('https://discord.com/api/v${client.apiOptions.apiVersion}').get('/test').reply( + 200, + jsonEncode({'message': 'success'}), + headers: { + HttpBucket.xRateLimitBucket: 'testBucketId', + HttpBucket.xRateLimitLimit: '5', + HttpBucket.xRateLimitRemaining: (4 - i).toString(), + HttpBucket.xRateLimitReset: '10005', + HttpBucket.xRateLimitResetAfter: '5', + }, + ); + } + }); + } + + // First 0-duration timer needs to trigger to register the initial handlers + await Future.delayed(Duration(milliseconds: 1)); + + // One request to populate bucket information + await handler.executeSafe(BasicRequest(HttpRoute()..test(), headers: {'count': 'first'})); + + for (int i = 0; i < 14; i++) { + handler.executeSafe(BasicRequest(HttpRoute()..test(), headers: {'count': i.toString()})); + } + + // Test should take 15 seconds (3 batches of 5 second limits) + some small amount of processing time. + // We need to close the handler so the call to toList below completes. + Timer(Duration(seconds: 16), () => handler.close()); + + final list = await handler.onResponse.where((event) => event.statusCode == 429).toList(); + + expect(list, isEmpty); + }); + }); + }); +} diff --git a/test/unit/http/managers/application_command_manager_test.dart b/test/unit/http/managers/application_command_manager_test.dart new file mode 100644 index 000000000..82d150890 --- /dev/null +++ b/test/unit/http/managers/application_command_manager_test.dart @@ -0,0 +1,163 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; + +final sampleCommand = { + "id": "1102343284505968762", + "application_id": "1033681843708510238", + "version": "1107729458535878799", + "default_member_permissions": null, + "type": 1, + "name": "ping", + "name_localizations": null, + "description": "Ping the bot", + "description_localizations": null, + "dm_permission": true, + "contexts": null, + "nsfw": false, +}; + +void checkCommand(ApplicationCommand command) { + expect(command.id, equals(Snowflake(1102343284505968762))); + expect(command.type, equals(ApplicationCommandType.chatInput)); + expect(command.applicationId, equals(Snowflake(1033681843708510238))); + expect(command.guildId, isNull); + expect(command.name, equals('ping')); + expect(command.nameLocalizations, isNull); + expect(command.description, equals('Ping the bot')); + expect(command.descriptionLocalizations, isNull); + expect(command.options, isNull); + expect(command.defaultMemberPermissions, isNull); + expect(command.hasDmPermission, isTrue); + expect(command.isNsfw, isFalse); + expect(command.version, equals(Snowflake(1107729458535878799))); +} + +final sampleCommandPermissions = { + 'id': '0', + 'application_id': '1', + 'guild_id': '2', + 'permissions': [ + { + 'id': '3', + 'type': 1, + 'permission': true, + }, + ], +}; + +void checkCommandPermissions(CommandPermissions permissions) { + expect(permissions.id, equals(Snowflake.zero)); + expect(permissions.applicationId, equals(Snowflake(1))); + expect(permissions.guildId, equals(Snowflake(2))); + expect(permissions.permissions, hasLength(1)); + + final permission = permissions.permissions.single; + + expect(permission.id, equals(Snowflake(3))); + expect(permission.type, equals(CommandPermissionType.role)); + expect(permission.hasPermission, isTrue); +} + +void main() { + testManager( + 'GlobalApplicationCommandManager', + (config, client) => GlobalApplicationCommandManager(config, client, applicationId: Snowflake.zero), + RegExp(r'/applications/0/commands/\d+'), + '/applications/0/commands', + sampleObject: sampleCommand, + sampleMatches: checkCommand, + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'list', + source: [sampleCommand], + urlMatcher: '/applications/0/commands', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + checkCommand(list.single); + }, + ), + EndpointTest, List>( + name: 'bulkOverride', + method: 'PUT', + source: [sampleCommand], + urlMatcher: '/applications/0/commands', + execute: (manager) => manager.bulkOverride([ApplicationCommandBuilder(name: 'TEST', type: ApplicationCommandType.chatInput)]), + check: (list) { + expect(list, hasLength(1)); + checkCommand(list.single); + }, + ), + ], + createBuilder: ApplicationCommandBuilder(name: 'TEST', type: ApplicationCommandType.chatInput), + updateBuilder: ApplicationCommandUpdateBuilder(), + ); + + testManager( + 'GuildApplicationCommandManager', + (config, client) => GuildApplicationCommandManager( + config, + client, + applicationId: Snowflake.zero, + guildId: Snowflake(1), + permissionsConfig: const CacheConfig(), + ), + RegExp(r'/applications/0/guilds/1/commands/\d+'), + '/applications/0/guilds/1/commands', + sampleObject: sampleCommand, + sampleMatches: checkCommand, + additionalParsingTests: [ + ParsingTest>( + name: 'parseCommandPermissions', + source: sampleCommandPermissions, + parse: (manager) => manager.parseCommandPermissions, + check: checkCommandPermissions, + ), + ], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'list', + source: [sampleCommand], + urlMatcher: '/applications/0/guilds/1/commands', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + checkCommand(list.single); + }, + ), + EndpointTest, List>( + name: 'bulkOverride', + method: 'PUT', + source: [sampleCommand], + urlMatcher: '/applications/0/guilds/1/commands', + execute: (manager) => manager.bulkOverride([ApplicationCommandBuilder(name: 'TEST', type: ApplicationCommandType.chatInput)]), + check: (list) { + expect(list, hasLength(1)); + checkCommand(list.single); + }, + ), + EndpointTest, List>( + name: 'listCommandPermissions', + source: [sampleCommandPermissions], + urlMatcher: '/applications/0/guilds/1/commands/permissions', + execute: (manager) => manager.listPermissions(), + check: (list) { + expect(list, hasLength(1)); + checkCommandPermissions(list.single); + }, + ), + EndpointTest>( + name: 'fetchCommandPermissions', + source: sampleCommandPermissions, + urlMatcher: '/applications/0/guilds/1/commands/2/permissions', + execute: (manager) => manager.fetchPermissions(Snowflake(2)), + check: checkCommandPermissions, + ), + ], + createBuilder: ApplicationCommandBuilder(name: 'TEST', type: ApplicationCommandType.chatInput), + updateBuilder: ApplicationCommandUpdateBuilder(), + ); +} diff --git a/test/unit/http/managers/application_manager_test.dart b/test/unit/http/managers/application_manager_test.dart new file mode 100644 index 000000000..d83a3c0b6 --- /dev/null +++ b/test/unit/http/managers/application_manager_test.dart @@ -0,0 +1,130 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../mocks/client.dart'; +import '../../../test_endpoint.dart'; +import '../../../test_manager.dart'; + +final sampleApplication = { + "bot_public": true, + "bot_require_code_grant": false, + "cover_image": "31deabb7e45b6c8ecfef77d2f99c81a5", + "description": "Test", + "guild_id": "290926798626357260", + "icon": null, + "id": "172150183260323840", + "name": "Baba O-Riley", + "owner": {"avatar": null, "discriminator": "1738", "flags": 1024, "id": "172150183260323840", "username": "i own a bot"}, + "primary_sku_id": "172150183260323840", + "slug": "test", + "summary": "", + "team": { + "icon": "dd9b7dcfdf5351b9c3de0fe167bacbe1", + "id": "531992624043786253", + "members": [ + { + "membership_state": 2, + "permissions": ["*"], + "team_id": "531992624043786253", + "user": {"avatar": "d9e261cd35999608eb7e3de1fae3688b", "discriminator": "0001", "id": "511972282709709995", "username": "Mr Owner"} + } + ], + + // The docs say these fields are present, but they aren't in the sample application Discord provides + "name": "test team", + "owner_user_id": "0", + }, + "verify_key": "1e0a356058d627ca38a5c8c9648818061d49e49bd9da9e3ab17d98ad4d6bg2u8" +}; + +void checkApplication(Application application) { + expect(application.id, equals(Snowflake(172150183260323840))); + expect(application.name, equals('Baba O-Riley')); + expect(application.iconHash, isNull); + expect(application.description, equals('Test')); + expect(application.rpcOrigins, isNull); + expect(application.isBotPublic, isTrue); + expect(application.botRequiresCodeGrant, isFalse); + expect(application.termsOfServiceUrl, isNull); + expect(application.privacyPolicyUrl, isNull); + expect(application.owner?.id, equals(Snowflake(172150183260323840))); + expect(application.verifyKey, equals('1e0a356058d627ca38a5c8c9648818061d49e49bd9da9e3ab17d98ad4d6bg2u8')); + expect(application.team?.id, equals(Snowflake(531992624043786253))); + expect(application.guildId, equals(Snowflake(290926798626357260))); + expect(application.primarySkuId, equals(Snowflake(172150183260323840))); + expect(application.slug, equals('test')); + expect(application.coverImageHash, equals('31deabb7e45b6c8ecfef77d2f99c81a5')); + expect(application.flags, equals(ApplicationFlags(0))); + expect(application.tags, isNull); + expect(application.installationParameters, isNull); + expect(application.customInstallUrl, isNull); + expect(application.roleConnectionsVerificationUrl, isNull); +} + +final sampleRoleConnectionMetadata = { + 'type': 1, + 'key': 'key', + 'name': 'test name', + 'description': 'test description', +}; + +void checkRoleConnectionMetadata(ApplicationRoleConnectionMetadata metadata) { + expect(metadata.type, equals(ConnectionMetadataType.integerLessThanOrEqual)); + expect(metadata.key, equals('key')); + expect(metadata.name, equals('test name')); + expect(metadata.localizedNames, isNull); + expect(metadata.description, equals('test description')); + expect(metadata.localizedDescriptions, isNull); +} + +void main() { + group('ApplicationManager', () { + test('parse', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + ParsingTest>( + name: 'parse', + source: sampleApplication, + parse: (manager) => manager.parse, + check: checkApplication, + ).runWithManager(ApplicationManager(client)); + }); + + test('parseApplicationRoleConnectionMetadata', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + ParsingTest>( + name: 'parseApplicationRoleConnectionMetadata', + source: sampleRoleConnectionMetadata, + parse: (manager) => manager.parseApplicationRoleConnectionMetadata, + check: checkRoleConnectionMetadata, + ).runWithManager(ApplicationManager(client)); + }); + + testEndpoint( + '/applications/0/role-connections/metadata', + name: 'fetchApplicationRoleConnectionMetadata', + (client) => client.applications.fetchApplicationRoleConnectionMetadata(Snowflake.zero), + response: [sampleRoleConnectionMetadata], + ); + + testEndpoint( + '/applications/0/role-connections/metadata', + method: 'PUT', + name: 'updateApplicationRoleConnectionMetadata', + (client) => client.applications.updateApplicationRoleConnectionMetadata(Snowflake.zero), + response: [sampleRoleConnectionMetadata], + ); + + testEndpoint( + '/oauth2/applications/@me', + (client) => client.applications.fetchCurrentApplication(), + response: sampleApplication, + ); + }); +} diff --git a/test/unit/http/managers/audit_log_manager_test.dart b/test/unit/http/managers/audit_log_manager_test.dart new file mode 100644 index 000000000..ee13f3a68 --- /dev/null +++ b/test/unit/http/managers/audit_log_manager_test.dart @@ -0,0 +1,50 @@ +import 'package:matcher/expect.dart'; +import 'package:nyxx/nyxx.dart'; + +import '../../../test_manager.dart'; + +final sampleAuditLogEntry = { + 'target_id': '1', + 'id': '1', + 'action_type': 1, + 'reason': 'Test reason', +}; + +void checkAuditLogEntry(AuditLogEntry entry) {} + +final sampleAuditLog = { + 'application_commands': [], + 'audit_log_entries': [sampleAuditLogEntry], + 'auto_moderation_rules': [], + 'guild_scheduled_events': [], + 'integrations': [], + 'threads': [], + 'users': [], + 'webhooks': [], +}; + +void main() { + testReadOnlyManager( + 'AuditLogManager', + (config, client) => AuditLogManager(config, client, guildId: Snowflake.zero), + // fetch() artificially creates a before field as before = id + 1 - testing ID is 1 so before is 2 + '/guilds/0/audit-logs?before=2', + sampleObject: sampleAuditLogEntry, + sampleMatches: checkAuditLogEntry, + // Fetch implementation internally uses `list()`, so we return a full audit log + fetchObjectOverride: sampleAuditLog, + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest, Map>( + name: 'list', + source: sampleAuditLog, + urlMatcher: '/guilds/0/audit-logs', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + checkAuditLogEntry(list.single); + }, + ), + ], + ); +} diff --git a/test/unit/http/managers/auto_moderation_manager_test.dart b/test/unit/http/managers/auto_moderation_manager_test.dart new file mode 100644 index 000000000..8faa539ac --- /dev/null +++ b/test/unit/http/managers/auto_moderation_manager_test.dart @@ -0,0 +1,62 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; + +final sampleAutoModerationRule = { + "id": "969707018069872670", + "guild_id": "613425648685547541", + "name": "Keyword Filter 1", + "creator_id": "423457898095789043", + "trigger_type": 1, + "event_type": 1, + "actions": [ + { + "type": 1, + "metadata": {"custom_message": "Please keep financial discussions limited to the #finance channel"} + }, + { + "type": 2, + "metadata": {"channel_id": "123456789123456789"} + }, + { + "type": 3, + "metadata": {"duration_seconds": 60} + } + ], + "trigger_metadata": { + "keyword_filter": ["cat*", "*dog", "*ana*", "i like c++"], + "regex_patterns": ["(b|c)at", "^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}\$"] + }, + "enabled": true, + "exempt_roles": ["323456789123456789", "423456789123456789"], + "exempt_channels": ["523456789123456789"] +}; + +void checkAutoModerationRule(AutoModerationRule rule) {} + +void main() { + testManager( + 'AutoModerationManager', + (config, client) => AutoModerationManager(config, client, guildId: Snowflake.zero), + RegExp(r'/guilds/0/auto-moderation/rules/\d+'), + '/guilds/0/auto-moderation/rules', + sampleObject: sampleAutoModerationRule, + sampleMatches: checkAutoModerationRule, + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'list', + source: [sampleAutoModerationRule], + urlMatcher: '/guilds/0/auto-moderation/rules', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + checkAutoModerationRule(list.single); + }, + ), + ], + createBuilder: AutoModerationRuleBuilder(name: 'test', eventType: AutoModerationEventType.messageSend, triggerType: TriggerType.keyword, actions: []), + updateBuilder: AutoModerationRuleUpdateBuilder(), + ); +} diff --git a/test/unit/http/managers/channel_manager_test.dart b/test/unit/http/managers/channel_manager_test.dart new file mode 100644 index 000000000..f58da4e28 --- /dev/null +++ b/test/unit/http/managers/channel_manager_test.dart @@ -0,0 +1,719 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../mocks/client.dart'; +import '../../../test_manager.dart'; +import 'invite_manager_test.dart'; +import 'member_manager_test.dart'; + +final sampleGuildText = { + "id": "41771983423143937", + "guild_id": "41771983423143937", + "name": "general", + "type": 0, + "position": 6, + "permission_overwrites": [], + "rate_limit_per_user": 2, + "nsfw": true, + "topic": "24/7 chat about how to gank Mike #2", + "last_message_id": "155117677105512449", + "parent_id": "399942396007890945", + "default_auto_archive_duration": 60 +}; + +void checkGuildText(Channel channel) { + expect(channel, isA()); + + channel as GuildTextChannel; + + expect(channel.id, equals(Snowflake(41771983423143937))); + expect(channel.topic, equals('24/7 chat about how to gank Mike #2')); + expect(channel.defaultAutoArchiveDuration, equals(Duration(minutes: 60))); + expect(channel.defaultThreadRateLimitPerUser, isNull); + expect(channel.guildId, equals(Snowflake(41771983423143937))); + expect(channel.isNsfw, isTrue); + expect(channel.lastMessageId, equals(Snowflake(155117677105512449))); + expect(channel.lastPinTimestamp, isNull); + expect(channel.name, equals('general')); + expect(channel.parentId, equals(Snowflake(399942396007890945))); + expect(channel.permissionOverwrites, equals([])); + expect(channel.position, equals(6)); + expect(channel.rateLimitPerUser, equals(Duration(seconds: 2))); +} + +final sampleGuildAnnouncement = { + "id": "41771983423143937", + "guild_id": "41771983423143937", + "name": "important-news", + "type": 5, + "position": 6, + "permission_overwrites": [], + "nsfw": true, + "topic": "Rumors about Half Life 3", + "last_message_id": "155117677105512449", + "parent_id": "399942396007890945", + "default_auto_archive_duration": 60 +}; + +void checkGuildAnnouncement(Channel channel) { + expect(channel, isA()); + + channel as GuildAnnouncementChannel; + + expect(channel.id, equals(Snowflake(41771983423143937))); + expect(channel.topic, equals('Rumors about Half Life 3')); + expect(channel.defaultAutoArchiveDuration, equals(Duration(minutes: 60))); + expect(channel.defaultThreadRateLimitPerUser, isNull); + expect(channel.guildId, equals(Snowflake(41771983423143937))); + expect(channel.isNsfw, isTrue); + expect(channel.lastMessageId, equals(Snowflake(155117677105512449))); + expect(channel.lastPinTimestamp, isNull); + expect(channel.name, equals('important-news')); + expect(channel.parentId, equals(Snowflake(399942396007890945))); + expect(channel.permissionOverwrites, equals([])); + expect(channel.position, equals(6)); + expect(channel.rateLimitPerUser, isNull); +} + +final sampleGuildVoice = { + "id": "155101607195836416", + "last_message_id": "174629835082649376", + "type": 2, + "name": "ROCKET CHEESE", + "position": 5, + "parent_id": null, + "bitrate": 64000, + "user_limit": 0, + "rtc_region": null, + "guild_id": "41771983423143937", + "permission_overwrites": [], + "rate_limit_per_user": 0, + "nsfw": false, +}; + +void checkGuildVoice(Channel channel) { + expect(channel, isA()); + + channel as GuildVoiceChannel; + + expect(channel.id, equals(Snowflake(155101607195836416))); + expect(channel.bitrate, equals(64000)); + expect(channel.guildId, equals(Snowflake(41771983423143937))); + expect(channel.isNsfw, isFalse); + expect(channel.lastMessageId, equals(Snowflake(174629835082649376))); + expect(channel.lastPinTimestamp, isNull); + expect(channel.name, equals('ROCKET CHEESE')); + expect(channel.parentId, isNull); + expect(channel.permissionOverwrites, equals([])); + expect(channel.position, equals(5)); + expect(channel.rateLimitPerUser, isNull); + expect(channel.rtcRegion, isNull); + expect(channel.userLimit, isNull); + expect(channel.videoQualityMode, VideoQualityMode.auto); +} + +final sampleDm = { + "last_message_id": "3343820033257021450", + "type": 1, + "id": "319674150115610528", + "recipients": [ + {"username": "test", "discriminator": "9999", "id": "82198898841029460", "avatar": "33ecab261d4681afa4d85a04691c4a01"} + ] +}; + +void checkDm(Channel channel) { + expect(channel, isA()); + + channel as DmChannel; + + expect(channel.id, equals(Snowflake(319674150115610528))); + expect(channel.recipient.id, equals(Snowflake(82198898841029460))); + expect(channel.lastMessageId, equals(Snowflake(3343820033257021450))); + expect(channel.lastPinTimestamp, isNull); + expect(channel.rateLimitPerUser, isNull); +} + +final sampleGroupDm = { + "name": "Some test channel", + "icon": null, + "recipients": [ + {"username": "test", "discriminator": "9999", "id": "82198898841029460", "avatar": "33ecab261d4681afa4d85a04691c4a01"}, + {"username": "test2", "discriminator": "9999", "id": "82198810841029460", "avatar": "33ecab261d4681afa4d85a10691c4a01"} + ], + "last_message_id": "3343820033257021450", + "type": 3, + "id": "319674150115710528", + "owner_id": "82198810841029460" +}; + +void checkGroupDm(Channel channel) { + expect(channel, isA()); + + channel as GroupDmChannel; + + expect(channel.id, equals(Snowflake(319674150115710528))); + expect(channel.name, equals('Some test channel')); + expect(channel.recipients, hasLength(2)); + expect(channel.iconHash, isNull); + expect(channel.ownerId, equals(Snowflake(82198810841029460))); + expect(channel.applicationId, isNull); + expect(channel.isManaged, isFalse); + expect(channel.lastMessageId, equals(Snowflake(3343820033257021450))); + expect(channel.lastPinTimestamp, isNull); + expect(channel.rateLimitPerUser, isNull); +} + +final sampleCategory = { + "permission_overwrites": [], + "name": "Test", + "parent_id": null, + "nsfw": false, + "position": 0, + "guild_id": "290926798629997250", + "type": 4, + "id": "399942396007890945" +}; + +void checkCategory(Channel channel) { + expect(channel, isA()); + + channel as GuildCategory; + + expect(channel.id, equals(Snowflake(399942396007890945))); + expect(channel.guildId, equals(Snowflake(290926798629997250))); + expect(channel.isNsfw, isFalse); + expect(channel.name, equals('Test')); + expect(channel.parentId, isNull); + expect(channel.permissionOverwrites, equals([])); + expect(channel.position, equals(0)); +} + +final sampleThread = { + "id": "41771983423143937", + "guild_id": "41771983423143937", + "parent_id": "41771983423143937", + "owner_id": "41771983423143937", + "name": "don't buy dota-2", + "type": 11, + "last_message_id": "155117677105512449", + "message_count": 1, + "member_count": 5, + "rate_limit_per_user": 2, + "thread_metadata": {"archived": false, "auto_archive_duration": 1440, "archive_timestamp": "2021-04-12T23:40:39.855793+00:00", "locked": false}, + "total_message_sent": 1 +}; + +void checkThread(Channel channel) { + expect(channel, isA()); + + channel as PublicThread; + + expect(channel.id, equals(Snowflake(41771983423143937))); + expect(channel.appliedTags, isNull); + expect(channel.approximateMemberCount, equals(5)); + expect(channel.archiveTimestamp, equals(DateTime.utc(2021, 04, 12, 23, 40, 39, 855, 793))); + expect(channel.autoArchiveDuration, equals(Duration(minutes: 1440))); + expect(channel.createdAt, equals(DateTime(2022, 01, 09))); + expect(channel.guildId, equals(Snowflake(41771983423143937))); + expect(channel.isArchived, isFalse); + expect(channel.isLocked, isFalse); + expect(channel.isNsfw, isFalse); + expect(channel.lastMessageId, equals(Snowflake(155117677105512449))); + expect(channel.lastPinTimestamp, isNull); + expect(channel.messageCount, equals(1)); + expect(channel.name, equals("don't buy dota-2")); + expect(channel.ownerId, equals(Snowflake(41771983423143937))); + expect(channel.parentId, equals(Snowflake(41771983423143937))); + expect(channel.permissionOverwrites, equals([])); + expect(channel.position, equals(-1)); + expect(channel.rateLimitPerUser, equals(Duration(seconds: 2))); + expect(channel.totalMessagesSent, equals(1)); + expect(channel.flags, isNull); +} + +final sampleAnnouncementThread = { + "id": "1093553602909442119", + "guild_id": "1033681997136146462", + "parent_id": "1093553555270545438", + "owner_id": "506759329068613643", + "type": 10, + "name": "Wow, such announcement", + "last_message_id": "1093553605472170094", + "thread_metadata": { + "archived": false, + "archive_timestamp": "2023-04-06T15:11:36.177000+00:00", + "auto_archive_duration": 4320, + "locked": false, + "create_timestamp": "2023-04-06T15:11:36.177000+00:00" + }, + "message_count": 1, + "member_count": 1, + "rate_limit_per_user": 0, + "flags": 0, + "total_message_sent": 1, +}; + +void checkAnnouncementThread(Channel channel) { + expect(channel, isA()); + + channel as AnnouncementThread; + + expect(channel.id, equals(Snowflake(1093553602909442119))); + expect(channel.appliedTags, isNull); + expect(channel.approximateMemberCount, equals(1)); + expect(channel.archiveTimestamp, equals(DateTime.utc(2023, 04, 06, 15, 11, 36, 177))); + expect(channel.autoArchiveDuration, equals(Duration(minutes: 4320))); + expect(channel.createdAt, equals(DateTime.utc(2023, 04, 06, 15, 11, 36, 177))); + expect(channel.guildId, equals(Snowflake(1033681997136146462))); + expect(channel.isArchived, isFalse); + expect(channel.isLocked, isFalse); + expect(channel.isNsfw, isFalse); + expect(channel.lastMessageId, equals(Snowflake(1093553605472170094))); + expect(channel.lastPinTimestamp, isNull); + expect(channel.messageCount, equals(1)); + expect(channel.name, equals('Wow, such announcement')); + expect(channel.ownerId, equals(Snowflake(506759329068613643))); + expect(channel.parentId, equals(Snowflake(1093553555270545438))); + expect(channel.permissionOverwrites, equals([])); + expect(channel.position, equals(-1)); + expect(channel.rateLimitPerUser, isNull); + expect(channel.totalMessagesSent, equals(1)); + expect(channel.flags, equals(ChannelFlags(0))); +} + +final samplePrivateThread = { + "id": "1093556383640715314", + "guild_id": "1033681997136146462", + "parent_id": "1038831656682930227", + "owner_id": "506759329068613643", + "type": 12, + "name": "blah", + "last_message_id": "1093556580290670633", + "thread_metadata": { + "archived": false, + "archive_timestamp": "2023-04-06T15:22:39.155000+00:00", + "auto_archive_duration": 4320, + "locked": false, + "create_timestamp": "2023-04-06T15:22:39.155000+00:00", + "invitable": true + }, + "message_count": 2, + "member_count": 2, + "rate_limit_per_user": 0, + "flags": 0, + "total_message_sent": 2, + "member": { + "id": "1093556383640715314", + "flags": 0, + "join_timestamp": "2023-04-06T15:23:26.010000+00:00", + "user_id": "1033681843708510238", + "muted": false, + "mute_config": null + }, +}; + +void checkPrivateThread(Channel channel) { + expect(channel, isA()); + + channel as PrivateThread; + + expect(channel.id, equals(Snowflake(1093556383640715314))); + expect(channel.isInvitable, isTrue); + expect(channel.appliedTags, isNull); + expect(channel.approximateMemberCount, equals(2)); + expect(channel.archiveTimestamp, equals(DateTime.utc(2023, 04, 06, 15, 22, 39, 155))); + expect(channel.autoArchiveDuration, equals(Duration(minutes: 4320))); + expect(channel.createdAt, equals(DateTime.utc(2023, 04, 06, 15, 22, 39, 155))); + expect(channel.guildId, equals(Snowflake(1033681997136146462))); + expect(channel.isArchived, isFalse); + expect(channel.isLocked, isFalse); + expect(channel.isNsfw, isFalse); + expect(channel.lastMessageId, equals(Snowflake(1093556580290670633))); + expect(channel.lastPinTimestamp, isNull); + expect(channel.messageCount, equals(2)); + expect(channel.name, equals('blah')); + expect(channel.ownerId, equals(Snowflake(506759329068613643))); + expect(channel.parentId, equals(Snowflake(1038831656682930227))); + expect(channel.permissionOverwrites, equals([])); + expect(channel.position, equals(-1)); + expect(channel.rateLimitPerUser, isNull); + expect(channel.totalMessagesSent, equals(2)); + expect(channel.flags, equals(ChannelFlags(0))); +} + +final samplePermissionOverwrite = { + 'id': '0', + 'type': 1, + 'allow': '100', + 'deny': '11', +}; + +void checkPermissionOverwrite(PermissionOverwrite overwrite) { + expect(overwrite.id, equals(Snowflake.zero)); + expect(overwrite.type, equals(PermissionOverwriteType.member)); + expect(overwrite.allow, equals(Permissions(100))); + expect(overwrite.deny, equals(Permissions(11))); +} + +final sampleForumTag = { + 'id': '0', + 'name': 'test tag', + 'moderated': false, + 'emoji_id': null, + 'emoji_name': 'slight_smile', +}; + +void checkForumTag(ForumTag tag) { + expect(tag.id, equals(Snowflake.zero)); + expect(tag.name, equals('test tag')); + expect(tag.isModerated, isFalse); + expect(tag.emojiId, isNull); + expect(tag.emojiName, equals('slight_smile')); +} + +final sampleDefaultReaction = { + 'emoji_id': '0', + 'emoji_name': null, +}; + +void checkDefaultReaction(DefaultReaction reaction) { + expect(reaction.emojiId, equals(Snowflake.zero)); + expect(reaction.emojiName, isNull); +} + +final sampleFollowedChannel = { + 'channel_id': '0', + 'webhook_id': '1', +}; + +void checkFollowedChannel(FollowedChannel followedChannel) { + expect(followedChannel.channelId, equals(Snowflake.zero)); + expect(followedChannel.webhookId, equals(Snowflake(1))); +} + +final sampleThreadMember = { + 'id': '0', + 'user_id': '1', + 'join_timestamp': '2023-04-03T10:49:41+00:00', + 'flags': 17, + 'member': sampleMember, +}; + +void checkThreadMember(ThreadMember member) { + expect(member.threadId, equals(Snowflake.zero)); + expect(member.userId, equals(Snowflake(1))); + expect(member.flags.value, equals(17)); + expect(member.joinTimestamp, equals(DateTime.utc(2023, 04, 03, 10, 49, 41))); + checkMember(member.member!); +} + +final sampleThreadList = { + 'threads': [sampleThread], + 'members': [sampleThreadMember], + 'has_more': false, +}; + +void checkThreadList(ThreadList list) { + expect(list.threads, hasLength(1)); + checkThread(list.threads.single); + + expect(list.members, hasLength(1)); + checkThreadMember(list.members.single); + + expect(list.hasMore, isFalse); +} + +final sampleStageInstance = { + "id": "840647391636226060", + "guild_id": "197038439483310086", + "channel_id": "733488538393510049", + "topic": "Testing Testing, 123", + "privacy_level": 1, + "discoverable_disabled": false, + "guild_scheduled_event_id": "947656305244532806" +}; + +void checkStageInstance(StageInstance instance) { + expect(instance.id, equals(Snowflake(840647391636226060))); + expect(instance.guildId, equals(Snowflake(197038439483310086))); + expect(instance.channelId, equals(Snowflake(733488538393510049))); + expect(instance.topic, equals('Testing Testing, 123')); + expect(instance.privacyLevel, equals(PrivacyLevel.public)); + expect(instance.scheduledEventId, equals(Snowflake(947656305244532806))); +} + +void main() { + testReadOnlyManager( + 'ChannelManager', + (config, client) => ChannelManager(config, client, stageInstanceConfig: CacheConfig()), + RegExp(r'/channels/\d+'), + sampleObject: sampleGuildText, + sampleMatches: checkGuildText, + additionalSampleObjects: [ + sampleGuildAnnouncement, + sampleGuildVoice, + sampleDm, + sampleGroupDm, + sampleCategory, + sampleThread, + sampleAnnouncementThread, + samplePrivateThread, + ], + additionalSampleMatchers: [ + checkGuildAnnouncement, + checkGuildVoice, + checkDm, + checkGroupDm, + checkCategory, + checkThread, + checkAnnouncementThread, + checkPrivateThread, + ], + additionalParsingTests: [ + ParsingTest>( + name: 'parsePermissionOverwrite', + source: samplePermissionOverwrite, + parse: (manager) => manager.parsePermissionOverwrite, + check: checkPermissionOverwrite, + ), + ParsingTest>( + name: 'parseForumTag', + source: sampleForumTag, + parse: (manager) => manager.parseForumTag, + check: checkForumTag, + ), + ParsingTest>( + name: 'parseDefaultReaction', + source: sampleDefaultReaction, + parse: (manager) => manager.parseDefaultReaction, + check: checkDefaultReaction, + ), + ParsingTest>( + name: 'parseFollowedChannel', + source: sampleFollowedChannel, + parse: (manager) => manager.parseFollowedChannel, + check: checkFollowedChannel, + ), + ParsingTest>( + name: 'parseThreadMember', + source: sampleThreadMember, + parse: (manager) => manager.parseThreadMember, + check: checkThreadMember, + ), + ParsingTest>( + name: 'parseThreadList', + source: sampleThreadList, + parse: (manager) => manager.parseThreadList, + check: checkThreadList, + ), + ParsingTest>( + name: 'parseStageInstance', + source: sampleStageInstance, + parse: (manager) => manager.parseStageInstance, + check: checkStageInstance, + ), + ], + additionalEndpointTests: [ + EndpointTest>( + name: 'update', + method: 'patch', + source: sampleGuildText, + urlMatcher: '/channels/0', + execute: (manager) => manager.update(Snowflake.zero, GuildTextChannelUpdateBuilder()), + check: checkGuildText, + ), + EndpointTest>( + name: 'delete', + method: 'delete', + source: sampleGuildText, + urlMatcher: '/channels/0', + execute: (manager) => manager.delete(Snowflake.zero), + check: checkGuildText, + ), + EndpointTest( + name: 'updatePermissionOverwrite', + method: 'put', + source: null, + urlMatcher: '/channels/0/permissions/1', + execute: (manager) => + manager.updatePermissionOverwrite(Snowflake.zero, PermissionOverwriteBuilder(id: Snowflake(1), type: PermissionOverwriteType.role)), + check: (_) {}, + ), + EndpointTest( + name: 'deletePermissionOverwrite', + method: 'delete', + source: null, + urlMatcher: '/channels/0/permissions/1', + execute: (manager) => manager.deletePermissionOverwrite(Snowflake.zero, Snowflake(1)), + check: (_) {}, + ), + EndpointTest, List>( + name: 'listInvites', + source: [sampleInviteWithMetadata], + urlMatcher: '/channels/0/invites', + execute: (manager) => manager.listInvites(Snowflake.zero), + check: (list) { + expect(list, hasLength(1)); + + checkInviteWithMetadata(list.single); + }, + ), + EndpointTest>( + name: 'createInvite', + method: 'POST', + source: sampleInvite, + urlMatcher: '/channels/0/invites', + execute: (manager) => manager.createInvite(Snowflake.zero, InviteBuilder()), + check: checkInvite, + ), + EndpointTest>( + name: 'followChannel', + method: 'post', + source: sampleFollowedChannel, + urlMatcher: '/channels/0/followers', + execute: (manager) => manager.followChannel(Snowflake(1), Snowflake.zero), + check: checkFollowedChannel, + ), + EndpointTest( + name: 'triggerTyping', + method: 'post', + source: null, + urlMatcher: '/channels/0/typing', + execute: (manager) => manager.triggerTyping(Snowflake.zero), + check: (_) {}, + ), + EndpointTest>( + name: 'createThreadFromMessage', + method: 'post', + source: sampleThread, + urlMatcher: '/channels/0/messages/1/threads', + execute: (manager) => manager.createThreadFromMessage(Snowflake.zero, Snowflake(1), ThreadFromMessageBuilder(name: 'test')), + check: checkThread, + ), + EndpointTest>( + name: 'createThread', + method: 'post', + source: sampleThread, + urlMatcher: '/channels/0/threads', + execute: (manager) => manager.createThread(Snowflake.zero, ThreadBuilder(name: 'test', type: ChannelType.publicThread)), + check: checkThread, + ), + EndpointTest>( + name: 'createForumThread', + method: 'post', + source: sampleThread, + urlMatcher: '/channels/0/threads', + execute: (manager) => manager.createForumThread(Snowflake.zero, ForumThreadBuilder(name: 'test', message: MessageBuilder())), + check: checkThread, + ), + EndpointTest( + name: 'joinThread', + method: 'put', + source: null, + urlMatcher: '/channels/0/thread-members/@me', + execute: (manager) => manager.joinThread(Snowflake.zero), + check: (_) {}, + ), + EndpointTest( + name: 'addThreadMember', + method: 'put', + source: null, + urlMatcher: '/channels/0/thread-members/1', + execute: (manager) => manager.addThreadMember(Snowflake.zero, Snowflake(1)), + check: (_) {}, + ), + EndpointTest( + name: 'leaveThread', + method: 'delete', + source: null, + urlMatcher: '/channels/0/thread-members/@me', + execute: (manager) => manager.leaveThread(Snowflake.zero), + check: (_) {}, + ), + EndpointTest( + name: 'removeThreadMember', + method: 'delete', + source: null, + urlMatcher: '/channels/0/thread-members/1', + execute: (manager) => manager.removeThreadMember(Snowflake.zero, Snowflake(1)), + check: (_) {}, + ), + EndpointTest>( + name: 'fetchThreadMember', + source: sampleThreadMember, + urlMatcher: '/channels/0/thread-members/1', + execute: (manager) => manager.fetchThreadMember(Snowflake.zero, Snowflake(1)), + check: checkThreadMember, + ), + EndpointTest, List>( + name: 'listThreadMembers', + source: [sampleThreadMember], + urlMatcher: '/channels/0/thread-members', + execute: (manager) => manager.listThreadMembers(Snowflake.zero), + check: (list) { + expect(list, hasLength(1)); + checkThreadMember(list.single); + }, + ), + EndpointTest>( + name: 'listPublicArchivedThreads', + source: sampleThreadList, + urlMatcher: '/channels/0/threads/archived/public', + execute: (manager) => manager.listPublicArchivedThreads(Snowflake.zero), + check: checkThreadList, + ), + EndpointTest>( + name: 'listPrivateArchivedThreads', + source: sampleThreadList, + urlMatcher: '/channels/0/threads/archived/private', + execute: (manager) => manager.listPrivateArchivedThreads(Snowflake.zero), + check: checkThreadList, + ), + EndpointTest>( + name: 'listJoinedPrivateArchivedThreads', + source: sampleThreadList, + urlMatcher: '/channels/0/users/@me/threads/archived/private', + execute: (manager) => manager.listJoinedPrivateArchivedThreads(Snowflake.zero), + check: checkThreadList, + ), + EndpointTest>( + name: 'createStageInstance', + method: 'POST', + source: sampleStageInstance, + urlMatcher: '/stage-instances', + execute: (manager) => manager.createStageInstance(Snowflake.zero, StageInstanceBuilder(topic: 'test')), + check: checkStageInstance, + ), + EndpointTest>( + name: 'fetchStageInstance', + source: sampleStageInstance, + urlMatcher: '/stage-instances/0', + execute: (manager) => manager.fetchStageInstance(Snowflake.zero), + check: checkStageInstance, + ), + EndpointTest>( + name: 'updateStageInstance', + method: 'PATCH', + source: sampleStageInstance, + urlMatcher: '/stage-instances/0', + execute: (manager) => manager.updateStageInstance(Snowflake.zero, StageInstanceUpdateBuilder()), + check: checkStageInstance, + ), + EndpointTest( + name: 'deleteStageInstance', + method: 'DELETE', + source: null, + urlMatcher: '/stage-instances/0', + execute: (manager) => manager.deleteStageInstance(Snowflake.zero), + check: (_) {}, + ), + ], + extraRun: () { + test('[] returns PartialTextChannel', () { + final manager = ChannelManager(const CacheConfig(), MockNyxx(), stageInstanceConfig: const CacheConfig()); + + expect(manager[Snowflake.zero], isA()); + }); + }, + ); +} diff --git a/test/unit/http/managers/emoji_manager_test.dart b/test/unit/http/managers/emoji_manager_test.dart new file mode 100644 index 000000000..52e0b8c29 --- /dev/null +++ b/test/unit/http/managers/emoji_manager_test.dart @@ -0,0 +1,71 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; + +final sampleGuildEmoji = { + "id": "41771983429993937", + "name": "LUL", + "roles": ["41771983429993000", "41771983429993111"], + "user": {"username": "Luigi", "discriminator": "0002", "id": "96008815106887111", "avatar": "5500909a3274e1812beb4e8de6631111", "public_flags": 131328}, + "require_colons": true, + "managed": false, + "animated": false +}; + +void checkGuildEmoji(Emoji emoji) { + expect(emoji, isA()); + + emoji as GuildEmoji; + expect(emoji.id, equals(Snowflake(41771983429993937))); + expect(emoji.name, equals('LUL')); + expect(emoji.roleIds, equals([Snowflake(41771983429993000), Snowflake(41771983429993111)])); + expect(emoji.user?.id, equals(Snowflake(96008815106887111))); + expect(emoji.requiresColons, isTrue); + expect(emoji.isManaged, isFalse); + expect(emoji.isAnimated, isFalse); + expect(emoji.isAvailable, isNull); +} + +final sampleTextEmoji = {"id": null, "name": "🔥"}; + +void checkTextEmoji(Emoji emoji) { + expect(emoji, isA()); + + emoji as TextEmoji; + expect(emoji.name, equals('🔥')); +} + +void main() { + testManager( + 'EmojiManager', + (config, client) => EmojiManager(config, client, guildId: Snowflake(1)), + RegExp(r'/guilds/1/emojis/\d+'), + '/guilds/1/emojis', + sampleObject: sampleGuildEmoji, + sampleMatches: checkGuildEmoji, + additionalParsingTests: [ + ParsingTest>( + name: 'parse (TextEmoji)', + source: sampleTextEmoji, + parse: (manager) => manager.parse, + check: checkTextEmoji, + ), + ], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'list', + source: [sampleGuildEmoji], + urlMatcher: '/guilds/1/emojis', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + + checkGuildEmoji(list.single); + }, + ), + ], + createBuilder: EmojiBuilder(name: 'foo', image: ImageBuilder(data: [], format: 'png'), roles: []), + updateBuilder: EmojiUpdateBuilder(), + ); +} diff --git a/test/unit/http/managers/gateway_manager_test.dart b/test/unit/http/managers/gateway_manager_test.dart new file mode 100644 index 000000000..9398183af --- /dev/null +++ b/test/unit/http/managers/gateway_manager_test.dart @@ -0,0 +1,153 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../mocks/client.dart'; +import '../../../test_endpoint.dart'; +import '../../../test_manager.dart'; + +final sampleGateway = {"url": "wss://gateway.discord.gg/"}; + +void checkGateway(GatewayConfiguration configuration) { + expect(configuration.url, equals(Uri(scheme: 'wss', host: 'gateway.discord.gg', path: '/'))); +} + +final sampleGatewayBot = { + "url": "wss://gateway.discord.gg/", + "shards": 9, + "session_start_limit": {"total": 1000, "remaining": 999, "reset_after": 14400000, "max_concurrency": 1} +}; + +void checkGatewayBot(GatewayBot gateway) { + expect(gateway.url, equals(Uri(scheme: 'wss', host: 'gateway.discord.gg', path: '/'))); + expect(gateway.shards, equals(9)); + expect(gateway.sessionStartLimit.total, equals(1000)); + expect(gateway.sessionStartLimit.remaining, equals(999)); + expect(gateway.sessionStartLimit.resetAfter, equals(Duration(milliseconds: 14400000))); + expect(gateway.sessionStartLimit.maxConcurrency, equals(1)); +} + +final sampleActivity = { + "details": "24H RL Stream for Charity", + "state": "Rocket League", + "name": "Twitch", + "type": 1, + "url": "https://www.twitch.tv/discord", +}; + +void checkActivity(Activity activity) { + expect(activity.name, equals('Twitch')); + expect(activity.type, equals(ActivityType.streaming)); + expect(activity.url, equals(Uri.parse('https://www.twitch.tv/discord'))); + expect(activity.createdAt, isNull); + expect(activity.timestamps, isNull); + expect(activity.applicationId, isNull); + expect(activity.details, equals('24H RL Stream for Charity')); + expect(activity.state, equals('Rocket League')); + expect(activity.party, isNull); + expect(activity.assets, isNull); + expect(activity.secrets, isNull); + expect(activity.isInstance, isNull); + expect(activity.flags, isNull); + expect(activity.buttons, isNull); +} + +final sampleActivity2 = { + "name": "Rocket League", + "type": 0, + "application_id": "379286085710381999", + "state": "In a Match", + "details": "Ranked Duos: 2-1", + "timestamps": {"start": 15112000660000}, + "party": { + "id": "9dd6594e-81b3-49f6-a6b5-a679e6a060d3", + "size": [2, 2] + }, + "assets": {"large_image": "351371005538729000", "large_text": "DFH Stadium", "small_image": "351371005538729111", "small_text": "Silver III"}, + "secrets": { + "join": "025ed05c71f639de8bfaa0d679d7c94b2fdce12f", + "spectate": "e7eb30d2ee025ed05c71ea495f770b76454ee4e0", + "match": "4b2fdce12f639de8bfa7e3591b71a0d679d7c93f" + } +}; + +void checkActivity2(Activity activity) { + expect(activity.name, equals('Rocket League')); + expect(activity.type, equals(ActivityType.game)); + expect(activity.url, isNull); + expect(activity.createdAt, isNull); + expect(activity.timestamps?.start, equals(DateTime.fromMillisecondsSinceEpoch(15112000660000))); + expect(activity.applicationId, equals(Snowflake(379286085710381999))); + expect(activity.details, equals('Ranked Duos: 2-1')); + expect(activity.state, equals('In a Match')); + expect(activity.party?.id, equals('9dd6594e-81b3-49f6-a6b5-a679e6a060d3')); + expect(activity.party?.currentSize, equals(2)); + expect(activity.party?.maxSize, equals(2)); + expect(activity.assets?.largeImage, equals('351371005538729000')); + expect(activity.assets?.largeText, equals('DFH Stadium')); + expect(activity.assets?.smallImage, equals('351371005538729111')); + expect(activity.assets?.smallText, equals('Silver III')); + expect(activity.secrets?.join, equals('025ed05c71f639de8bfaa0d679d7c94b2fdce12f')); + expect(activity.secrets?.spectate, equals('e7eb30d2ee025ed05c71ea495f770b76454ee4e0')); + expect(activity.secrets?.match, equals('4b2fdce12f639de8bfa7e3591b71a0d679d7c93f')); + expect(activity.isInstance, isNull); + expect(activity.flags, isNull); + expect(activity.buttons, isNull); +} + +void main() { + group('GatewayManager', () { + test('parseGatewayConfiguration', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + ParsingTest>( + name: 'parseGatewayConfiguration', + source: sampleGateway, + parse: (manager) => manager.parseGatewayConfiguration, + check: checkGateway, + ).runWithManager(GatewayManager(client)); + }); + + test('parseGatewayBot', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + ParsingTest>( + name: 'parseGatewayBot', + source: sampleGatewayBot, + parse: (manager) => manager.parseGatewayBot, + check: checkGatewayBot, + ).runWithManager(GatewayManager(client)); + }); + + test('parseActivity', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + ParsingTest>( + name: 'parseActivity', + source: sampleActivity, + parse: (manager) => manager.parseActivity, + check: checkActivity, + ).runWithManager(GatewayManager(client)); + }); + + testEndpoint( + '/gateway', + name: 'fetchGateway', + (client) => client.gateway.fetchGatewayConfiguration(), + response: sampleGateway, + ); + + testEndpoint( + '/gateway/bot', + name: 'fetchGatewayBot', + (client) => client.gateway.fetchGatewayBot(), + response: sampleGatewayBot, + ); + }); +} diff --git a/test/unit/http/managers/guild_manager_test.dart b/test/unit/http/managers/guild_manager_test.dart new file mode 100644 index 000000000..9af1ee74b --- /dev/null +++ b/test/unit/http/managers/guild_manager_test.dart @@ -0,0 +1,810 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; +import 'channel_manager_test.dart'; +import 'invite_manager_test.dart'; +import 'voice_manager_test.dart'; + +final sampleGuild = { + "id": "197038439483310086", + "name": "Discord Testers", + "icon": "f64c482b807da4f539cff778d174971c", + "description": "The official place to report Discord Bugs!", + "splash": null, + "discovery_splash": null, + "features": [ + "ANIMATED_ICON", + "VERIFIED", + "NEWS", + "VANITY_URL", + "DISCOVERABLE", + // "MORE_EMOJI", // This feature is in the sample guild but is undocumented + "INVITE_SPLASH", + "BANNER", + "COMMUNITY", + ], + "emojis": [], + "banner": "9b6439a7de04f1d26af92f84ac9e1e4a", + "owner_id": "73193882359173120", + "application_id": null, + "region": null, + "afk_channel_id": null, + "afk_timeout": 300, + "system_channel_id": null, + "widget_enabled": true, + "widget_channel_id": null, + "verification_level": 3, + "roles": [], + "default_message_notifications": 1, + "mfa_level": 1, + "explicit_content_filter": 2, + "max_presences": 40000, + "max_members": 250000, + "vanity_url_code": "discord-testers", + "premium_tier": 3, + "premium_subscription_count": 33, + "system_channel_flags": 0, + "preferred_locale": "en-US", + "rules_channel_id": "441688182833020939", + "public_updates_channel_id": "281283303326089216", + + // These fields are documented as always present, but are missing from the provided sample + "nsfw_level": 2, + "premium_progress_bar_enabled": true, +}; + +void checkGuild(Guild guild) { + expect(guild.id, equals(Snowflake(197038439483310086))); + expect(guild.name, equals('Discord Testers')); + expect(guild.iconHash, equals('f64c482b807da4f539cff778d174971c')); + expect(guild.splashHash, isNull); + expect(guild.discoverySplashHash, isNull); + expect(guild.isOwnedByCurrentUser, isNull); + expect(guild.ownerId, equals(Snowflake(73193882359173120))); + expect(guild.currentUserPermissions, isNull); + expect(guild.afkChannelId, isNull); + expect(guild.afkTimeout, equals(Duration(seconds: 300))); + expect(guild.isWidgetEnabled, isTrue); + expect(guild.widgetChannelId, isNull); + expect(guild.verificationLevel, equals(VerificationLevel.high)); + expect(guild.defaultMessageNotificationLevel, equals(MessageNotificationLevel.onlyMentions)); + expect(guild.explicitContentFilterLevel, equals(ExplicitContentFilterLevel.allMembers)); + expect( + guild.features, + equals( + GuildFeatures.animatedIcon | + GuildFeatures.verified | + GuildFeatures.news | + GuildFeatures.vanityUrl | + GuildFeatures.discoverable | + // GuildFeatures.moreEmoji | + GuildFeatures.inviteSplash | + GuildFeatures.banner | + GuildFeatures.community, + ), + ); + expect(guild.mfaLevel, equals(MfaLevel.elevated)); + expect(guild.applicationId, isNull); + expect(guild.systemChannelId, isNull); + expect(guild.systemChannelFlags, equals(SystemChannelFlags(0))); + expect(guild.rulesChannelId, equals(Snowflake(441688182833020939))); + expect(guild.maxPresences, equals(40000)); + expect(guild.maxMembers, equals(250000)); + expect(guild.vanityUrlCode, equals('discord-testers')); + expect(guild.description, equals('The official place to report Discord Bugs!')); + expect(guild.bannerHash, equals('9b6439a7de04f1d26af92f84ac9e1e4a')); + expect(guild.premiumTier, equals(PremiumTier.three)); + expect(guild.premiumSubscriptionCount, equals(33)); + expect(guild.preferredLocale, equals(Locale.enUs)); + expect(guild.publicUpdatesChannelId, equals(Snowflake(281283303326089216))); + expect(guild.maxVideoChannelUsers, isNull); + expect(guild.maxStageChannelUsers, isNull); + expect(guild.approximateMemberCount, isNull); + expect(guild.approximatePresenceCount, isNull); + expect(guild.welcomeScreen, isNull); + expect(guild.nsfwLevel, NsfwLevel.safe); + expect(guild.hasPremiumProgressBarEnabled, isTrue); +} + +final sampleGuild2 = { + "id": "2909267986263572999", + "name": "Mason's Test Server", + "icon": "389030ec9db118cb5b85a732333b7c98", + "description": null, + "splash": "75610b05a0dd09ec2c3c7df9f6975ea0", + "discovery_splash": null, + "approximate_member_count": 2, + "approximate_presence_count": 2, + "features": [ + "INVITE_SPLASH", + "VANITY_URL", + // "COMMERCE", // This feature is in the sample guild but is undocumented + "BANNER", + "NEWS", + "VERIFIED", + "VIP_REGIONS", + ], + "emojis": [ + {"name": "ultrafastparrot", "roles": [], "id": "393564762228785161", "require_colons": true, "managed": false, "animated": true, "available": true} + ], + "banner": "5c3cb8d1bc159937fffe7e641ec96ca7", + "owner_id": "53908232506183680", + "application_id": null, + "region": null, + "afk_channel_id": null, + "afk_timeout": 300, + "system_channel_id": null, + "widget_enabled": true, + "widget_channel_id": "639513352485470208", + "verification_level": 0, + "roles": [ + { + "id": "2909267986263572999", + "name": "@everyone", + "permissions": "49794752", + "position": 0, + "color": 0, + "hoist": false, + "managed": false, + "mentionable": false + } + ], + "default_message_notifications": 1, + "mfa_level": 0, + "explicit_content_filter": 0, + "max_presences": null, + "max_members": 250000, + "max_video_channel_users": 25, + "vanity_url_code": "no", + "premium_tier": 0, + "premium_subscription_count": 0, + "system_channel_flags": 0, + "preferred_locale": "en-US", + "rules_channel_id": null, + "public_updates_channel_id": null, + + // These fields are documented as always present, but are missing from the provided sample + "nsfw_level": 2, + "premium_progress_bar_enabled": true, +}; + +void checkGuild2(Guild guild) { + expect(guild.id, equals(Snowflake(2909267986263572999))); + expect(guild.name, equals("Mason's Test Server")); + expect(guild.iconHash, equals('389030ec9db118cb5b85a732333b7c98')); + expect(guild.splashHash, '75610b05a0dd09ec2c3c7df9f6975ea0'); + expect(guild.discoverySplashHash, isNull); + expect(guild.isOwnedByCurrentUser, isNull); + expect(guild.ownerId, equals(Snowflake(53908232506183680))); + expect(guild.currentUserPermissions, isNull); + expect(guild.afkChannelId, isNull); + expect(guild.afkTimeout, equals(Duration(seconds: 300))); + expect(guild.isWidgetEnabled, isTrue); + expect(guild.widgetChannelId, Snowflake(639513352485470208)); + expect(guild.verificationLevel, equals(VerificationLevel.none)); + expect(guild.defaultMessageNotificationLevel, equals(MessageNotificationLevel.onlyMentions)); + expect(guild.explicitContentFilterLevel, equals(ExplicitContentFilterLevel.disabled)); + expect( + guild.features, + equals( + GuildFeatures.inviteSplash | + GuildFeatures.vanityUrl | + // GuildFeatures.commerce | + GuildFeatures.banner | + GuildFeatures.news | + GuildFeatures.verified | + GuildFeatures.vipRegions, + ), + ); + expect(guild.mfaLevel, equals(MfaLevel.none)); + expect(guild.applicationId, isNull); + expect(guild.systemChannelId, isNull); + expect(guild.systemChannelFlags, equals(SystemChannelFlags(0))); + expect(guild.rulesChannelId, isNull); + expect(guild.maxPresences, isNull); + expect(guild.maxMembers, equals(250000)); + expect(guild.vanityUrlCode, equals('no')); + expect(guild.description, isNull); + expect(guild.bannerHash, equals('5c3cb8d1bc159937fffe7e641ec96ca7')); + expect(guild.premiumTier, equals(PremiumTier.none)); + expect(guild.premiumSubscriptionCount, equals(0)); + expect(guild.preferredLocale, equals(Locale.enUs)); + expect(guild.publicUpdatesChannelId, isNull); + expect(guild.maxVideoChannelUsers, equals(25)); + expect(guild.maxStageChannelUsers, isNull); + expect(guild.approximateMemberCount, equals(2)); + expect(guild.approximatePresenceCount, equals(2)); + expect(guild.welcomeScreen, isNull); + expect(guild.nsfwLevel, NsfwLevel.safe); + expect(guild.hasPremiumProgressBarEnabled, isTrue); +} + +final sampleWelcomeScreen = { + "description": "Discord Developers is a place to learn about Discord's API, bots, and SDKs and integrations. This is NOT a general Discord support server.", + "welcome_channels": [ + {"channel_id": "697138785317814292", "description": "Follow for official Discord API updates", "emoji_id": null, "emoji_name": "📡"}, + {"channel_id": "697236247739105340", "description": "Get help with Bot Verifications", "emoji_id": null, "emoji_name": "📸"}, + {"channel_id": "697489244649816084", "description": "Create amazing things with Discord's API", "emoji_id": null, "emoji_name": "🔬"}, + {"channel_id": "613425918748131338", "description": "Integrate Discord into your game", "emoji_id": null, "emoji_name": "🎮"}, + {"channel_id": "646517734150242346", "description": "Find more places to help you on your quest", "emoji_id": null, "emoji_name": "🔦"} + ] +}; + +void checkWelcomeScreen(WelcomeScreen screen) { + expect( + screen.description, + equals("Discord Developers is a place to learn about Discord's API, bots, and SDKs and integrations. This is NOT a general Discord support server."), + ); + + expect(screen.channels, hasLength(5)); + + expect(screen.channels[0].channelId, equals(Snowflake(697138785317814292))); + expect(screen.channels[0].description, equals('Follow for official Discord API updates')); + expect(screen.channels[0].emojiId, isNull); + expect(screen.channels[0].emojiName, equals('📡')); +} + +final sampleGuildPreview = { + "id": "197038439483310086", + "name": "Discord Testers", + "icon": "f64c482b807da4f539cff778d174971c", + "splash": null, + "discovery_splash": null, + "emojis": [], + "features": [ + "DISCOVERABLE", + "VANITY_URL", + "ANIMATED_ICON", + "INVITE_SPLASH", + "NEWS", + "COMMUNITY", + "BANNER", + "VERIFIED", + // "MORE_EMOJI", // This feature is in the sample guild preview but is undocumented + ], + "approximate_member_count": 60814, + "approximate_presence_count": 20034, + "description": "The official place to report Discord Bugs!", + "stickers": [] +}; + +void checkGuildPreview(GuildPreview preview) { + expect(preview.id, equals(Snowflake(197038439483310086))); + expect(preview.name, equals('Discord Testers')); + expect(preview.iconHash, equals('f64c482b807da4f539cff778d174971c')); + expect(preview.splashHash, isNull); + expect(preview.discoverySplashHash, isNull); + expect( + preview.features, + equals( + GuildFeatures.discoverable | + GuildFeatures.vanityUrl | + GuildFeatures.animatedIcon | + GuildFeatures.inviteSplash | + GuildFeatures.news | + GuildFeatures.community | + GuildFeatures.banner | + GuildFeatures.verified, + ), + ); + expect(preview.approximateMemberCount, equals(60814)); + expect(preview.approximatePresenceCount, equals(20034)); + expect(preview.description, equals('The official place to report Discord Bugs!')); +} + +final sampleWidgetSettings = {"enabled": true, "channel_id": "41771983444115456"}; + +void checkWidgetSettings(WidgetSettings settings) { + expect(settings.isEnabled, isTrue); + expect(settings.channelId, equals(Snowflake(41771983444115456))); +} + +final sampleGuildWidget = { + "id": "290926798626999250", + "name": "Test Server", + "instant_invite": "https://discord.com/invite/abcdefg", + "channels": [ + {"id": "705216630279993882", "name": "elephant", "position": 2}, + {"id": "669583461748992190", "name": "groovy-music", "position": 1} + ], + "members": [ + { + "id": "0", + "username": "1234", + "discriminator": "0000", + "avatar": null, + "status": "online", + "avatar_url": + "https://cdn.discordapp.com/widget-avatars/FfvURgcr3Za92K3JtoCppqnYMppMDc5B-Rll74YrGCU/C-1DyBZPQ6t5q2RuATFuMFgq0_uEMZVzd_6LbGN_uJKvZflobA9diAlTjhf6CAESLLeTuu4dLuHFWOb_PNLteooNfhC4C6k5QgAGuxEOP12tVVVCvX6t64k14PMXZrGTDq8pWZhukP40Wg" + } + ], + "presence_count": 1 +}; + +void checkGuildWidget(GuildWidget widget) { + expect(widget.guildId, equals(Snowflake(290926798626999250))); + expect(widget.name, equals('Test Server')); + expect(widget.invite, equals('https://discord.com/invite/abcdefg')); + expect(widget.presenceCount, equals(1)); + + expect(widget.channels, hasLength(2)); + + expect(widget.users, hasLength(1)); +} + +final sampleBan = { + "reason": "mentioning b1nzy", + "user": {"username": "Mason", "discriminator": "9999", "id": "53908099506183680", "avatar": "a_bab14f271d565501444b2ca3be944b25", "public_flags": 131141} +}; + +void checkBan(Ban ban) { + expect(ban.reason, equals('mentioning b1nzy')); + expect(ban.user.id, equals(Snowflake(53908099506183680))); +} + +final sampleOnboarding = { + "guild_id": "960007075288915998", + "prompts": [ + { + "id": "1067461047608422473", + "title": "What do you want to do in this community?", + "options": [ + { + "id": "1067461047608422476", + "title": "Chat with Friends", + "description": "", + "emoji": {"id": "1070002302032826408", "name": "chat", "animated": false}, + "role_ids": [], + "channel_ids": ["962007075288916001"] + }, + { + "id": "1070004843541954678", + "title": "Get Gud", + "description": "We have excellent teachers!", + "emoji": {"id": null, "name": "😀", "animated": false}, + "role_ids": ["982014491980083211"], + "channel_ids": [] + } + ], + "single_select": false, + "required": false, + "in_onboarding": true, + "type": 0 + } + ], + "default_channel_ids": [ + "998678771706110023", + "998678693058719784", + "1070008122577518632", + "998678764340912138", + "998678704446263309", + "998678683592171602", + "998678699715067986" + ], + "enabled": true +}; + +void checkOnboarding(Onboarding onboarding) { + expect(onboarding.guildId, equals(Snowflake(960007075288915998))); + expect(onboarding.isEnabled, isTrue); + expect( + onboarding.defaultChannelIds, + equals([ + Snowflake(998678771706110023), + Snowflake(998678693058719784), + Snowflake(1070008122577518632), + Snowflake(998678764340912138), + Snowflake(998678704446263309), + Snowflake(998678683592171602), + Snowflake(998678699715067986), + ]), + ); + + expect(onboarding.prompts, hasLength(1)); + final prompt = onboarding.prompts.first; + + expect(prompt.id, equals(Snowflake(1067461047608422473))); + expect(prompt.title, equals('What do you want to do in this community?')); + + expect(prompt.options, hasLength(2)); + final option = prompt.options.first; + + expect(option.id, equals(Snowflake(1067461047608422476))); + expect(option.title, equals('Chat with Friends')); + expect(option.description, equals('')); + expect(option.roleIds, equals([])); + expect(option.channelIds, equals([Snowflake(962007075288916001)])); +} + +final sampleGuildTemplate = { + "code": "hgM48av5Q69A", + "name": "Friends & Family", + "description": "", + "usage_count": 49605, + "creator_id": "132837293881950208", + "creator": {"id": "132837293881950208", "username": "hoges", "avatar": "79b0d9f8c340f2d43e1f78b09f175b62", "discriminator": "0001", "public_flags": 129}, + "created_at": "2020-04-02T21:10:38+00:00", + "updated_at": "2020-05-01T17:57:38+00:00", + "source_guild_id": "678070694164299796", + "serialized_source_guild": { + "name": "Friends & Family", + "description": null, + "region": "us-west", + "verification_level": 0, + "default_message_notifications": 0, + "explicit_content_filter": 0, + "preferred_locale": "en-US", + "afk_timeout": 300, + "roles": [ + { + "id": '0', + "name": "@everyone", + "permissions": "104324689", + "color": 0, + "hoist": false, + "mentionable": false, + } + ], + "channels": [ + { + "name": "Text Channels", + "position": 1, + "topic": null, + "bitrate": 64000, + "user_limit": 0, + "nsfw": false, + "rate_limit_per_user": 0, + "parent_id": null, + "permission_overwrites": [], + "id": '1', + "type": 4 + }, + { + "name": "general", + "position": 1, + "topic": null, + "bitrate": 64000, + "user_limit": 0, + "nsfw": false, + "rate_limit_per_user": 0, + "parent_id": 1, + "permission_overwrites": [], + "id": '2', + "type": 0 + } + ], + "afk_channel_id": null, + "system_channel_id": '2', + "system_channel_flags": 0, + "icon_hash": null + }, + "is_dirty": null +}; + +void checkGuildTemplate(GuildTemplate template) { + expect(template.code, equals('hgM48av5Q69A')); + expect(template.name, equals('Friends & Family')); + expect(template.description, equals('')); + expect(template.usageCount, equals(49605)); + expect(template.creatorId, equals(Snowflake(132837293881950208))); + expect(template.creator.id, equals(Snowflake(132837293881950208))); + expect(template.createdAt, equals(DateTime.utc(2020, 04, 02, 21, 10, 38))); + expect(template.updatedAt, equals(DateTime.utc(2020, 05, 01, 17, 57, 38))); + expect(template.sourceGuildId, equals(Snowflake(678070694164299796))); + expect(template.serializedSourceGuild.name, equals('Friends & Family')); + expect(template.isDirty, isNull); +} + +void main() { + testManager( + 'GuildManager', + (client, config) => GuildManager(client, config), + RegExp(r'/guilds/\d+'), + '/guilds', + sampleObject: sampleGuild, + sampleMatches: checkGuild, + additionalSampleObjects: [sampleGuild2], + additionalSampleMatchers: [checkGuild2], + additionalParsingTests: [ + ParsingTest>( + name: 'parseWelcomeScreen', + source: sampleWelcomeScreen, + parse: (manager) => manager.parseWelcomeScreen, + check: checkWelcomeScreen, + ), + ParsingTest>( + name: 'parseGuildPreview', + source: sampleGuildPreview, + parse: (manager) => manager.parseGuildPreview, + check: checkGuildPreview, + ), + ParsingTest>( + name: 'parseWidgetSettings', + source: sampleWidgetSettings, + parse: (manager) => manager.parseWidgetSettings, + check: checkWidgetSettings, + ), + ParsingTest>( + name: 'parseGuildWidget', + source: sampleGuildWidget, + parse: (manager) => manager.parseGuildWidget, + check: checkGuildWidget, + ), + ParsingTest>( + name: 'parseBan', + source: sampleBan, + parse: (manager) => manager.parseBan, + check: checkBan, + ), + ParsingTest>( + name: 'parseOnboarding', + source: sampleOnboarding, + parse: (manager) => manager.parseOnboarding, + check: checkOnboarding, + ), + ParsingTest>( + name: 'parseGuildTemplate', + source: sampleGuildTemplate, + parse: (manager) => manager.parseGuildTemplate, + check: checkGuildTemplate, + ), + ], + additionalEndpointTests: [ + EndpointTest>( + name: 'fetchGuildPreview', + source: sampleGuildPreview, + urlMatcher: '/guilds/0/preview', + execute: (manager) => manager.fetchGuildPreview(Snowflake.zero), + check: checkGuildPreview, + ), + EndpointTest, List>( + name: 'fetchGuildChannels', + source: [sampleGuildText], + urlMatcher: '/guilds/0/channels', + execute: (manager) => manager.fetchGuildChannels(Snowflake.zero), + check: (list) { + expect(list, hasLength(1)); + + checkGuildText(list.first); + }, + ), + EndpointTest>( + name: 'createGuildChannel', + source: sampleGuildText, + method: 'POST', + urlMatcher: '/guilds/0/channels', + execute: (manager) => manager.createGuildChannel(Snowflake.zero, GuildTextChannelBuilder(name: 'test')), + check: checkGuildText, + ), + EndpointTest( + name: 'updateChannelPositions', + source: null, + method: 'PATCH', + urlMatcher: '/guilds/0/channels', + execute: (manager) => manager.updateChannelPositions(Snowflake.zero, []), + check: (_) {}, + ), + EndpointTest>( + name: 'listActiveThreads', + source: sampleThreadList, + urlMatcher: '/guilds/0/threads/active', + execute: (manager) => manager.listActiveThreads(Snowflake.zero), + check: checkThreadList, + ), + EndpointTest, List>( + name: 'listBans', + source: [sampleBan], + urlMatcher: '/guilds/0/bans', + execute: (manager) => manager.listBans(Snowflake.zero), + check: (list) { + expect(list, hasLength(1)); + + checkBan(list.first); + }, + ), + EndpointTest>( + name: 'fetchBan', + source: sampleBan, + urlMatcher: '/guilds/0/bans/0', + execute: (manager) => manager.fetchBan(Snowflake.zero, Snowflake.zero), + check: checkBan, + ), + EndpointTest( + name: 'createBan', + method: 'PUT', + source: null, + urlMatcher: '/guilds/0/bans/0', + execute: (manager) => manager.createBan(Snowflake.zero, Snowflake.zero), + check: (_) {}, + ), + EndpointTest( + name: 'deleteBan', + method: 'DELETE', + source: null, + urlMatcher: '/guilds/0/bans/0', + execute: (manager) => manager.deleteBan(Snowflake.zero, Snowflake.zero), + check: (_) {}, + ), + EndpointTest( + name: 'updateMfaLevel', + method: 'POST', + source: 0, + urlMatcher: '/guilds/0/mfa', + execute: (manager) => manager.updateMfaLevel(Snowflake.zero, MfaLevel.none), + check: (level) => expect(level, equals(MfaLevel.none)), + ), + EndpointTest>( + name: 'fetchPruneCount', + source: {'pruned': 0}, + urlMatcher: '/guilds/0/prune', + execute: (manager) => manager.fetchPruneCount(Snowflake.zero), + check: (count) => expect(count, equals(0)), + ), + EndpointTest>( + name: 'startGuildPrune', + method: 'POST', + source: {'pruned': null}, + urlMatcher: '/guilds/0/prune', + execute: (manager) => manager.startGuildPrune(Snowflake.zero), + check: (count) => expect(count, isNull), + ), + EndpointTest, List>( + name: 'listVoiceRegions', + source: [sampleVoiceRegion], + urlMatcher: '/guilds/0/regions', + execute: (manager) => manager.listVoiceRegions(Snowflake.zero), + check: (list) { + expect(list, hasLength(1)); + + checkVoiceRegion(list.first); + }, + ), + EndpointTest>( + name: 'fetchWidgetSettings', + source: sampleWidgetSettings, + urlMatcher: '/guilds/0/widget', + execute: (manager) => manager.fetchWidgetSettings(Snowflake.zero), + check: checkWidgetSettings, + ), + EndpointTest>( + name: 'updateWidgetSettings', + method: 'PATCH', + source: sampleWidgetSettings, + urlMatcher: '/guilds/0/widget', + execute: (manager) => manager.updateWidgetSettings(Snowflake.zero, WidgetSettingsUpdateBuilder()), + check: checkWidgetSettings, + ), + EndpointTest>( + name: 'fetchGuildWidget', + source: sampleGuildWidget, + urlMatcher: '/guilds/0/widget.json', + execute: (manager) => manager.fetchGuildWidget(Snowflake.zero), + check: checkGuildWidget, + ), + EndpointTest( + name: 'fetchGuildWidgetImage', + source: '""', + urlMatcher: '/guilds/0/widget.png', + execute: (manager) => manager.fetchGuildWidgetImage(Snowflake.zero), + check: (data) { + // We can't pass arbitrary bytes to [source], so reconstruct what the binary data + // for the value we did pass would be. + final expectedData = utf8.encode(jsonEncode('""')); + expect(data, equals(expectedData)); + }, + ), + EndpointTest>( + name: 'fetchWelcomeScreen', + source: sampleWelcomeScreen, + urlMatcher: '/guilds/0/welcome-screen', + execute: (manager) => manager.fetchWelcomeScreen(Snowflake.zero), + check: checkWelcomeScreen, + ), + EndpointTest>( + name: 'updateWelcomeScreen', + method: 'PATCH', + source: sampleWelcomeScreen, + urlMatcher: '/guilds/0/welcome-screen', + execute: (manager) => manager.updateWelcomeScreen(Snowflake.zero, WelcomeScreenUpdateBuilder()), + check: checkWelcomeScreen, + ), + EndpointTest>( + name: 'fetchOnboarding', + source: sampleOnboarding, + urlMatcher: '/guilds/0/onboarding', + execute: (manager) => manager.fetchOnboarding(Snowflake.zero), + check: checkOnboarding, + ), + EndpointTest( + name: 'updateCurrentUserVoiceState', + method: 'PATCH', + source: null, + urlMatcher: '/guilds/0/voice-states/@me', + execute: (manager) => manager.updateCurrentUserVoiceState(Snowflake.zero, CurrentUserVoiceStateUpdateBuilder()), + check: (_) {}, + ), + EndpointTest( + name: 'updateVoiceState', + method: 'PATCH', + source: null, + urlMatcher: '/guilds/0/voice-states/0', + execute: (manager) => manager.updateVoiceState(Snowflake.zero, Snowflake.zero, VoiceStateUpdateBuilder()), + check: (_) {}, + ), + EndpointTest>( + name: 'fetchGuildTemplate', + source: sampleGuildTemplate, + urlMatcher: '/guilds/templates/test', + execute: (manager) => manager.fetchGuildTemplate('test'), + check: checkGuildTemplate, + ), + EndpointTest>( + name: 'createGuildFromTemplate', + method: 'POST', + source: sampleGuild, + urlMatcher: '/guilds/templates/test', + execute: (manager) => manager.createGuildFromTemplate('test', name: 'test guild'), + check: checkGuild, + ), + EndpointTest, List>( + name: 'listGuildTemplates', + source: [sampleGuildTemplate], + urlMatcher: '/guilds/0/templates', + execute: (manager) => manager.listGuildTemplates(Snowflake.zero), + check: (list) { + expect(list, hasLength(1)); + checkGuildTemplate(list.single); + }, + ), + EndpointTest>( + name: 'createGuildTemplate', + method: 'POST', + source: sampleGuildTemplate, + urlMatcher: '/guilds/0/templates', + execute: (manager) => manager.createGuildTemplate(Snowflake.zero, GuildTemplateBuilder(name: 'test')), + check: checkGuildTemplate, + ), + EndpointTest>( + name: 'syncGuildTemplate', + method: 'PUT', + source: sampleGuildTemplate, + urlMatcher: '/guilds/0/templates/test', + execute: (manager) => manager.syncGuildTemplate(Snowflake.zero, 'test'), + check: checkGuildTemplate, + ), + EndpointTest>( + name: 'updateGuildTemplate', + method: 'PATCH', + source: sampleGuildTemplate, + urlMatcher: '/guilds/0/templates/test', + execute: (manager) => manager.updateGuildTemplate(Snowflake.zero, 'test', GuildTemplateUpdateBuilder()), + check: checkGuildTemplate, + ), + EndpointTest>( + name: 'deleteGuildTemplate', + method: 'DELETE', + source: sampleGuildTemplate, + urlMatcher: '/guilds/0/templates/test', + execute: (manager) => manager.deleteGuildTemplate(Snowflake.zero, 'test'), + check: checkGuildTemplate, + ), + EndpointTest, List>( + name: 'listInvites', + source: [sampleInvite], + urlMatcher: '/guilds/0/invites', + execute: (manager) => manager.listInvites(Snowflake.zero), + check: (list) { + expect(list, hasLength(1)); + + checkInvite(list.single); + }, + ) + ], + createBuilder: GuildBuilder(name: 'Test guild'), + updateBuilder: GuildUpdateBuilder(), + ); +} diff --git a/test/unit/http/managers/integration_manager_test.dart b/test/unit/http/managers/integration_manager_test.dart new file mode 100644 index 000000000..b6068583b --- /dev/null +++ b/test/unit/http/managers/integration_manager_test.dart @@ -0,0 +1,69 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; + +final sampleIntegration = { + // Changed to 1 so we find it when testing fetch() + "id": "1", + "name": "test", + "type": "youtube", + "enabled": true, + "account": { + "id": "0", + "name": "account name", + }, +}; + +void checkIntegration(Integration integration) { + expect(integration.id, equals(Snowflake(1))); + expect(integration.name, equals("test")); + expect(integration.type, equals("youtube")); + expect(integration.isEnabled, isTrue); + expect(integration.isSyncing, isNull); + expect(integration.roleId, isNull); + expect(integration.enableEmoticons, isNull); + expect(integration.expireBehavior, isNull); + expect(integration.expireGracePeriod, isNull); + expect(integration.user, isNull); + expect(integration.account.id, equals(Snowflake(0))); + expect(integration.syncedAt, isNull); + expect(integration.subscriberCount, isNull); + expect(integration.isRevoked, isNull); + expect(integration.application, isNull); + expect(integration.scopes, isNull); +} + +void main() { + testReadOnlyManager( + 'IntegrationManager', + (config, client) => IntegrationManager(config, client, guildId: Snowflake.zero), + '/guilds/0/integrations', + sampleObject: sampleIntegration, + sampleMatches: checkIntegration, + // Fetch internally uses list(), so we return a list + fetchObjectOverride: [sampleIntegration], + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'list', + source: [sampleIntegration], + urlMatcher: '/guilds/0/integrations', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + + checkIntegration(list.first); + }, + ), + EndpointTest( + name: 'delete', + method: 'DELETE', + source: null, + urlMatcher: '/guilds/0/integrations/0', + execute: (manager) => manager.delete(Snowflake.zero), + check: (_) {}, + ), + ], + ); +} diff --git a/test/unit/http/managers/interaction_manager_test.dart b/test/unit/http/managers/interaction_manager_test.dart new file mode 100644 index 000000000..6cc7eabdd --- /dev/null +++ b/test/unit/http/managers/interaction_manager_test.dart @@ -0,0 +1,188 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../mocks/client.dart'; +import '../../../test_manager.dart'; + +final sampleCommandInteraction = { + "type": 2, + "token": "A_UNIQUE_TOKEN", + "member": { + "user": {"id": "53908232506183680", "username": "Mason", "avatar": "a_d5efa99b3eeaa7dd43acca82f5692432", "discriminator": "1337", "public_flags": 131141}, + "roles": ["539082325061836999"], + "premium_since": null, + "permissions": "2147483647", + "pending": false, + "nick": null, + "mute": false, + "joined_at": "2017-03-13T19:19:14.040000+00:00", + "is_pending": false, + "deaf": false, + + // Fields not present in the example but documented + "flags": 0, + }, + "id": "786008729715212338", + "guild_id": "290926798626357999", + "app_permissions": "442368", + "guild_locale": "en-US", + "locale": "en-US", + "data": { + "options": [ + {"type": 3, "name": "cardname", "value": "The Gitrog Monster"} + ], + "type": 1, + "name": "cardsearch", + "id": "771825006014889984" + }, + "channel_id": "645027906669510667", + + // Fields not present in the example but documented + "application_id": "0", + "version": 1, +}; + +void checkCommandInteraction(Interaction interaction) { + expect(interaction, isA()); + interaction as ApplicationCommandInteraction; + + expect(interaction.id, equals(Snowflake(786008729715212338))); + expect(interaction.applicationId, equals(Snowflake.zero)); + expect(interaction.type, equals(InteractionType.applicationCommand)); + expect(interaction.guildId, equals(Snowflake(290926798626357999))); + expect(interaction.channel, isNull); + expect(interaction.channelId, equals(Snowflake(645027906669510667))); + expect(interaction.member?.id, equals(Snowflake(53908232506183680))); + expect(interaction.user, isNull); + expect(interaction.token, equals('A_UNIQUE_TOKEN')); + expect(interaction.version, equals(1)); + expect(interaction.message, isNull); + expect(interaction.appPermissions, equals(Permissions(442368))); + expect(interaction.locale, equals(Locale.enUs)); + expect(interaction.guildLocale, equals(Locale.enUs)); +} + +final sampleCommandInteraction2 = { + "version": 1, + "type": 2, + "token": "REDACTED", + "member": { + "user": { + "username": "abitofevrything", + "public_flags": 128, + "id": "506759329068613643", + "global_name": "Abitofevrything", + "discriminator": "0", + "avatar_decoration_data": null, + "avatar": "b591ea8a9d057669ea2a6cd3ab450301" + }, + "unusual_dm_activity_until": null, + "roles": ["1034762811726901269"], + "premium_since": null, + "permissions": "562949953421311", + "pending": false, + "nick": null, + "mute": false, + "joined_at": "2022-10-23T10:03:13.019000+00:00", + "flags": 2, + "deaf": false, + "communication_disabled_until": null, + "avatar": null + }, + "locale": "en-GB", + "id": "1145002345244135444", + "guild_locale": "en-US", + "guild_id": "1033681997136146462", + "guild": { + "locale": "en-US", + "id": "1033681997136146462", + "features": ["GUILD_ONBOARDING_EVER_ENABLED", "GUILD_ONBOARDING_HAS_PROMPTS", "NEWS", "GUILD_ONBOARDING", "COMMUNITY"] + }, + "entitlements": [], + "entitlement_sku_ids": [], + "data": { + "type": 1, + "resolved": { + "users": { + "1033681843708510238": { + "username": "Abitofbot", + "public_flags": 0, + "id": "1033681843708510238", + "global_name": null, + "discriminator": "8969", + "bot": true, + "avatar_decoration_data": null, + "avatar": null + } + }, + "members": { + "1033681843708510238": { + "unusual_dm_activity_until": null, + "roles": ["1123231366734168107"], + "premium_since": null, + "permissions": "562949953421311", + "pending": false, + "nick": null, + "joined_at": "2023-06-27T12:40:26.840000+00:00", + "flags": 10, + "communication_disabled_until": null, + "avatar": null + } + } + }, + "options": [ + {"value": "1033681843708510238", "type": 6, "name": "target"}, + {"value": "erfhi", "type": 3, "name": "new-nick"} + ], + "name": "nick", + "id": "1144994260677050489" + }, + "channel_id": "1038831656682930227", + "channel": { + "type": 0, + "topic": null, + "rate_limit_per_user": 0, + "position": 3, + "permissions": "562949953421311", + "parent_id": "1038831638836162580", + "nsfw": false, + "name": "testing", + "last_message_id": "1145000144400552047", + "id": "1038831656682930227", + "guild_id": "1033681997136146462", + "flags": 0 + }, + "application_id": "1033681843708510238", + "app_permissions": "562949953421311", +}; + +void checkCommandInteraction2(Interaction interaction) { + expect(interaction, isA()); +} + +void main() { + group('InteractionManager', () { + test('parse', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + ParsingTest, Map>( + name: 'parse (1)', + source: sampleCommandInteraction, + parse: (manager) => manager.parse, + check: checkCommandInteraction, + ).runWithManager(InteractionManager(client, applicationId: Snowflake.zero)); + + ParsingTest, Map>( + name: 'parse (2)', + source: sampleCommandInteraction2, + parse: (manager) => manager.parse, + check: checkCommandInteraction2, + ).runWithManager(InteractionManager(client, applicationId: Snowflake.zero)); + }); + + // Endpoints are tested in webhook_manager_test.dart as the implementation is the same. + }); +} diff --git a/test/unit/http/managers/invite_manager_test.dart b/test/unit/http/managers/invite_manager_test.dart new file mode 100644 index 000000000..58cac573f --- /dev/null +++ b/test/unit/http/managers/invite_manager_test.dart @@ -0,0 +1,105 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../mocks/client.dart'; +import '../../../test_endpoint.dart'; +import '../../../test_manager.dart'; + +final sampleInvite = { + "code": "0vCdhLbwjZZTWZLD", + "guild": { + "id": "165176875973476352", + "name": "CS:GO Fraggers Only", + "splash": null, + "banner": null, + "description": "Very good description", + "icon": null, + "features": ["NEWS", "DISCOVERABLE"], + "verification_level": 2, + "vanity_url_code": null, + "nsfw_level": 0, + "premium_subscription_count": 5 + }, + "channel": {"id": "165176875973476352", "name": "illuminati", "type": 0}, + "inviter": {"id": "115590097100865541", "username": "speed", "avatar": "deadbeef", "discriminator": "7653", "public_flags": 131328}, + "target_type": 1, + "target_user": {"id": "165176875973476352", "username": "bob", "avatar": "deadbeef", "discriminator": "1234", "public_flags": 64}, + "expires_at": "2017-07-11T17:27:07.299000+00:00", +}; + +void checkInvite(Invite invite) { + expect(invite.code, equals('0vCdhLbwjZZTWZLD')); + // expect(invite.guild.id, equals(Snowflake(165176875973476352))); + expect(invite.channel.id, equals(Snowflake(165176875973476352))); + expect(invite.inviter?.id, equals(Snowflake(115590097100865541))); + expect(invite.targetType, equals(TargetType.stream)); + expect(invite.expiresAt, equals(DateTime.utc(2017, 07, 11, 17, 27, 07, 299))); +} + +final sampleInviteWithMetadata = { + ...sampleInvite, + "uses": 0, + "max_uses": 0, + "max_age": 0, + "temporary": false, + "created_at": "2016-03-31T19:15:39.954000+00:00", +}; + +void checkInviteWithMetadata(InviteWithMetadata invite) { + checkInvite(invite); + + expect(invite.uses, equals(0)); + expect(invite.maxUses, equals(0)); + expect(invite.maxAge, equals(Duration.zero)); + expect(invite.isTemporary, isFalse); + expect(invite.createdAt, equals(DateTime.utc(2016, 3, 31, 19, 15, 39, 954))); +} + +void main() { + group('InviteManager', () { + late MockNyxx client; + late InviteManager manager; + + setUp(() { + client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + manager = InviteManager(client); + }); + + test('parse', () { + ParsingTest>( + name: 'parse', + check: checkInvite, + parse: (m) => m.parse, + source: sampleInvite, + ).runWithManager(manager); + }); + + test('parseWithMetadata', () { + ParsingTest>( + name: 'parseWithMetadata', + check: checkInviteWithMetadata, + parse: (m) => m.parseWithMetadata, + source: sampleInviteWithMetadata, + ).runWithManager(manager); + }); + + testEndpoint( + '/invites/0vCdhLbwjZZTWZLD', + name: 'fetch', + (client) => client.invites.fetch('0vCdhLbwjZZTWZLD'), + response: sampleInvite, + ); + + testEndpoint( + '/invites/0vCdhLbwjZZTWZLD', + name: 'delete', + method: 'DELETE', + (client) => client.invites.delete('0vCdhLbwjZZTWZLD'), + response: sampleInvite, + ); + }); +} diff --git a/test/unit/http/managers/manager_test.dart b/test/unit/http/managers/manager_test.dart new file mode 100644 index 000000000..9367f929f --- /dev/null +++ b/test/unit/http/managers/manager_test.dart @@ -0,0 +1,36 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../mocks/client.dart'; + +class TestFetchException implements Exception {} + +class MockManager extends Manager with Fake { + MockManager(super.config, super.client) : super(identifier: 'MOCK_IDENTIFIER'); + + @override + Future fetch(Snowflake id) => throw TestFetchException(); +} + +class MockSnowflakeEntity extends WritableSnowflakeEntity with Fake { + MockSnowflakeEntity({required super.id}); +} + +void main() { + group('Manager', () { + test('get only calls API when entity is not cached', () { + final manager = MockManager(CacheConfig(), MockNyxx()); + + manager.cache[Snowflake.zero] = MockSnowflakeEntity(id: Snowflake.zero); + + expect(() => manager.get(Snowflake.zero), returnsNormally); + }); + + test('calls API when entity is not cached', () { + final manager = MockManager(CacheConfig(), MockNyxx()); + + expect(() => manager.get(Snowflake.zero), throwsA(isA())); + }); + }); +} diff --git a/test/unit/http/managers/member_manager_test.dart b/test/unit/http/managers/member_manager_test.dart new file mode 100644 index 000000000..674085d93 --- /dev/null +++ b/test/unit/http/managers/member_manager_test.dart @@ -0,0 +1,100 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; +import 'user_manager_test.dart'; + +final sampleMember = { + "user": sampleUser, + "nick": "NOT API SUPPORT", + "avatar": null, + "roles": [], + "joined_at": "2015-04-26T06:26:56.936000+00:00", + "deaf": false, + "mute": false, + + // These fields are documented as always present but are not in the provided sample + "flags": 0, +}; + +void checkMember(Member member) { + expect(member.id, equals(Snowflake(80351110224678912))); + expect(member.user, isNotNull); + expect(member.nick, equals('NOT API SUPPORT')); + expect(member.avatarHash, isNull); + expect(member.roleIds, equals([])); + expect(member.joinedAt, equals(DateTime.utc(2015, 04, 26, 06, 26, 56, 936))); + expect(member.premiumSince, isNull); + expect(member.isDeaf, isFalse); + expect(member.isMute, isFalse); + expect(member.flags, equals(MemberFlags(0))); + expect(member.isPending, isFalse); + expect(member.permissions, isNull); + expect(member.communicationDisabledUntil, isNull); + + expect(member.user, isNotNull); + checkSampleUser(member.user!); +} + +void main() { + testManager( + 'MemberManager', + (client, config) => MemberManager(client, config, guildId: Snowflake.zero), + RegExp(r'/guilds/0/members/\d+'), + RegExp(r'/guilds/0/members/\d+'), + createMethod: 'PUT', + sampleObject: sampleMember, + sampleMatches: checkMember, + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'listMembers', + source: [sampleMember], + urlMatcher: '/guilds/0/members', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + + checkMember(list.first); + }, + ), + EndpointTest, List>( + name: 'searchMembers', + source: [sampleMember], + urlMatcher: '/guilds/0/members/search?query=test', + execute: (manager) => manager.search('test'), + check: (list) { + expect(list, hasLength(1)); + + checkMember(list.first); + }, + ), + EndpointTest>( + name: 'updateCurrentMember', + method: 'PATCH', + source: sampleMember, + urlMatcher: '/guilds/0/members/@me', + execute: (manager) => manager.updateCurrentMember(CurrentMemberUpdateBuilder()), + check: checkMember, + ), + EndpointTest( + name: 'addRole', + method: 'PUT', + source: null, + urlMatcher: '/guilds/0/members/0/roles/0', + execute: (manager) => manager.addRole(Snowflake.zero, Snowflake.zero), + check: (_) {}, + ), + EndpointTest( + name: 'addRole', + method: 'DELETE', + source: null, + urlMatcher: '/guilds/0/members/0/roles/0', + execute: (manager) => manager.removeRole(Snowflake.zero, Snowflake.zero), + check: (_) {}, + ), + ], + createBuilder: MemberBuilder(accessToken: 'TEST_ACCESS_TOKEN', userId: Snowflake.zero), + updateBuilder: MemberUpdateBuilder(), + ); +} diff --git a/test/unit/http/managers/message_manager_test.dart b/test/unit/http/managers/message_manager_test.dart new file mode 100644 index 000000000..e4224fc98 --- /dev/null +++ b/test/unit/http/managers/message_manager_test.dart @@ -0,0 +1,199 @@ +import 'package:test/test.dart'; +import 'package:nyxx/nyxx.dart'; + +import '../../../test_manager.dart'; + +final sampleMessage = { + "reactions": [ + { + "count": 1, + "me": false, + "emoji": {"id": null, "name": "🔥"} + } + ], + "attachments": [], + "tts": false, + "embeds": [], + "timestamp": "2017-07-11T17:27:07.299000+00:00", + "mention_everyone": false, + "id": "334385199974967042", + "pinned": false, + "edited_timestamp": null, + "author": {"username": "Mason", "discriminator": "9999", "id": "53908099506183680", "avatar": "a_bab14f271d565501444b2ca3be944b25"}, + "mention_roles": [], + "content": "Supa Hot", + "channel_id": "290926798999357250", + "mentions": [], + "type": 0, + "sticker_items": [ + { + "id": "0", + "name": "example sticker", + "format_type": 1, + } + ] +}; + +void checkMessage(Message message) { + expect(message.id, equals(Snowflake(334385199974967042))); + expect(message.author.id, equals(Snowflake(53908099506183680))); + expect(message.content, equals('Supa Hot')); + expect(message.timestamp, equals(DateTime.utc(2017, 07, 11, 17, 27, 07, 299))); + expect(message.editedTimestamp, isNull); + expect(message.isTts, isFalse); + expect(message.mentionsEveryone, isFalse); + expect(message.mentions, isEmpty); + expect(message.channelMentions, isEmpty); + expect(message.attachments, isEmpty); + expect(message.embeds, isEmpty); + expect(message.reactions, hasLength(1)); + expect(message.reactions.single.count, equals(1)); + expect(message.reactions.single.me, isFalse); + expect(message.nonce, isNull); + expect(message.isPinned, isFalse); + expect(message.webhookId, isNull); + expect(message.type, equals(MessageType.normal)); + expect(message.activity, isNull); + expect(message.applicationId, isNull); + expect(message.reference, isNull); + expect(message.flags, equals(MessageFlags(0))); + expect(message.referencedMessage, isNull); + expect(message.thread, isNull); + expect(message.position, isNull); + expect(message.roleSubscriptionData, isNull); + expect(message.stickers, hasLength(1)); +} + +final sampleCrosspostedMessage = { + "reactions": [ + { + "count": 1, + "me": false, + "emoji": {"id": null, "name": "🔥"} + } + ], + "attachments": [], + "tts": false, + "embeds": [], + "timestamp": "2017-07-11T17:27:07.299000+00:00", + "mention_everyone": false, + "id": "334385199974967042", + "pinned": false, + "edited_timestamp": null, + "author": {"username": "Mason", "discriminator": "9999", "id": "53908099506183680", "avatar": "a_bab14f271d565501444b2ca3be944b25"}, + "mention_roles": [], + "mention_channels": [ + {"id": "278325129692446722", "guild_id": "278325129692446720", "name": "big-news", "type": 5} + ], + "content": "Big news! In this <#278325129692446722> channel!", + "channel_id": "290926798999357250", + "mentions": [], + "type": 0, + "flags": 2, + "message_reference": {"channel_id": "278325129692446722", "guild_id": "278325129692446720", "message_id": "306588351130107906"} +}; + +void checkCrosspostedMessage(Message message) { + expect(message.id, equals(Snowflake(334385199974967042))); + expect(message.author.id, equals(Snowflake(53908099506183680))); + expect(message.content, equals('Big news! In this <#278325129692446722> channel!')); + expect(message.timestamp, equals(DateTime.utc(2017, 07, 11, 17, 27, 07, 299))); + expect(message.editedTimestamp, isNull); + expect(message.isTts, isFalse); + expect(message.mentionsEveryone, isFalse); + expect(message.mentions, isEmpty); + expect(message.channelMentions, hasLength(1)); + expect(message.channelMentions.single.guildId, equals(Snowflake(278325129692446720))); + expect(message.channelMentions.single.id, equals(Snowflake(278325129692446722))); + expect(message.channelMentions.single.name, equals('big-news')); + expect(message.channelMentions.single.type, equals(ChannelType.guildAnnouncement)); + expect(message.attachments, isEmpty); + expect(message.embeds, isEmpty); + expect(message.reactions, hasLength(1)); + expect(message.reactions.single.count, equals(1)); + expect(message.reactions.single.me, isFalse); + expect(message.nonce, isNull); + expect(message.isPinned, isFalse); + expect(message.webhookId, isNull); + expect(message.type, equals(MessageType.normal)); + expect(message.activity, isNull); + expect(message.applicationId, isNull); + expect(message.reference?.channelId, equals(Snowflake(278325129692446722))); + expect(message.reference?.guildId, equals(Snowflake(278325129692446720))); + expect(message.reference?.messageId, equals(Snowflake(306588351130107906))); + expect(message.flags, equals(MessageFlags(2))); + expect(message.referencedMessage, isNull); + expect(message.thread, isNull); + expect(message.position, isNull); + expect(message.roleSubscriptionData, isNull); +} + +void main() { + testManager( + 'MessageManager', + (config, client) => MessageManager(config, client, channelId: Snowflake.zero), + RegExp(r'/channels/0/messages/\d+'), + '/channels/0/messages', + sampleObject: sampleMessage, + sampleMatches: checkMessage, + additionalSampleObjects: [sampleCrosspostedMessage], + additionalSampleMatchers: [checkCrosspostedMessage], + createBuilder: MessageBuilder(), + updateBuilder: MessageUpdateBuilder(), + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'fetchMany', + source: [sampleMessage], + urlMatcher: '/channels/0/messages', + execute: (manager) => manager.fetchMany(), + check: (list) { + expect(list, hasLength(1)); + checkMessage(list.single); + }, + ), + EndpointTest>( + name: 'crosspost', + method: 'post', + source: sampleCrosspostedMessage, + urlMatcher: '/channels/0/messages/1/crosspost', + execute: (manager) => manager.crosspost(Snowflake(1)), + check: checkCrosspostedMessage, + ), + EndpointTest( + name: 'bulkDelete', + method: 'post', + source: null, + urlMatcher: '/channels/0/messages/bulk-delete', + execute: (manager) => manager.bulkDelete([Snowflake.zero]), + check: (_) {}, + ), + EndpointTest, List>( + name: 'getPins', + source: [sampleMessage], + urlMatcher: '/channels/0/pins', + execute: (manager) => manager.getPins(), + check: (list) { + expect(list, hasLength(1)); + checkMessage(list.single); + }, + ), + EndpointTest( + name: 'pin', + method: 'put', + source: null, + urlMatcher: '/channels/0/pins/1', + execute: (manager) => manager.pin(Snowflake(1)), + check: (_) {}, + ), + EndpointTest( + name: 'unpin', + method: 'delete', + source: null, + urlMatcher: '/channels/0/pins/1', + execute: (manager) => manager.unpin(Snowflake(1)), + check: (_) {}, + ), + ], + ); +} diff --git a/test/unit/http/managers/role_manager_test.dart b/test/unit/http/managers/role_manager_test.dart new file mode 100644 index 000000000..36a26e056 --- /dev/null +++ b/test/unit/http/managers/role_manager_test.dart @@ -0,0 +1,72 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; + +final sampleRole = { + // Changed to 1 so tests find the role with ID 1 + "id": "1", + "name": "WE DEM BOYZZ!!!!!!", + "color": 3447003, + "hoist": true, + "icon": "cf3ced8600b777c9486c6d8d84fb4327", + "unicode_emoji": null, + "position": 1, + "permissions": "66321471", + "managed": false, + "mentionable": false +}; + +void checkRole(Role role) { + expect(role.id, equals(Snowflake(1))); + expect(role.name, equals('WE DEM BOYZZ!!!!!!')); + expect(role.color, equals(DiscordColor(3447003))); + expect(role.isHoisted, isTrue); + expect(role.iconHash, equals('cf3ced8600b777c9486c6d8d84fb4327')); + expect(role.unicodeEmoji, isNull); + expect(role.position, equals(1)); + expect(role.permissions, equals(Permissions(66321471))); + expect(role.isMentionable, isFalse); + expect(role.tags, isNull); +} + +void main() { + testManager( + 'RoleManager', + (config, client) => RoleManager(config, client, guildId: Snowflake.zero), + RegExp(r'/guilds/0/roles(/\d+)?'), + '/guilds/0/roles', + sampleObject: sampleRole, + // Fetch implementation internally uses `list()`, so we return a list + fetchObjectOverride: [sampleRole], + sampleMatches: checkRole, + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'list', + source: [sampleRole], + urlMatcher: '/guilds/0/roles', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + + checkRole(list.first); + }, + ), + EndpointTest, List>( + name: 'updatePositions', + method: 'PATCH', + source: [sampleRole], + urlMatcher: '/guilds/0/roles', + execute: (manager) => manager.updatePositions({}), + check: (list) { + expect(list, hasLength(1)); + + checkRole(list.first); + }, + ), + ], + createBuilder: RoleBuilder(), + updateBuilder: RoleUpdateBuilder(), + ); +} diff --git a/test/unit/http/managers/scheduled_event_manager_test.dart b/test/unit/http/managers/scheduled_event_manager_test.dart new file mode 100644 index 000000000..8a79a8285 --- /dev/null +++ b/test/unit/http/managers/scheduled_event_manager_test.dart @@ -0,0 +1,105 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; +import 'member_manager_test.dart'; +import 'user_manager_test.dart'; + +final sampleScheduledEvent = { + 'id': '0', + 'guild_id': '1', + 'channel_id': '2', + 'creator_id': '3', + 'name': 'test', + 'description': 'a test event', + 'scheduled_start_time': '2023-06-10T16:37:18Z', + 'scheduled_end_time': '2023-06-10T16:37:18Z', + 'privacy_level': 2, + 'status': 1, + 'entity_type': 1, + 'entity_id': '2', + 'creator': sampleUser, + 'user_count': null, + 'image': null, +}; + +void checkScheduledEvent(ScheduledEvent event) { + expect(event.id, equals(Snowflake.zero)); + expect(event.guildId, equals(Snowflake(1))); + expect(event.channelId, equals(Snowflake(2))); + expect(event.creatorId, equals(Snowflake(3))); + expect(event.name, equals('test')); + expect(event.description, equals('a test event')); + expect(event.scheduledStartTime, equals(DateTime.utc(2023, 06, 10, 16, 37, 18))); + expect(event.scheduledEndTime, equals(DateTime.utc(2023, 06, 10, 16, 37, 18))); + expect(event.privacyLevel, equals(PrivacyLevel.guildOnly)); + expect(event.status, equals(EventStatus.scheduled)); + expect(event.type, equals(ScheduledEntityType.stageInstance)); + expect(event.entityId, equals(Snowflake(2))); + expect(event.metadata, isNull); + checkSampleUser(event.creator!); + expect(event.userCount, isNull); + expect(event.coverImageHash, isNull); +} + +final sampleScheduledEventUser = { + 'guild_scheduled_event_id': '0', + 'user': sampleUser, + 'member': sampleMember, +}; + +void checkScheduledEventUser(ScheduledEventUser user) { + expect(user.scheduledEventId, equals(Snowflake.zero)); + checkSampleUser(user.user); + checkMember(user.member!); +} + +void main() { + testManager( + 'ScheduledEventManager', + (config, client) => ScheduledEventManager(config, client, guildId: Snowflake.zero), + RegExp(r'/guilds/0/scheduled-events/\d+'), + '/guilds/0/scheduled-events', + sampleObject: sampleScheduledEvent, + sampleMatches: checkScheduledEvent, + additionalParsingTests: [ + ParsingTest>( + name: 'parseScheduledEventUser', + source: sampleScheduledEventUser, + parse: (manager) => manager.parseScheduledEventUser, + check: checkScheduledEventUser, + ), + ], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'list', + source: [sampleScheduledEvent], + urlMatcher: '/guilds/0/scheduled-events', + execute: (manager) => manager.list(), + check: (list) { + expect(list, hasLength(1)); + checkScheduledEvent(list.single); + }, + ), + EndpointTest, List>( + name: 'listEventUsers', + source: [sampleScheduledEventUser], + urlMatcher: '/guilds/0/scheduled-events/1/users', + execute: (manager) => manager.listEventUsers(Snowflake(1)), + check: (list) { + expect(list, hasLength(1)); + checkScheduledEventUser(list.single); + }, + ), + ], + createBuilder: ScheduledEventBuilder( + channelId: Snowflake.zero, + name: 'test', + privacyLevel: PrivacyLevel.guildOnly, + scheduledStartTime: DateTime(2023), + scheduledEndTime: DateTime(2023), + type: ScheduledEntityType.stageInstance, + ), + updateBuilder: ScheduledEventUpdateBuilder(), + ); +} diff --git a/test/unit/http/managers/sticker_manager_test.dart b/test/unit/http/managers/sticker_manager_test.dart new file mode 100644 index 000000000..0f430c51c --- /dev/null +++ b/test/unit/http/managers/sticker_manager_test.dart @@ -0,0 +1,120 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; + +final sampleGuildSticker = { + "id": "0", + "name": "sampleGuildSticker", + "description": "Example description", + "tags": "first,second,third", + "type": 2, + "format_type": 1, + "available": true, + "user": {"id": "0"}, + "sort_value": 1, + "guild_id": "0", +}; + +final sampleGlobalSticker = { + "id": "0", + "name": "sampleGuildSticker", + "description": "Example description", + "tags": "first,second,third", + "type": 2, + "format_type": 1, + "available": true, + "sort_value": 1, + "pack_id": "0", +}; + +final sampleStickerPack = { + "id": "0", + "stickers": [ + { + "id": "0", + "name": "sampleGuildSticker", + "description": "Example description", + "tags": "first,second,third", + "type": 2, + "format_type": 1, + "available": true, + "sort_value": 1, + "pack_id": "0", + } + ], + "name": "sticker-pack", + "sku_id": "0", + "cover_sticker_id": "0", + "description": "Example description", +}; + +final sampleNitroStickerPacks = { + "sticker_packs": [ + sampleStickerPack, + ], +}; + +void main() { + testManager( + "GuildStickerManager", + (config, client) => GuildStickerManager(config, client, guildId: Snowflake.zero), + RegExp(r'/guilds/0/stickers/\d+'), + '/guilds/0/stickers', + sampleObject: sampleGuildSticker, + sampleMatches: (GuildSticker sticker) { + expect(sticker.id, equals(Snowflake.zero)); + expect(sticker.name, equals("sampleGuildSticker")); + expect(sticker.description, equals("Example description")); + expect(sticker.type, equals(StickerType.guild)); + expect(sticker.formatType, equals(StickerFormatType.png)); + expect(sticker.available, equals(true)); + expect(sticker.sortValue, equals(1)); + expect(sticker.guildId, equals(Snowflake.zero)); + expect(sticker.getTags(), equals(["first", "second", "third"])); + }, + additionalParsingTests: [], + additionalEndpointTests: [], + createBuilder: StickerBuilder(name: "cool_sticker", description: "cool description", tags: "cool,new,tags", file: ImageBuilder(data: [], format: 'png')), + updateBuilder: StickerUpdateBuilder(name: "cool_new_name", tags: "cool,new,tags"), + ); + + testReadOnlyManager( + "GlobalStickerManger", + (config, client) => GlobalStickerManager(config, client), + RegExp(r'/stickers/\d+'), + sampleObject: sampleGlobalSticker, + sampleMatches: (GlobalSticker sticker) { + expect(sticker.id, equals(Snowflake.zero)); + expect(sticker.name, equals("sampleGuildSticker")); + expect(sticker.description, equals("Example description")); + expect(sticker.type, equals(StickerType.guild)); + expect(sticker.formatType, equals(StickerFormatType.png)); + expect(sticker.available, equals(true)); + expect(sticker.sortValue, equals(1)); + expect(sticker.packId, equals(Snowflake.zero)); + expect(sticker.getTags(), equals(["first", "second", "third"])); + }, + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest>( + name: 'sticker-packs', + source: sampleStickerPack, + urlMatcher: '/sticker-packs/0', + execute: (manager) => manager.fetchStickerPack(Snowflake.zero), + check: (stickerPack) { + expect(stickerPack.stickers, hasLength(1)); + }, + ), + EndpointTest, Map>( + name: 'nitro-sticker-packs', + source: sampleNitroStickerPacks, + urlMatcher: '/sticker-packs', + execute: (manager) => manager.fetchNitroStickerPacks(), + check: (stickerPacks) { + expect(stickerPacks, hasLength(1)); + }, + ) + ], + ); +} diff --git a/test/unit/http/managers/user_manager_test.dart b/test/unit/http/managers/user_manager_test.dart new file mode 100644 index 000000000..53b8cb7fd --- /dev/null +++ b/test/unit/http/managers/user_manager_test.dart @@ -0,0 +1,175 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; +import 'channel_manager_test.dart'; +import 'member_manager_test.dart'; + +final sampleUser = { + "id": "80351110224678912", + "username": "Nelly", + "discriminator": "1337", + "avatar": "8342729096ea3675442027381ff50dfe", + "verified": true, + "email": "nelly@discord.com", + "flags": 64, + "banner": "06c16474723fe537c283b8efa61a30c8", + "accent_color": 16711680, + "premium_type": 1, + "public_flags": 64, +}; + +void checkSampleUser(User user) { + expect(user.id, equals(Snowflake(80351110224678912))); + expect(user.username, equals('Nelly')); + expect(user.discriminator, equals('1337')); + expect(user.globalName, isNull); + expect(user.avatarHash, equals('8342729096ea3675442027381ff50dfe')); + expect(user.isBot, isFalse); + expect(user.isSystem, isFalse); + expect(user.hasMfaEnabled, isFalse); + expect(user.bannerHash, equals('06c16474723fe537c283b8efa61a30c8')); + expect(user.accentColor, equals(DiscordColor(16711680))); + expect(user.locale, isNull); + expect(user.flags, equals(UserFlags(64))); + expect(user.nitroType, equals(NitroType.classic)); + expect(user.publicFlags, equals(UserFlags(64))); +} + +final sampleConnection = { + 'id': '1234567890abcdef', + 'name': 'MyUsername', + 'type': 'battlenet', + 'verified': false, + 'friend_sync': true, + 'show_activity': true, + 'two_way_link': false, + 'visibility': 0, +}; + +void checkSampleConnection(connection) { + expect(connection.id, equals('1234567890abcdef')); + expect(connection.name, equals('MyUsername')); + expect(connection.type, equals(ConnectionType.battleNet)); + expect(connection.isRevoked, isNull); + expect(connection.isVerified, isFalse); + expect(connection.isFriendSyncEnabled, isTrue); + expect(connection.showActivity, isTrue); + expect(connection.isTwoWayLink, isFalse); + expect(connection.visibility, ConnectionVisibility.none); +} + +final sampleApplicationRoleConnection = { + 'platform_name': 'test', + 'platform_username': 'user', + 'metadata': {}, +}; + +void checkApplicationRoleConnection(ApplicationRoleConnection connection) { + expect(connection.platformName, equals('test')); + expect(connection.platformUsername, equals('user')); + expect(connection.metadata, equals({})); +} + +void main() { + testReadOnlyManager( + 'UserManager', + UserManager.new, + RegExp(r'/users/\d+'), + sampleObject: sampleUser, + sampleMatches: checkSampleUser, + additionalParsingTests: [ + ParsingTest>( + name: 'parseConnection', + source: sampleConnection, + parse: (manager) => manager.parseConnection, + check: checkSampleConnection, + ), + ], + additionalEndpointTests: [ + EndpointTest>( + name: 'fetchCurrentUser', + source: sampleUser, + urlMatcher: '/users/@me', + execute: (manager) => manager.fetchCurrentUser(), + check: checkSampleUser, + ), + EndpointTest>( + name: 'updateCurrentUser', + source: sampleUser, + urlMatcher: '/users/@me', + method: 'patch', + execute: (manager) => manager.updateCurrentUser(UserUpdateBuilder()), + check: checkSampleUser, + ), + EndpointTest, List>( + name: 'listCurrentUserGuilds', + source: [ + {'id': '0'} + ], + urlMatcher: '/users/@me/guilds', + execute: (manager) => manager.listCurrentUserGuilds(), + check: (list) { + expect(list, hasLength(1)); + expect(list.single.id, equals(Snowflake.zero)); + }, + ), + EndpointTest>( + name: 'fetchCurrentUserMember', + source: sampleMember, + urlMatcher: '/users/@me/guilds/0/member', + execute: (manager) => manager.fetchCurrentUserMember(Snowflake.zero), + check: checkMember, + ), + EndpointTest( + name: 'leaveGuild', + method: 'DELETE', + source: null, + urlMatcher: '/users/@me/guilds/0', + execute: (manager) => manager.leaveGuild(Snowflake.zero), + check: (_) {}, + ), + EndpointTest>( + name: 'createDm', + source: sampleDm, + method: 'POST', + urlMatcher: '/users/@me/channels', + execute: (manager) => manager.createDm(Snowflake.zero), + check: checkDm, + ), + EndpointTest>( + name: 'createGroupDm', + source: sampleGroupDm, + method: 'POST', + urlMatcher: '/users/@me/channels', + execute: (manager) => manager.createGroupDm([], {}), + check: checkGroupDm, + ), + EndpointTest, List>>( + name: 'fetchCurrentUserConnections', + source: [sampleConnection], + urlMatcher: '/users/@me/connections', + execute: (manager) => manager.fetchCurrentUserConnections(), + check: (connections) { + expect(connections, hasLength(1)); + checkSampleConnection(connections.single); + }, + ), + EndpointTest>( + name: 'fetchCurrentUserApplicationRoleConnection', + source: sampleApplicationRoleConnection, + urlMatcher: '/users/@me/applications/0/role-connection', + execute: (manager) => manager.fetchCurrentUserApplicationRoleConnection(Snowflake.zero), + check: checkApplicationRoleConnection, + ), + EndpointTest>( + name: 'updateCurrentUserApplicationRoleConnection', + method: 'PUT', + source: sampleApplicationRoleConnection, + urlMatcher: '/users/@me/applications/0/role-connection', + execute: (manager) => manager.updateCurrentUserApplicationRoleConnection(Snowflake.zero, ApplicationRoleConnectionUpdateBuilder()), + check: checkApplicationRoleConnection, + ), + ], + ); +} diff --git a/test/unit/http/managers/voice_manager_test.dart b/test/unit/http/managers/voice_manager_test.dart new file mode 100644 index 000000000..8d0fbb9db --- /dev/null +++ b/test/unit/http/managers/voice_manager_test.dart @@ -0,0 +1,93 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../mocks/client.dart'; +import '../../../test_endpoint.dart'; +import '../../../test_manager.dart'; + +final sampleVoiceState = { + "channel_id": "157733188964188161", + "user_id": "80351110224678912", + "session_id": "90326bd25d71d39b9ef95b299e3872ff", + "deaf": false, + "mute": false, + "self_deaf": false, + "self_mute": true, + "suppress": false, + "request_to_speak_timestamp": "2021-03-31T18:45:31.297561+00:00", + + // The API reference says this field is always present, but it's missing in the sample + "self_video": false, +}; + +void checkVoiceState(VoiceState state) { + expect(state.guildId, isNull); + expect(state.channelId, equals(Snowflake(157733188964188161))); + expect(state.userId, equals(Snowflake(80351110224678912))); + expect(state.sessionId, equals('90326bd25d71d39b9ef95b299e3872ff')); + expect(state.isServerDeafened, isFalse); + expect(state.isServerMuted, isFalse); + expect(state.isSelfDeafened, isFalse); + expect(state.isSelfMuted, isTrue); + expect(state.isStreaming, isFalse); + expect(state.isVideoEnabled, isFalse); + expect(state.isSuppressed, isFalse); + expect(state.requestedToSpeakAt, equals(DateTime.utc(2021, 3, 31, 18, 45, 31, 297, 561))); +} + +final sampleVoiceRegion = { + "id": "brazil", + "custom": false, + "deprecated": false, + "optimal": false, + "name": "Brazil", +}; + +void checkVoiceRegion(VoiceRegion region) { + expect(region.id, equals('brazil')); + expect(region.name, equals('Brazil')); + expect(region.isOptimal, isFalse); + expect(region.isDeprecated, isFalse); + expect(region.isCustom, isFalse); +} + +void main() { + group('VoiceManager', () { + late MockNyxx client; + late VoiceManager manager; + + setUp(() { + client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + + manager = VoiceManager(client); + }); + + test('parseVoiceState', () { + ParsingTest>( + name: 'parseVoiceState', + source: sampleVoiceState, + parse: (manager) => manager.parseVoiceState, + check: checkVoiceState, + ).runWithManager(manager); + }); + + test('parseVoiceRegion', () { + ParsingTest>( + name: 'parseVoiceRegion', + source: sampleVoiceRegion, + parse: (manager) => manager.parseVoiceRegion, + check: checkVoiceRegion, + ).runWithManager(manager); + }); + + testEndpoint( + name: 'listRegions', + '/voice/regions', + (client) => client.voice.listRegions(), + response: [sampleVoiceRegion], + ); + }); +} diff --git a/test/unit/http/managers/webhook_manager_test.dart b/test/unit/http/managers/webhook_manager_test.dart new file mode 100644 index 000000000..3f36a502b --- /dev/null +++ b/test/unit/http/managers/webhook_manager_test.dart @@ -0,0 +1,168 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../../test_manager.dart'; +import 'message_manager_test.dart'; + +final sampleIncomingWebhook = { + "name": "test webhook", + "type": 1, + "channel_id": "199737254929760256", + "token": "3d89bb7572e0fb30d8128367b3b1b44fecd1726de135cbe28a41f8b2f777c372ba2939e72279b94526ff5d1bd4358d65cf11", + "avatar": null, + "guild_id": "199737254929760256", + "id": "223704706495545344", + "application_id": null, + "user": {"username": "test", "discriminator": "7479", "id": "190320984123768832", "avatar": "b004ec1740a63ca06ae2e14c5cee11f3", "public_flags": 131328} +}; + +void checkIncomingWebhook(Webhook webhook) { + expect(webhook.id, equals(Snowflake(223704706495545344))); + expect(webhook.type, equals(WebhookType.incoming)); + expect(webhook.guildId, equals(Snowflake(199737254929760256))); + expect(webhook.channelId, equals(Snowflake(199737254929760256))); + expect(webhook.user?.id, equals(Snowflake(190320984123768832))); + expect(webhook.name, equals('test webhook')); + expect(webhook.avatarHash, isNull); + expect(webhook.token, equals('3d89bb7572e0fb30d8128367b3b1b44fecd1726de135cbe28a41f8b2f777c372ba2939e72279b94526ff5d1bd4358d65cf11')); + expect(webhook.applicationId, isNull); + expect(webhook.sourceChannel, isNull); + expect(webhook.url, isNull); +} + +final sampleChannelFollowerWebhook = { + "type": 2, + "id": "752831914402115456", + "name": "Guildy name", + "avatar": "bb71f469c158984e265093a81b3397fb", + "channel_id": "561885260615255432", + "guild_id": "56188498421443265", + "application_id": null, + "source_guild": {"id": "56188498421476534", "name": "Guildy name", "icon": "bb71f469c158984e265093a81b3397fb"}, + "source_channel": {"id": "5618852344134324", "name": "announcements"}, + "user": {"username": "test", "discriminator": "7479", "id": "190320984123768832", "avatar": "b004ec1740a63ca06ae2e14c5cee11f3", "public_flags": 131328} +}; + +void checkChannelFollowerWebhook(Webhook webhook) { + expect(webhook.id, equals(Snowflake(752831914402115456))); + expect(webhook.type, equals(WebhookType.channelFollower)); + expect(webhook.guildId, equals(Snowflake(56188498421443265))); + expect(webhook.channelId, equals(Snowflake(561885260615255432))); + expect(webhook.user?.id, equals(Snowflake(190320984123768832))); + expect(webhook.name, equals('Guildy name')); + expect(webhook.avatarHash, equals('bb71f469c158984e265093a81b3397fb')); + expect(webhook.token, isNull); + expect(webhook.applicationId, isNull); + expect(webhook.sourceChannel?.id, equals(Snowflake(5618852344134324))); + expect(webhook.url, isNull); +} + +final sampleApplicationWebhook = { + "type": 3, + "id": "658822586720976555", + "name": "Clyde", + "avatar": "689161dc90ac261d00f1608694ac6bfd", + "channel_id": null, + "guild_id": null, + "application_id": "658822586720976555" +}; + +void checkApplicationWebhook(Webhook webhook) { + expect(webhook.id, equals(Snowflake(658822586720976555))); + expect(webhook.type, equals(WebhookType.application)); + expect(webhook.guildId, isNull); + expect(webhook.channelId, isNull); + expect(webhook.user, isNull); + expect(webhook.name, equals('Clyde')); + expect(webhook.avatarHash, equals('689161dc90ac261d00f1608694ac6bfd')); + expect(webhook.token, isNull); + expect(webhook.applicationId, equals(Snowflake(658822586720976555))); + expect(webhook.sourceChannel, isNull); + expect(webhook.url, isNull); +} + +void main() { + testManager( + 'WebhookManager', + WebhookManager.new, + RegExp(r'/webhooks/\d+'), + RegExp(r'/channels/\d+/webhooks'), + sampleObject: sampleIncomingWebhook, + sampleMatches: checkIncomingWebhook, + additionalSampleObjects: [sampleChannelFollowerWebhook, sampleApplicationWebhook], + additionalSampleMatchers: [checkChannelFollowerWebhook, checkApplicationWebhook], + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest, List>( + name: 'fetchChannelWebhooks', + source: [sampleApplicationWebhook, sampleIncomingWebhook, sampleChannelFollowerWebhook], + urlMatcher: '/channels/0/webhooks', + execute: (manager) => manager.fetchChannelWebhooks(Snowflake(0)), + check: (webhooks) { + expect(webhooks, hasLength(3)); + + checkApplicationWebhook(webhooks[0]); + checkIncomingWebhook(webhooks[1]); + checkChannelFollowerWebhook(webhooks[2]); + }, + ), + EndpointTest, List>( + name: 'fetchGuildWebhooks', + source: [sampleApplicationWebhook, sampleIncomingWebhook, sampleChannelFollowerWebhook], + urlMatcher: '/guilds/0/webhooks', + execute: (manager) => manager.fetchGuildWebhooks(Snowflake(0)), + check: (webhooks) { + expect(webhooks, hasLength(3)); + + checkApplicationWebhook(webhooks[0]); + checkIncomingWebhook(webhooks[1]); + checkChannelFollowerWebhook(webhooks[2]); + }, + ), + EndpointTest( + name: 'execute (no wait)', + source: null, + urlMatcher: '/webhooks/0/token', + method: 'POST', + execute: (manager) => manager.execute(Snowflake(0), MessageBuilder(content: 'foo'), token: 'token'), + check: (_) {}, + ), + EndpointTest>( + name: 'execute (wait)', + source: sampleMessage, + urlMatcher: '/webhooks/0/token?wait=true', + method: 'POST', + execute: (manager) => manager.execute(Snowflake(0), MessageBuilder(content: 'foo'), token: 'token', wait: true), + check: (message) { + expect(message, isNotNull); + checkMessage(message!); + }, + ), + EndpointTest>( + name: 'fetchWebhookMessage', + source: sampleMessage, + urlMatcher: '/webhooks/0/token/messages/1', + execute: (manager) => manager.fetchWebhookMessage(Snowflake(0), Snowflake(1), token: 'token'), + check: checkMessage, + ), + EndpointTest>( + name: 'updateWebhookMessage', + source: sampleMessage, + urlMatcher: '/webhooks/0/token/messages/1', + method: 'PATCH', + execute: (manager) => manager.updateWebhookMessage(Snowflake(0), Snowflake(1), MessageUpdateBuilder(), token: 'token'), + check: checkMessage, + ), + EndpointTest( + name: 'deleteWebhookMessage', + source: null, + urlMatcher: '/webhooks/0/token/messages/1', + method: 'DELETE', + execute: (manager) => manager.deleteWebhookMessage(Snowflake(0), Snowflake(1), token: 'token'), + check: (_) {}, + ), + ], + createBuilder: WebhookBuilder(name: 'Test webhook', channelId: Snowflake(0)), + updateBuilder: WebhookUpdateBuilder(name: 'Updated test webhook'), + ); +} diff --git a/test/unit/http/request_test.dart b/test/unit/http/request_test.dart new file mode 100644 index 000000000..596a9e43b --- /dev/null +++ b/test/unit/http/request_test.dart @@ -0,0 +1,94 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../mocks/client.dart'; + +void main() { + group('BasicRequest', () { + group('prepare', () { + test('has correct route', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test_token_1234')); + + final request = BasicRequest(HttpRoute()..add(HttpRoutePart('test'))).prepare(client); + + expect(request.url, equals(Uri.parse('https://discord.com/api/v${client.apiOptions.apiVersion}/test'))); + + final request2 = BasicRequest( + HttpRoute()..add(HttpRoutePart('test')), + queryParameters: {'foo': 'bar'}, + ).prepare(client); + + expect(request2.url, equals(Uri.parse('https://discord.com/api/v${client.apiOptions.apiVersion}/test?foo=bar'))); + }); + + test('has correct headers', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test_token_1234')); + + final request = BasicRequest(HttpRoute()..add(HttpRoutePart('test'))).prepare(client); + + expect(request.headers['User-Agent'], equals(ApiOptions.defaultUserAgent)); + expect(request.headers['Authorization'], equals(client.apiOptions.authorizationHeader)); + expect(request.headers['Content-Type'], isNot(startsWith(BasicRequest.jsonContentTypeHeader.values.single))); + + final request2 = BasicRequest( + HttpRoute()..add(HttpRoutePart('test')), + body: 'test_body', + ).prepare(client); + + expect(request2.headers['User-Agent'], equals(ApiOptions.defaultUserAgent)); + expect(request2.headers['Authorization'], equals(client.apiOptions.authorizationHeader)); + expect(request2.headers['Content-Type'], startsWith(BasicRequest.jsonContentTypeHeader.values.single)); + }); + + test('has correct body', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test_token_1234')); + + final request = BasicRequest( + HttpRoute()..add(HttpRoutePart('test')), + body: 'test_body', + ).prepare(client); + + expect(request.body, equals('test_body')); + }); + }); + }); + + group('MultipartRequest', () { + group('prepare', () { + test('has correct route', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test_token_1234')); + + final request = MultipartRequest(HttpRoute()..add(HttpRoutePart('test'))).prepare(client); + + expect(request.url, equals(Uri.parse('https://discord.com/api/v${client.apiOptions.apiVersion}/test'))); + }); + + test('has correct headers', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test_token_1234')); + + final request = MultipartRequest(HttpRoute()..add(HttpRoutePart('test'))).prepare(client); + + expect(request.headers['User-Agent'], equals(ApiOptions.defaultUserAgent)); + expect(request.headers['Authorization'], equals(client.apiOptions.authorizationHeader)); + }); + + test('has correct payload', () { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'test_token_1234')); + + final request = MultipartRequest( + HttpRoute()..add(HttpRoutePart('test')), + jsonPayload: 'test_body', + ).prepare(client); + + expect(request.fields['payload_json'], equals('test_body')); + }); + }); + }); +} diff --git a/test/unit/http/response_test.dart b/test/unit/http/response_test.dart new file mode 100644 index 000000000..059cad68d --- /dev/null +++ b/test/unit/http/response_test.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +import '../../mocks/request.dart'; +import '../../mocks/response.dart'; + +void main() { + group('HttpResponseSuccess', () { + test('has correct body', () { + final mockHttpResponse = MockPackageHttpResponse(); + when(() => mockHttpResponse.statusCode).thenReturn(200); + when(() => mockHttpResponse.headers).thenReturn({}); + + final response = HttpResponseSuccess( + response: mockHttpResponse, + request: MockHttpRequest(), + body: Uint8List.fromList(utf8.encode(jsonEncode({'value': 'test body'}))), + ); + + expect(response.textBody, equals(r'{"value":"test body"}')); + expect(response.hasJsonBody, isTrue); + expect(response.jsonBody, equals({'value': 'test body'})); + }); + + test('handles invalid bodies', () { + final mockHttpResponse = MockPackageHttpResponse(); + when(() => mockHttpResponse.statusCode).thenReturn(200); + when(() => mockHttpResponse.headers).thenReturn({}); + + final response = HttpResponseSuccess( + response: mockHttpResponse, + request: MockHttpRequest(), + body: Uint8List.fromList(utf8.encode('some invalid json')), + ); + + expect(response.textBody, equals('some invalid json')); + expect(response.hasJsonBody, isFalse); + expect(response.jsonBody, isNull); + }); + }); + + group('HttpResponseError', () { + test('has correct body', () { + final mockHttpResponse = MockPackageHttpResponse(); + when(() => mockHttpResponse.statusCode).thenReturn(400); + when(() => mockHttpResponse.headers).thenReturn({}); + + final response = HttpResponseSuccess( + response: mockHttpResponse, + request: MockHttpRequest(), + body: Uint8List.fromList(utf8.encode(jsonEncode({'value': 'test body'}))), + ); + + expect(response.textBody, equals(r'{"value":"test body"}')); + expect(response.hasJsonBody, isTrue); + expect(response.jsonBody, equals({'value': 'test body'})); + }); + + test('handles invalid bodies', () { + final mockHttpResponse = MockPackageHttpResponse(); + when(() => mockHttpResponse.statusCode).thenReturn(400); + when(() => mockHttpResponse.headers).thenReturn({}); + + final response = HttpResponseSuccess( + response: mockHttpResponse, + request: MockHttpRequest(), + body: Uint8List.fromList(utf8.encode('some invalid json')), + ); + + expect(response.textBody, equals('some invalid json')); + expect(response.hasJsonBody, isFalse); + expect(response.jsonBody, isNull); + }); + + test('has correct error code', () { + final mockHttpResponse = MockPackageHttpResponse(); + when(() => mockHttpResponse.statusCode).thenReturn(400); + when(() => mockHttpResponse.headers).thenReturn({}); + + final response = HttpResponseError( + response: mockHttpResponse, + request: MockHttpRequest(), + body: Uint8List.fromList(utf8.encode(jsonEncode({ + 'code': 1001, + 'message': 'A dummy message', + }))), + ); + + expect(response.errorCode, equals(1001)); + expect(response.message, equals('A dummy message')); + + final response2 = HttpResponseError( + response: mockHttpResponse, + request: MockHttpRequest(), + body: Uint8List.fromList([]), + ); + + // 400 is the status code we set in the mock above + expect(response2.errorCode, equals(400)); + }); + }); + + group('FieldError', () { + test('pathToName', () { + expect(FieldError.pathToName([]), equals('')); + expect(FieldError.pathToName(['foo']), equals('foo')); + expect(FieldError.pathToName(['foo', 'bar']), equals('foo.bar')); + expect(FieldError.pathToName(['foo', '175', 'bar']), equals('foo[175].bar')); + expect(FieldError.pathToName(['foo', '175', '80', 'bar']), equals('foo[175][80].bar')); + }); + }); + + group('HttpErrorData', () { + test('throws TypeError on invalid input', () { + expect(() => HttpErrorData.parse({'some': 'invalid', 'input': '80'}), throwsA(isA())); + expect(() => HttpErrorData.parse({'code': 'invalid', 'message': '80'}), throwsA(isA())); + }); + + test('parses error messages correctly', () { + final errorData = HttpErrorData.parse({ + "code": 50035, + "errors": { + "activities": { + "0": { + "platform": { + "_errors": [ + {"code": "BASE_TYPE_CHOICES", "message": "Value must be one of ('desktop', 'android', 'ios')."} + ] + }, + "type": { + "_errors": [ + {"code": "BASE_TYPE_CHOICES", "message": "Value must be one of (0, 1, 2, 3, 4, 5)."} + ] + } + } + } + }, + "message": "Invalid Form Body" + }); + + expect(errorData.errorCode, equals(50035)); + expect(errorData.errorMessage, equals('Invalid Form Body')); + expect(errorData.fieldErrors.length, equals(2)); + + expect(errorData.fieldErrors['activities[0].platform']?.errorCode, equals('BASE_TYPE_CHOICES')); + expect(errorData.fieldErrors['activities[0].type']?.errorMessage, equals('Value must be one of (0, 1, 2, 3, 4, 5).')); + }); + }); +} diff --git a/test/unit/http/route_test.dart b/test/unit/http/route_test.dart new file mode 100644 index 000000000..05d1570cb --- /dev/null +++ b/test/unit/http/route_test.dart @@ -0,0 +1,34 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + group('HttpRoute', () { + test('toString encodes route correctly', () { + final route = HttpRoute() + ..add(HttpRoutePart( + 'test', + [HttpRouteParam('one')], + )) + ..add(HttpRoutePart( + 'test-again', + [HttpRouteParam('two'), HttpRouteParam('three')], + )); + + expect(route.toString(), equals('/test/one/test-again/two/three')); + }); + + test('rateLimitId is the same for two routes with different minor parameters', () { + final route1 = HttpRoute()..add(HttpRoutePart('test', [HttpRouteParam('one')])); + final route2 = HttpRoute()..add(HttpRoutePart('test', [HttpRouteParam('one')])); + + expect(route1.rateLimitId, equals(route2.rateLimitId)); + }); + + test('rateLimitId is different for two routes with different major parameters', () { + final route1 = HttpRoute()..add(HttpRoutePart('test', [HttpRouteParam('one', isMajor: true)])); + final route2 = HttpRoute()..add(HttpRoutePart('test', [HttpRouteParam('one', isMajor: true)])); + + expect(route1.rateLimitId, equals(route2.rateLimitId)); + }); + }); +} diff --git a/test/unit/models/discord_color_test.dart b/test/unit/models/discord_color_test.dart new file mode 100644 index 000000000..523238252 --- /dev/null +++ b/test/unit/models/discord_color_test.dart @@ -0,0 +1,49 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + group('DiscordColor', () { + test('r, g, b', () { + final color = DiscordColor(0xffed12); + + expect(color.r, equals(0xff)); + expect(color.g, equals(0xed)); + expect(color.b, equals(0x12)); + }); + + test('equality', () { + final color1 = DiscordColor(0xffed12); + final color2 = DiscordColor.fromRgb(0xff, 0xed, 0x12); + + expect(color1, equals(color2)); + expect(color1.hashCode, equals(color2.hashCode)); + }); + + test('toHexString', () { + expect(DiscordColor(0xffed12).toHexString(), equals('#FFED12')); + expect(DiscordColor(0).toHexString(), equals('#000000')); + }); + + test('fromRgb', () { + final color = DiscordColor.fromRgb(123, 213, 132); + + expect(color.r, equals(123)); + expect(color.g, equals(213)); + expect(color.b, equals(132)); + }); + + test('fromScaledRgb', () { + final color = DiscordColor.fromScaledRgb(1.0, 0.5, 0.0); + + expect(color.r, equals(255)); + expect(color.g, equals(127)); + expect(color.b, equals(0)); + }); + + test('parseHexString', () { + final color = DiscordColor.parseHexString('#ffed12'); + + expect(color, equals(DiscordColor(0xffed12))); + }); + }); +} diff --git a/test/unit/models/snowflake_entity/snowflake_entity_test.dart b/test/unit/models/snowflake_entity/snowflake_entity_test.dart new file mode 100644 index 000000000..2d2c94883 --- /dev/null +++ b/test/unit/models/snowflake_entity/snowflake_entity_test.dart @@ -0,0 +1,45 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +class PartialMockSnowflakeEntity extends WritableSnowflakeEntity { + @override + final MockSnowflakeEntityManager manager = MockSnowflakeEntityManager(); + + PartialMockSnowflakeEntity({required super.id}); +} + +class MockSnowflakeEntity extends PartialMockSnowflakeEntity { + MockSnowflakeEntity({required super.id}); +} + +class MockSnowflakeEntityManager with Mock implements Manager {} + +void main() { + group('SnowflakeEntity', () { + test('equality', () { + final entity1 = MockSnowflakeEntity(id: Snowflake(1)); + final entity2 = MockSnowflakeEntity(id: Snowflake(1)); + + expect(entity1, equals(entity2)); + expect(entity1.hashCode, equals(entity2.hashCode)); + }); + + test('equality with partial entities', () { + final entity = MockSnowflakeEntity(id: Snowflake(1)); + final partial = PartialMockSnowflakeEntity(id: Snowflake(1)); + + expect(entity, equals(partial)); + expect(partial, equals(entity)); + expect(entity.hashCode, equals(partial.hashCode)); + }); + + test('equality between partial entities', () { + final partial1 = PartialMockSnowflakeEntity(id: Snowflake(1)); + final partial2 = PartialMockSnowflakeEntity(id: Snowflake(1)); + + expect(partial1, equals(partial2)); + expect(partial1.hashCode, equals(partial2.hashCode)); + }); + }); +} diff --git a/test/unit/models/snowflake_test.dart b/test/unit/models/snowflake_test.dart new file mode 100644 index 000000000..faf71789c --- /dev/null +++ b/test/unit/models/snowflake_test.dart @@ -0,0 +1,96 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; + +void main() { + group('Snowflake', () { + test('zero has correct value', () { + expect(Snowflake.zero.value, isZero); + expect(Snowflake.zero.isZero, isTrue); + }); + + test('structure is parsed correctly', () { + const snowflake = Snowflake(175928847299117063); + + expect(snowflake.increment, equals(7)); + expect(snowflake.processId, equals(0)); + expect(snowflake.workerId, equals(1)); + expect(snowflake.millisecondsSinceEpoch, equals(41944705796)); + }); + + test('timestamp is parsed correctly', () { + const snowflake = Snowflake(175928847299117063); + + expect(snowflake.timestamp, DateTime.utc(2016, 04, 30, 11, 18, 25, 796)); + }); + + test('equality', () { + final snowflake = Snowflake(0); + + expect(snowflake, equals(Snowflake.zero)); + expect(snowflake.hashCode, equals(Snowflake.zero.hashCode)); + expect(snowflake.compareTo(Snowflake.zero), isZero); + }); + + test('parse', () { + expect(Snowflake.parse('175928847299117063'), equals(Snowflake(175928847299117063))); + }); + + // Indirectly tests fromDateTime + test('now', () { + final snowflake = Snowflake.now(); + + expect(snowflake.increment, isZero); + expect(snowflake.processId, isZero); + expect(snowflake.workerId, isZero); + + expect(snowflake.timestamp.millisecondsSinceEpoch, closeTo(DateTime.now().millisecondsSinceEpoch, 500)); + }); + + test('isBefore', () { + final snowflake1 = Snowflake.fromDateTime(DateTime(2020)); + final snowflake2 = Snowflake.fromDateTime(DateTime(2019)); + + expect(snowflake2.isBefore(snowflake1), isTrue); + expect(snowflake1.isBefore(snowflake2), isFalse); + }); + + test('isAfter', () { + final snowflake1 = Snowflake.fromDateTime(DateTime(2020)); + final snowflake2 = Snowflake.fromDateTime(DateTime(2019)); + + expect(snowflake2.isAfter(snowflake1), isFalse); + expect(snowflake1.isAfter(snowflake2), isTrue); + }); + + test('isAtSameMomentAs', () { + final snowflake1 = Snowflake.fromDateTime(DateTime(2020)); + final snowflake2 = Snowflake.fromDateTime(DateTime(2019)); + final snowflake3 = Snowflake.fromDateTime(DateTime(2020)); + + expect(snowflake2.isAtSameMomentAs(snowflake1), isFalse); + expect(snowflake1.isAtSameMomentAs(snowflake2), isFalse); + expect(snowflake3.isAtSameMomentAs(snowflake1), isTrue); + expect(snowflake2.isAtSameMomentAs(snowflake3), isFalse); + }); + + test('operator +', () { + final snowflake = Snowflake.fromDateTime(DateTime(2022, 1, 1)); + + expect(snowflake + const Duration(days: 1), equals(Snowflake.fromDateTime(DateTime(2022, 1, 2)))); + }); + + test('operator -', () { + final snowflake = Snowflake.fromDateTime(DateTime(2022, 1, 2)); + + expect(snowflake - const Duration(days: 1), equals(Snowflake.fromDateTime(DateTime(2022, 1, 1)))); + }); + + test('compareTo', () { + final snowflake1 = Snowflake.fromDateTime(DateTime(2019)); + final snowflake2 = Snowflake.fromDateTime(DateTime(2018)); + final snowflake3 = Snowflake.fromDateTime(DateTime(2017)); + + expect([snowflake3, snowflake1, snowflake2, snowflake1]..sort(), equals([snowflake3, snowflake2, snowflake1, snowflake1])); + }); + }); +} diff --git a/test/unit/nyxx_test.dart b/test/unit/nyxx_test.dart deleted file mode 100644 index f52a27147..000000000 --- a/test/unit/nyxx_test.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:nyxx/src/client_options.dart'; -import 'package:nyxx/src/core/snowflake.dart'; -import 'package:nyxx/src/internal/exceptions/missing_token_error.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -main() { - test("nyxx rest constructor", () { - expect(() => NyxxFactory.createNyxxRest("", 0, Snowflake.zero(), options: ClientOptions()), throwsA(isA())); - expect(() => NyxxFactory.createNyxxRest("test", 0, Snowflake.zero(), options: ClientOptions()), isNotNull); - }); -} diff --git a/test/unit/snowflake_entity_test.dart b/test/unit/snowflake_entity_test.dart deleted file mode 100644 index e3cdc3354..000000000 --- a/test/unit/snowflake_entity_test.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -main() { - test(".createdAt", () { - final testSnowflake = Snowflake.fromNow(); - final testSnowflakeEntity = SnowflakeEntity(testSnowflake); - - expect(testSnowflake.timestamp, equals(testSnowflakeEntity.createdAt)); - }); - - test(".toString", () { - final testSnowflake = Snowflake(123); - final testSnowflakeEntity = SnowflakeEntity(testSnowflake); - - expect(testSnowflakeEntity.toString(), equals('123')); - }); - - test(".==", () { - final testSnowflake = Snowflake(123); - final testSnowflakeEntity = SnowflakeEntity(testSnowflake); - - expect(testSnowflakeEntity, equals(testSnowflakeEntity)); - expect(testSnowflakeEntity, equals(testSnowflake)); - expect(123, equals(testSnowflakeEntity)); - expect('123', equals(testSnowflakeEntity)); - - expect(DateTime.now(), isNot(equals(testSnowflakeEntity))); - expect('random-stuff', isNot(equals(testSnowflakeEntity))); - }); - - test(".hashCode", () { - final snowflakeValue = 123; - final testSnowflake = Snowflake(123); - final testSnowflakeEntity = SnowflakeEntity(testSnowflake); - - expect(snowflakeValue.hashCode, equals(testSnowflake.hashCode)); - expect(snowflakeValue.hashCode, equals(testSnowflakeEntity.hashCode)); - }); -} diff --git a/test/unit/snowflake_test.dart b/test/unit/snowflake_test.dart deleted file mode 100644 index 70e34b4a6..000000000 --- a/test/unit/snowflake_test.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -void main() { - group("Snowflake", () { - test(".toSnowflake()", () { - const rawSnowflake = 901383075009806336; - final snowflake = rawSnowflake.toSnowflake(); - - expect(snowflake.id, rawSnowflake); - }); - - test("DateTime", () { - final snowflake = Snowflake(901383332011593750); - - expect(snowflake.timestamp, DateTime.fromMillisecondsSinceEpoch(1634976933244).toUtc()); - }); - - test(".fromNow()", () { - final firstNowSnowflake = Snowflake.fromNow(); - final secondNowSnowflake = Snowflake.fromNow(); - - expect(firstNowSnowflake.timestamp.millisecondsSinceEpoch, lessThanOrEqualTo(DateTime.now().millisecondsSinceEpoch)); - expect(firstNowSnowflake.timestamp.millisecondsSinceEpoch, lessThanOrEqualTo(DateTime.now().millisecondsSinceEpoch)); - - expect(firstNowSnowflake.timestamp.millisecondsSinceEpoch, lessThanOrEqualTo(secondNowSnowflake.timestamp.millisecondsSinceEpoch)); - }); - - test(".bulk()", () { - final bulkSnowflake = Snowflake.bulk(); - - expect(bulkSnowflake.timestamp.millisecondsSinceEpoch, lessThanOrEqualTo(DateTime.now().subtract(Duration(days: 14)).millisecondsSinceEpoch)); - }); - - test(".fromDateTime()", () { - final now = DateTime.now(); - - final firstFromDateSnowflake = Snowflake.fromDateTime(now); - final secondFromDateSnowflake = Snowflake.fromDateTime(now); - - expect(firstFromDateSnowflake.timestamp.millisecondsSinceEpoch, equals(now.millisecondsSinceEpoch)); - expect(firstFromDateSnowflake, equals(secondFromDateSnowflake)); - }); - - test(".isZero", () { - final zeroSnowflake = Snowflake(0); - final nonZeroSnowflake = Snowflake(123); - - expect(zeroSnowflake.isZero, isTrue); - expect(nonZeroSnowflake.isZero, isFalse); - }); - - test(".toSnowflakeEntity", () { - final testSnowflake = Snowflake(123); - - final resultingSnowflakeEntity = testSnowflake.toSnowflakeEntity(); - - expect(resultingSnowflakeEntity.id, equals(testSnowflake)); - expect(resultingSnowflakeEntity, equals(testSnowflake)); - }); - - test(".isBefore", () { - final firstSnowflake = Snowflake.fromDateTime(DateTime(2016)); - final secondSnowflake = Snowflake.fromDateTime(DateTime(2020)); - - expect(firstSnowflake.isBefore(secondSnowflake), isTrue); - expect(secondSnowflake.isBefore(firstSnowflake), isFalse); - }); - - test(".isAfter", () { - final firstSnowflake = Snowflake.fromDateTime(DateTime(2016)); - final secondSnowflake = Snowflake.fromDateTime(DateTime(2020)); - - expect(firstSnowflake.isAfter(secondSnowflake), isFalse); - expect(secondSnowflake.isAfter(firstSnowflake), isTrue); - }); - - test(".compareDates", () { - final firstSnowflake = Snowflake.fromDateTime(DateTime(2016)); - final secondSnowflake = Snowflake.fromDateTime(DateTime(2020)); - - expect(Snowflake.compareDates(firstSnowflake, secondSnowflake), lessThan(0)); - expect(Snowflake.compareDates(secondSnowflake, firstSnowflake), greaterThan(0)); - expect(Snowflake.compareDates(secondSnowflake, secondSnowflake), equals(0)); - expect(Snowflake.compareDates(firstSnowflake, firstSnowflake), equals(0)); - }); - - test(".compareTo", () { - final firstSnowflake = Snowflake.fromDateTime(DateTime(2016)); - final secondSnowflake = Snowflake.fromDateTime(DateTime(2020)); - - expect(firstSnowflake.compareTo(secondSnowflake), lessThan(0)); - expect(secondSnowflake.compareTo(firstSnowflake), greaterThan(0)); - expect(secondSnowflake.compareTo(secondSnowflake), equals(0)); - expect(firstSnowflake.compareTo(firstSnowflake), equals(0)); - }); - - test(".toString", () { - final testSnowflake = Snowflake(123); - - expect(testSnowflake.toString(), equals("123")); - }); - - test(".hashCode", () { - final snowflakeValue = 123; - final testSnowflake = Snowflake(snowflakeValue); - - expect(testSnowflake.hashCode, equals(snowflakeValue.hashCode)); - }); - - test("InvalidSnowflakeException", () { - expect(() => Snowflake(true), throwsA(isA())); - expect(() => Snowflake(1.12), throwsA(isA())); - expect(() => Snowflake(DateTime.now()), throwsA(isA())); - expect(() => Snowflake(() => "test"), throwsA(isA())); - expect(() => Snowflake("123test"), throwsA(isA())); - expect(() => Snowflake("test123"), throwsA(isA())); - expect(() => Snowflake("test 123"), throwsA(isA())); - expect(() => Snowflake("123 test"), throwsA(isA())); - }); - }); -} diff --git a/test/unit/utils/flags_test.dart b/test/unit/utils/flags_test.dart new file mode 100644 index 000000000..c357ca53a --- /dev/null +++ b/test/unit/utils/flags_test.dart @@ -0,0 +1,42 @@ +import 'package:nyxx/src/utils/flags.dart'; +import 'package:test/test.dart'; + +void main() { + group('Flag', () { + test('fromOffset gives the correct value', () { + expect(Flag.fromOffset(0).value, equals(1 << 0)); + expect(Flag.fromOffset(1).value, equals(1 << 1)); + expect(Flag.fromOffset(10).value, equals(1 << 10)); + }); + }); + + group('Flags', () { + test('has checks for flags correctly', () { + final zeroFlag = Flag.fromOffset(0); + final oneFlag = Flag.fromOffset(1); + + final flags = Flags(1); + + expect(flags.has(zeroFlag), isTrue); + expect(flags.has(oneFlag), isFalse); + }); + + test('equality', () { + expect(Flags(1), equals(Flags(1))); + }); + + test('|', () { + final flags = Flags(3) | Flag.fromOffset(2) | Flags(0xff00); + + expect(flags, equals(Flags(0xff07))); + }); + + test('iterator', () { + final flags = Flag.fromOffset(3) | Flag.fromOffset(2) | Flag.fromOffset(10); + + expect(flags, hasLength(3)); + expect(flags.first, equals(Flag.fromOffset(2))); + expect(flags.last, equals(Flag.fromOffset(10))); + }); + }); +} diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart deleted file mode 100644 index 15337832e..000000000 --- a/test/unit/utils_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -import '../mocks/enum.mock.dart'; - -void main() { - group("Utils", () { - final inputData = [1, 2, 3, 4]; - - test(".firstWhereSafe has element", () { - final result = inputData.firstWhereSafe((element) => element == 2); - - expect(result, equals(2)); - }); - - test(".firstWhereSafe missing element no else", () { - final result = inputData.firstWhereSafe((element) => element == 10); - - expect(result, isNull); - }); - - test(".firstWhereSafe missing element has else", () { - final result = inputData.firstWhereSafe((element) => element == 10, orElse: () => -1); - - expect(result, equals(-1)); - }); - - test(".firstWhereSafe missing element has else returns null", () { - final result = inputData.firstWhereSafe((element) => element == 10, orElse: () => null); - - expect(result, isNull); - }); - - test('chunk', () { - final result = [1, 2, 3, 4].chunk(2); - - expect( - result, - emitsInOrder([ - [1, 2], - [3, 4] - ])); - }); - }); - - test('extensions', () { - final intExtensionResult = 123.toSnowflakeEntity(); - expect(intExtensionResult.id.id, equals(123)); - - final stringToSnowflakeResult = "456".toSnowflake(); - expect(stringToSnowflakeResult.id, equals(456)); - - final stringToSnowflakeEntityResult = "789".toSnowflakeEntity(); - expect(stringToSnowflakeEntityResult.id.id, equals(789)); - - final snowflakeEntityListExtensionsResult = [intExtensionResult, stringToSnowflakeEntityResult].asSnowflakes(); - expect(snowflakeEntityListExtensionsResult, [intExtensionResult.id, stringToSnowflakeEntityResult.id]); - }); - - test("IEnum", () { - final fistEnum = EnumMock('test'); - final secondEnum = EnumMock("test2"); - - expect(fistEnum, isNot(equals(secondEnum))); - - expect(fistEnum.toString(), 'test'); - expect(fistEnum.hashCode, 'test'.hashCode); - }); -}