diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index ef3a8e0..69bc5e4 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -61,6 +61,7 @@ jobs: env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} TEST_GUILD: ${{ secrets.TEST_GUILD }} + TEST_TEXT_CHANNEL: ${{ secrets.TEST_TEXT_CHANNEL }} steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 76906ac..e92fd45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,42 @@ +## 4.2.0 +__04.10.2024__ + +- feat: Add an alternative method for formatting dates (#27) +- feat: Add methods to stream entities from paginated endpoints (#28) +- feat: Add methods for computing permissions in a channel. (#29) +- bug: Update README.md (#32) +- feat: Restructure nyxx_extensions (#31) +- feat: Add a plugin for detecting when the client joins or leaves a guild (#33) +- feat: Add a method for fetching the children of a `CategoryChannel` (#34) +- feat: Add endpoint pagination for reactions (#35) +- feat: Add getter for everyone role in a guild (#36) +- feat: Add .toBuilder() method to GuildChannel (#39) +- feat: Add extension getter for cdn assets (#37) +- feat: Add support for sanitizing slash commands mentions (#40) +- feat: Add getInviteUrl extension for PartialApplications (#41) +- feat: Add utilities for fetching lists of entities (#42) +- feat: Add extension to get a member's highest role (#43) + ## 4.1.0 __15.11.2023__ + - Added a helper to reply to a message. - Fixed an issue with paginating back to a page that had already been seen. ## 4.0.0 __20.10.2023__ + - Fixed an issue with link formatting. ## 4.0.0-dev.1 __17.09.2023__ + - Bump nyxx to `6.0.0`. See the changelog at https://pub.dev/packages/nyxx for more information. - Removed helpers now in the nyxx package. - Added pagination support. ## 3.2.0 +__10.09.2023__ - Bump nyxx to `4.2.0` - Correctly export `acronym` property on guild diff --git a/README.md b/README.md index e8d802d..ca35e1e 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,40 @@ # nyxx_extensions -[![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_extensions) -[![documentation](https://img.shields.io/badge/Documentation-nyxx_interactions-yellow.svg)](https://www.dartdocs.org/documentation/nyxx_extensions/latest/) +[![Discord](https://discordapp.com/api/guilds/846136758470443069/widget.png?style=shield)](https://discord.gg/nyxx) +[![pub](https://img.shields.io/pub/v/nyxx_extensions.svg)](https://pub.dev/packages/nyxx_extensions) +[![documentation](https://img.shields.io/badge/Documentation-nyxx__extensions-yellow.svg)](https://pub.dev/documentation/nyxx_extensions/latest/) -Simple, robust framework for creating discord bots for Dart language. +Additional utilities and helpers for working with [nyxx](https://pub.dev/packages/nyxx). -
+While the main [nyxx](https://pub.dev/packages/nyxx) package focuses on providing a wrapper only for the features described in [Discord's developer documentation](https://discord.com/developers/docs/intro), this package contains a collection of commonly used functions and plugins when making Discord bots, such as [pagination](https://pub.dev/documentation/nyxx_extensions/latest/nyxx_extensions/Pagination-class.html), [sanitization](https://pub.dev/documentation/nyxx_extensions/latest/nyxx_extensions/sanitizeContent.html) or [endpoint pagination](https://pub.dev/documentation/nyxx_extensions/latest/search.html?q=stream). -### Features - -Additional extensions and subpackages to ease out developing bots in nyxx framework. +This package is very open to contributions as it is built upon the needs of developers using [nyxx](https://pub.dev/packages/nyxx). If you find yourself implementing a feature that isn't specific to your bot, please consider [opening a pull request](https://github.com/nyxx-discord/nyxx_extensions/pulls) and add your code to this package. Read our [contribution document](https://github.com/nyxx-discord/nyxx_extensions/blob/dev/CONTRIBUTING.md) for more information. ## Other nyxx packages -- [nyxx](https://github.com/nyxx-discord/nyxx) -- [nyxx_interactions](https://github.com/nyxx-discord/nyxx_interactions) -- [nyxx_commander](https://github.com/nyxx-discord/nyxx_commander) -- [nyxx_lavalink](https://github.com/nyxx-discord/nyxx_lavalink) -- [nyxx_pagination](https://github.com/nyxx-discord/nyxx_pagination) - -## More examples - -Nyxx examples can be found [here](https://github.com/nyxx-discord/nyxx_extensions/tree/dev/example). +- [nyxx](https://pub.dev/packages/nyxx): The main package wrapping Discord's developer API. +- [nyxx_commands](https://pub.dev/packages/nyxx_commands): A command framework for handling both simple & complex commands. +- [nyxx_lavalink](https://pub.dev/packages/nyxx_lavalink): Lavalink support for playing audio in voice channels. -### Example bots -- [Running on Dart](https://github.com/l7ssha/running_on_dart) +## Additional documentation & help -## Documentation, help and examples +The API documentation for the latest stable version can be found on [pub](https://pub.dev/documentation/nyxx). -**Dartdoc documentation for latest stable version is hosted on [pub](https://www.dartdocs.org/documentation/nyxx_extensions/latest/)** +### [Docs and wiki](https://nyxx.l7ssha.xyz) +Tutorials and wiki articles are hosted here, as well as API documentation for development versions from GitHub. -#### [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) +### [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. -#### [Official nyxx discord server](https://discord.gg/nyxx) -If you need assistance in developing bot using nyxx you can join official nyxx discord guild. +### [Discord API docs](https://discord.dev/) +Discord's API documentation details what nyxx implements & provides more detailed explanations of certain topics. -#### [Discord API docs](https://discordapp.com/developers/docs/intro) -Discord API documentation features rich descriptions about all topics that nyxx covers. - -#### [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_extensions/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_extensions/blob/dev/CONTRIBUTING.md) - -## Credits - -* [Hackzzila's](https://github.com/Hackzzila) for [nyx](https://github.com/Hackzzila/nyx). +Read the [contributing document](https://github.com/nyxx-discord/nyxx/blob/dev/CONTRIBUTING.md) diff --git a/example/example.dart b/example/example.dart index eb12212..298b307 100644 --- a/example/example.dart +++ b/example/example.dart @@ -24,7 +24,7 @@ void main() async { // Sanitizing content makes it safe to send to Discord without triggering any mentions client.onMessageCreate.listen((event) async { if (event.message.content.startsWith('!sanitize')) { - event.message.channel.sendMessage(MessageBuilder( + await event.message.channel.sendMessage(MessageBuilder( content: 'Sanitized content: ${await sanitizeContent(event.message.content, channel: event.message.channel)}', )); } @@ -67,5 +67,15 @@ ullamcorper morbi tincidunt ornare. } }); + client.onMessageCreate.listen((event) async { + if (event.message.content.startsWith('!avatar') && event.message.mentions.isNotEmpty) { + // Display the first mentioned user's avatar with the specified size. + final user = event.message.mentions.first; + await event.message.channel.sendMessage(MessageBuilder( + content: 'Avatar URL: ${user.avatar.get(format: CdnFormat.jpeg, size: 3072)}', + )); + } + }); + // ...and more! } diff --git a/lib/nyxx_extensions.dart b/lib/nyxx_extensions.dart index 394f59e..761868a 100644 --- a/lib/nyxx_extensions.dart +++ b/lib/nyxx_extensions.dart @@ -1,13 +1,34 @@ +/// Extensions and additional utilities for working with [nyxx](https://pub.dev/packages/nyxx). library nyxx_extensions; -export 'src/channel.dart'; -export 'src/date_time.dart'; -export 'src/embed_builder.dart'; -export 'src/emoji.dart'; -export 'src/guild.dart'; -export 'src/message.dart'; -export 'src/pagination.dart'; -export 'src/role.dart'; -export 'src/sanitizer.dart'; -export 'src/user.dart'; +export 'src/utils/emoji.dart'; +export 'src/utils/endpoint_paginator.dart' hide streamPaginatedEndpoint; export 'src/utils/formatters.dart'; +export 'src/utils/guild_joins.dart'; +export 'src/utils/pagination.dart'; +export 'src/utils/permissions.dart'; +export 'src/utils/sanitizer.dart'; + +export 'src/extensions/cdn_asset.dart' hide getRequest; +export 'src/extensions/channel.dart'; +export 'src/extensions/client.dart'; +export 'src/extensions/date_time.dart'; +export 'src/extensions/embed.dart'; +export 'src/extensions/emoji.dart'; +export 'src/extensions/guild.dart'; +export 'src/extensions/managers/audit_log_manager.dart'; +export 'src/extensions/managers/channel_manager.dart'; +export 'src/extensions/managers/entitlement_manager.dart'; +export 'src/extensions/managers/guild_manager.dart'; +export 'src/extensions/managers/member_manager.dart'; +export 'src/extensions/managers/message_manager.dart'; +export 'src/extensions/managers/scheduled_event_manager.dart'; +export 'src/extensions/managers/user_manager.dart'; +export 'src/extensions/member.dart'; +export 'src/extensions/message.dart'; +export 'src/extensions/role.dart'; +export 'src/extensions/scheduled_event.dart'; +export 'src/extensions/snowflake_entity.dart'; +export 'src/extensions/user.dart'; +export 'src/extensions/list.dart'; +export 'src/extensions/application.dart'; diff --git a/lib/src/channel.dart b/lib/src/channel.dart deleted file mode 100644 index 5dfde75..0000000 --- a/lib/src/channel.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_extensions/src/utils/formatters.dart'; - -extension PartialChannelUtils on PartialChannel { - /// A mention of this channel. - String get mention => channelMention(id); -} - -extension ChannelUtils on Channel { - /// A URL clients can visit to navigate to this channel. - Uri get url => Uri.https(manager.client.apiOptions.host, '/channels/${this is GuildChannel ? '${(this as GuildChannel).guildId}' : '@me'}/$id'); -} diff --git a/lib/src/extensions/application.dart b/lib/src/extensions/application.dart new file mode 100644 index 0000000..b6940c8 --- /dev/null +++ b/lib/src/extensions/application.dart @@ -0,0 +1,23 @@ +import 'package:nyxx/nyxx.dart'; + +/// Extensions on [PartialApplication]s. +extension ApplicationExtensions on PartialApplication { + /// Get a URL users can visit to add this bot to a guild. + Uri getInviteUri({ + List scopes = const ['bot', 'application.commands'], + Flags? permissions, + Snowflake? guildId, + bool? disableGuildSelect, + }) => + Uri.https( + 'discord.com', + '/oauth2/authorize', + { + 'client_id': id.toString(), + 'scope': scopes.join(' '), + if (permissions != null) 'permissions': permissions.value.toString(), + if (guildId != null) 'guild_id': guildId.toString(), + if (disableGuildSelect != null) 'disable_guild_select': disableGuildSelect, + }, + ); +} diff --git a/lib/src/extensions/cdn_asset.dart b/lib/src/extensions/cdn_asset.dart new file mode 100644 index 0000000..6c2ddae --- /dev/null +++ b/lib/src/extensions/cdn_asset.dart @@ -0,0 +1,22 @@ +import 'package:nyxx/nyxx.dart'; + +extension CdnAssetExtensions on CdnAsset { + Uri get({CdnFormat? format, int? size}) => getRequest(this, format ?? defaultFormat, size).prepare(client).url; +} + +// Re-implementing the private method from CdnAsset +CdnRequest getRequest(CdnAsset asset, CdnFormat format, int? size) { + final route = HttpRoute(); + + for (final part in asset.base.parts) { + route.add(part); + } + route.add(HttpRoutePart('${asset.hash}.${format.extension}')); + + return CdnRequest( + route, + queryParameters: { + if (size != null) 'size': size.toString(), + }, + ); +} diff --git a/lib/src/extensions/channel.dart b/lib/src/extensions/channel.dart new file mode 100644 index 0000000..246228d --- /dev/null +++ b/lib/src/extensions/channel.dart @@ -0,0 +1,128 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/extensions/guild.dart'; +import 'package:nyxx_extensions/src/extensions/managers/channel_manager.dart'; +import 'package:nyxx_extensions/src/utils/formatters.dart'; +import 'package:nyxx_extensions/src/utils/permissions.dart'; + +/// Extensions on [PartialChannel]s. +extension PartialChannelExtensions on PartialChannel { + /// A mention of this channel. + String get mention => channelMention(id); +} + +/// Extensions on [Channel]s. +extension ChannelExtensions on Channel { + /// A URL clients can visit to navigate to this channel. + Uri get url => Uri.https(manager.client.apiOptions.host, '/channels/${this is GuildChannel ? '${(this as GuildChannel).guildId}' : '@me'}/$id'); +} + +/// Extensions on [GuildChannel]s. +extension GuildChannelExtensions on GuildChannel { + /// Compute [member]'s permissions in this channel. + /// + /// {@macro compute_permissions_detail} + Future computePermissionsFor(PartialMember member) async => await computePermissions(this, await member.get()); +} + +/// Extensions on all [GuildChannel] types +extension GuildChannelsExtension on T { + /// Create a builder with the properties of this channel + GuildChannelBuilder toBuilder() => switch (this) { + GuildTextChannel channel => GuildTextChannelBuilder( + name: channel.name, + defaultAutoArchiveDuration: channel.defaultAutoArchiveDuration, + isNsfw: channel.isNsfw, + parentId: channel.parentId, + permissionOverwrites: channel.permissionOverwrites + .map((e) => PermissionOverwriteBuilder( + id: e.id, + type: e.type, + allow: e.allow, + deny: e.deny, + )) + .toList(), + position: channel.position, + rateLimitPerUser: channel.rateLimitPerUser, + topic: channel.topic, + ) as GuildChannelBuilder, + GuildVoiceChannel channel => GuildVoiceChannelBuilder( + name: channel.name, + permissionOverwrites: + channel.permissionOverwrites.map((e) => PermissionOverwriteBuilder(id: e.id, type: e.type, allow: e.allow, deny: e.deny)).toList(), + position: channel.position, + isNsfw: channel.isNsfw, + parentId: channel.parentId, + bitRate: channel.bitrate, + rtcRegion: channel.rtcRegion, + userLimit: channel.userLimit, + videoQualityMode: channel.videoQualityMode, + ) as GuildChannelBuilder, + GuildStageChannel channel => GuildStageChannelBuilder( + name: channel.name, + bitRate: channel.bitrate, + isNsfw: channel.isNsfw, + parentId: channel.parentId, + permissionOverwrites: + channel.permissionOverwrites.map((e) => PermissionOverwriteBuilder(id: e.id, type: e.type, allow: e.allow, deny: e.deny)).toList(), + position: channel.position, + rtcRegion: channel.rtcRegion, + userLimit: channel.userLimit, + videoQualityMode: channel.videoQualityMode, + ) as GuildChannelBuilder, + PrivateThread thread => ThreadBuilder.privateThread( + name: thread.name, + autoArchiveDuration: thread.autoArchiveDuration, + invitable: thread.isInvitable, + rateLimitPerUser: thread.rateLimitPerUser, + ) as GuildChannelBuilder, + PublicThread thread => ThreadBuilder.publicThread( + name: thread.name, + autoArchiveDuration: thread.autoArchiveDuration, + rateLimitPerUser: thread.rateLimitPerUser, + ) as GuildChannelBuilder, + GuildCategory category => GuildCategoryBuilder( + name: category.name, + permissionOverwrites: + category.permissionOverwrites.map((e) => PermissionOverwriteBuilder(id: e.id, type: e.type, allow: e.allow, deny: e.deny)).toList(), + position: category.position, + ) as GuildChannelBuilder, + GuildAnnouncementChannel channel => GuildAnnouncementChannelBuilder( + name: channel.name, + defaultAutoArchiveDuration: channel.defaultAutoArchiveDuration, + isNsfw: channel.isNsfw, + parentId: channel.parentId, + permissionOverwrites: + channel.permissionOverwrites.map((e) => PermissionOverwriteBuilder(id: e.id, type: e.type, allow: e.allow, deny: e.deny)).toList(), + position: channel.position, + topic: channel.topic, + ) as GuildChannelBuilder, + _ => GuildChannelBuilder( + name: name, + type: type, + position: position, + permissionOverwrites: permissionOverwrites.map((e) => PermissionOverwriteBuilder(id: e.id, type: e.type, allow: e.allow, deny: e.deny)).toList(), + ), + }; +} + +/// Extensions on [Thread]s. +extension ThreadExtensions on Thread { + /// Same as [listThreadMembers], but has no limit on the number of members returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + Stream streamThreadMembers({bool? withMembers, Snowflake? after, Snowflake? before, int? pageSize}) => + manager.streamThreadMembers(id, withMembers: withMembers, after: after, before: before, pageSize: pageSize); +} + +/// Extensions on [GuildCategory]s. +extension GuildCategoryExtensions on GuildCategory { + /// Fetch the channels in this category. + Future> fetchChannels() async { + final guildChannels = await guild.fetchChannels(); + + return guildChannels.where((element) => element.parentId == id).toList(); + } + + /// Return a list of channels in the client's cache that are in this category. + List get cachedChannels => guild.cachedChannels.where((element) => element.parentId == id).toList(); +} diff --git a/lib/src/extensions/client.dart b/lib/src/extensions/client.dart new file mode 100644 index 0000000..b390e7d --- /dev/null +++ b/lib/src/extensions/client.dart @@ -0,0 +1,13 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/emoji.dart'; + +/// Extensions on [NyxxRest]. +extension NyxxRestExtensions on NyxxRest { + /// List all the text emoji available to this client. + Future> getTextEmojis() async => (await getEmojiDefinitions()) + .map((definition) => TextEmoji(id: Snowflake.zero, manager: guilds[Snowflake.zero].emojis, name: definition.surrogates)) + .toList(); + + /// Get a text emoji by name. + TextEmoji getTextEmoji(String name) => TextEmoji(id: Snowflake.zero, manager: guilds[Snowflake.zero].emojis, name: name); +} diff --git a/lib/src/date_time.dart b/lib/src/extensions/date_time.dart similarity index 75% rename from lib/src/date_time.dart rename to lib/src/extensions/date_time.dart index f341d6f..3b93b42 100644 --- a/lib/src/date_time.dart +++ b/lib/src/extensions/date_time.dart @@ -1,11 +1,13 @@ import 'package:nyxx_extensions/src/utils/formatters.dart'; -extension TimestampStyleDateTime on DateTime { +/// Extensions on [DateTime]. +extension DateTimeExtensions on DateTime { /// Formats the [DateTime] into a date string timestamp. String format([TimestampStyle style = TimestampStyle.none]) => formatDate(this, style); } -extension TimestampStyleDuration on Duration { +/// Extensions on [Duration]. +extension DurationExtensions on Duration { /// Formats the [Duration] into a date string timestamp. /// The style will always be relative to represent as a duration on Discord. String format() => formatDate(DateTime.now().add(this), TimestampStyle.relativeTime); diff --git a/lib/src/embed_builder.dart b/lib/src/extensions/embed.dart similarity index 89% rename from lib/src/embed_builder.dart rename to lib/src/extensions/embed.dart index b8d976f..198c5e1 100644 --- a/lib/src/embed_builder.dart +++ b/lib/src/extensions/embed.dart @@ -1,6 +1,8 @@ import 'package:nyxx/nyxx.dart'; -extension EmbedExtension on Embed { +/// Extensions on [Embed]. +extension EmbedExtensions on Embed { + /// Return an [EmbedBuilder] that can be used to construct this embed. EmbedBuilder toEmbedBuilder() { return EmbedBuilder( author: author?._toEmbedAuthorBuilder(), diff --git a/lib/src/extensions/emoji.dart b/lib/src/extensions/emoji.dart new file mode 100644 index 0000000..8ff7fa7 --- /dev/null +++ b/lib/src/extensions/emoji.dart @@ -0,0 +1,9 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/emoji.dart'; + +/// Extensions on [TextEmoji]. +extension TextEmojiExtensions on TextEmoji { + /// Get the definition of this emoji. + Future getDefinition() async => + (await getEmojiDefinitions()).singleWhere((definition) => definition.surrogates == name || definition.alternateSurrogates == name); +} diff --git a/lib/src/extensions/guild.dart b/lib/src/extensions/guild.dart new file mode 100644 index 0000000..8dd668a --- /dev/null +++ b/lib/src/extensions/guild.dart @@ -0,0 +1,24 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/extensions/managers/guild_manager.dart'; + +/// Extensions on [PartialGuild]s. +extension PartialGuildExtensions on PartialGuild { + /// A URL clients can visit to navigate to this guild. + Uri get url => Uri.https(manager.client.apiOptions.host, '/guilds/$id'); + + /// Same as [listBans], but has no limit on the number of bans returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + Stream streamBans({Snowflake? after, Snowflake? before, int? pageSize}) => manager.streamBans(id, after: after, before: before, pageSize: pageSize); + + /// Return a list of channels in the client's cache that are in this guild. + List get cachedChannels => manager.client.channels.cache.values.whereType().where((element) => element.guildId == id).toList(); +} + +/// Extensions on [Guild]s. +extension GuildExtensions on Guild { + /// The acronym of the guild if no icon is chosen. + String get acronym { + return name.replaceAll(r"'s ", ' ').replaceAllMapped(RegExp(r'\w+'), (match) => match[0]![0]).replaceAll(RegExp(r'\s'), ''); + } +} diff --git a/lib/src/extensions/list.dart b/lib/src/extensions/list.dart new file mode 100644 index 0000000..dfef125 --- /dev/null +++ b/lib/src/extensions/list.dart @@ -0,0 +1,10 @@ +import 'package:nyxx/nyxx.dart'; + +/// Extensions for fetching lists of [SnowflakeEntity]s. +extension PartialList> on List> { + /// Get all the entities in this list using the cached entity if possible. + Future> get() => Future.wait(map((entity) => entity.get())); + + /// Fetch all the entities in this list. + Future> fetch() => Future.wait(map((entity) => entity.fetch())); +} diff --git a/lib/src/extensions/managers/audit_log_manager.dart b/lib/src/extensions/managers/audit_log_manager.dart new file mode 100644 index 0000000..cebdfe7 --- /dev/null +++ b/lib/src/extensions/managers/audit_log_manager.dart @@ -0,0 +1,27 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [AuditLogManager]. +extension AuditLogManagerExtensions on AuditLogManager { + /// Same as [list], but has no limit on the number of entries returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + /// + /// {@macro paginated_endpoint_order_parameters} + Stream stream({ + Snowflake? userId, + AuditLogEvent? type, + Snowflake? before, + Snowflake? after, + int? pageSize, + StreamOrder? order, + }) => + streamPaginatedEndpoint( + ({after, before, limit}) => list(userId: userId, type: type, after: after, before: before, limit: limit), + extractId: (entry) => entry.id, + before: before, + after: after, + pageSize: pageSize, + order: order, + ); +} diff --git a/lib/src/extensions/managers/channel_manager.dart b/lib/src/extensions/managers/channel_manager.dart new file mode 100644 index 0000000..3e76a75 --- /dev/null +++ b/lib/src/extensions/managers/channel_manager.dart @@ -0,0 +1,24 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [ChannelManager]s. +extension ChannelManagerExtensions on ChannelManager { + /// Same as [listThreadMembers], but has no limit on the number of members returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + Stream streamThreadMembers( + Snowflake id, { + bool? withMembers, + Snowflake? after, + Snowflake? before, + int? pageSize, + }) => + streamPaginatedEndpoint( + ({after, before, limit}) => listThreadMembers(id, withMembers: withMembers, after: after, limit: limit), + extractId: (member) => member.userId, + before: before, + after: after, + pageSize: pageSize, + order: StreamOrder.oldestFirst, + ); +} diff --git a/lib/src/extensions/managers/entitlement_manager.dart b/lib/src/extensions/managers/entitlement_manager.dart new file mode 100644 index 0000000..427b39d --- /dev/null +++ b/lib/src/extensions/managers/entitlement_manager.dart @@ -0,0 +1,37 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [EntitlementManager]s. +extension EntitlementManagerExtensions on EntitlementManager { + /// Same as [list], but has no limit on the number of entitlements returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + /// + /// {@macro paginated_endpoint_order_parameters} + Stream stream({ + Snowflake? userId, + List? skuIds, + Snowflake? before, + Snowflake? after, + int? pageSize, + Snowflake? guildId, + bool? excludeEnded, + StreamOrder? order, + }) => + streamPaginatedEndpoint( + ({after, before, limit}) => list( + after: after, + before: before, + excludeEnded: excludeEnded, + guildId: guildId, + limit: limit, + skuIds: skuIds, + userId: userId, + ), + extractId: (entitlement) => entitlement.id, + before: before, + after: after, + pageSize: pageSize, + order: order, + ); +} diff --git a/lib/src/extensions/managers/guild_manager.dart b/lib/src/extensions/managers/guild_manager.dart new file mode 100644 index 0000000..7686074 --- /dev/null +++ b/lib/src/extensions/managers/guild_manager.dart @@ -0,0 +1,23 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [GuildManager]s. +extension GuildManagerExtensions on GuildManager { + /// Same as [listBans], but has no limit on the number of bans returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + Stream streamBans( + Snowflake id, { + Snowflake? after, + Snowflake? before, + int? pageSize, + }) => + streamPaginatedEndpoint( + ({after, before, limit}) => listBans(id, after: after, before: before, limit: limit), + extractId: (ban) => ban.user.id, + before: before, + after: after, + pageSize: pageSize, + order: StreamOrder.oldestFirst, + ); +} diff --git a/lib/src/extensions/managers/member_manager.dart b/lib/src/extensions/managers/member_manager.dart new file mode 100644 index 0000000..9b5e1a9 --- /dev/null +++ b/lib/src/extensions/managers/member_manager.dart @@ -0,0 +1,22 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [MemberManager]s. +extension MemberManagerExtensions on MemberManager { + /// Same as [list], but has no limit on the number of members returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + Stream stream({ + Snowflake? after, + Snowflake? before, + int? pageSize, + }) => + streamPaginatedEndpoint( + ({after, before, limit}) => list(after: after, limit: limit), + extractId: (member) => member.id, + before: before, + after: after, + pageSize: pageSize, + order: StreamOrder.oldestFirst, + ); +} diff --git a/lib/src/extensions/managers/message_manager.dart b/lib/src/extensions/managers/message_manager.dart new file mode 100644 index 0000000..4e4dda7 --- /dev/null +++ b/lib/src/extensions/managers/message_manager.dart @@ -0,0 +1,56 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [MessageManager]s. +extension MessageManagerExtensions on MessageManager { + /// Same as [fetchMany], but has no limit on the number of messages returned. + /// + /// {@template paginated_endpoint_streaming_parameters} + /// If [after] is set, only entities whose ID is after it will be returned. + /// If [before] is set, only entities whose ID is before it will be returned. + /// + /// [pageSize] can be set to control the `limit` parameter of the underlying + /// requests to the paginated endpoint. Most users will want to leave this + /// unset and default to the maximum page size. + /// {@endtemplate} + /// + /// {@template paginated_endpoint_order_parameters} + /// [order] can be set to change the order in which entities are emitted on + /// the returned stream. Entities will be emitted oldest first if it is not + /// set, unless only [before] is provided, in which case entities will be + /// emitted most recent first. + /// {@endtemplate} + Stream stream({ + Snowflake? before, + Snowflake? after, + int? pageSize, + StreamOrder? order, + }) => + streamPaginatedEndpoint( + fetchMany, + extractId: (message) => message.id, + before: before, + after: after, + pageSize: pageSize, + order: order, + ); + + /// Same as [fetchReactions], but has no limit on the number of reactions returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + Stream streamReactions( + Snowflake id, + ReactionBuilder emoji, { + Snowflake? after, + Snowflake? before, + int? pageSize, + }) => + streamPaginatedEndpoint( + ({before, after, limit}) => fetchReactions(id, emoji, after: after, limit: limit), + extractId: (user) => user.id, + before: before, + after: after, + pageSize: pageSize, + order: StreamOrder.oldestFirst, + ); +} diff --git a/lib/src/extensions/managers/role_manager.dart b/lib/src/extensions/managers/role_manager.dart new file mode 100644 index 0000000..b38bdeb --- /dev/null +++ b/lib/src/extensions/managers/role_manager.dart @@ -0,0 +1,6 @@ +import 'package:nyxx/nyxx.dart'; + +extension RoleManagerExtensions on RoleManager { + /// The role representing `@everyone` in this guild. + PartialRole get everyone => this[guildId]; +} diff --git a/lib/src/extensions/managers/scheduled_event_manager.dart b/lib/src/extensions/managers/scheduled_event_manager.dart new file mode 100644 index 0000000..645ba35 --- /dev/null +++ b/lib/src/extensions/managers/scheduled_event_manager.dart @@ -0,0 +1,27 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [ScheduledEventManager]s. +extension ScheduledEventManagerExtensions on ScheduledEventManager { + /// Same as [listEventUsers], but has no limit on the number of users returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + /// + /// {@macro paginated_endpoint_order_parameters} + Stream streamEventUsers( + Snowflake id, { + bool? withMembers, + Snowflake? before, + Snowflake? after, + int? pageSize, + StreamOrder? order, + }) => + streamPaginatedEndpoint( + ({after, before, limit}) => listEventUsers(id, after: after, before: before, limit: limit, withMembers: withMembers), + extractId: (user) => user.user.id, + before: before, + after: after, + pageSize: pageSize, + order: order, + ); +} diff --git a/lib/src/extensions/managers/user_manager.dart b/lib/src/extensions/managers/user_manager.dart new file mode 100644 index 0000000..e4102eb --- /dev/null +++ b/lib/src/extensions/managers/user_manager.dart @@ -0,0 +1,26 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [UserManager]s. +extension UserManagerExtensions on UserManager { + /// Same as [listCurrentUserGuilds], but has no limit on the number of guilds returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + /// + /// {@macro paginated_endpoint_order_parameters} + Stream streamCurrentUserGuilds({ + Snowflake? after, + Snowflake? before, + bool? withCounts, + int? pageSize, + StreamOrder? order, + }) => + streamPaginatedEndpoint( + ({after, before, limit}) => listCurrentUserGuilds(after: after, before: before, limit: limit, withCounts: withCounts), + extractId: (guild) => guild.id, + before: before, + after: after, + pageSize: pageSize, + order: order, + ); +} diff --git a/lib/src/extensions/member.dart b/lib/src/extensions/member.dart new file mode 100644 index 0000000..8c08dd0 --- /dev/null +++ b/lib/src/extensions/member.dart @@ -0,0 +1,10 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/permissions.dart'; + +/// Extensions on [PartialMember]s. +extension PartialMemberExtensions on PartialMember { + /// Compute this member's permissions in [channel]. + /// + /// {@macro compute_permissions_detail} + Future computePermissionsIn(GuildChannel channel) async => await computePermissions(channel, await get()); +} diff --git a/lib/src/message.dart b/lib/src/extensions/message.dart similarity index 63% rename from lib/src/message.dart rename to lib/src/extensions/message.dart index b4b44ae..8a95371 100644 --- a/lib/src/message.dart +++ b/lib/src/extensions/message.dart @@ -1,7 +1,8 @@ import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_extensions/src/channel.dart'; +import 'package:nyxx_extensions/nyxx_extensions.dart'; -extension MessageUtils on Message { +/// Extensions on [Message]s. +extension MessageExtensions on Message { /// A URL clients can visit to navigate to this message. Future get url async => Uri.https(manager.client.apiOptions.host, '${(await channel.get()).url.path}/$id'); @@ -24,4 +25,15 @@ extension MessageUtils on Message { return channel.sendMessage(copiedBuilder); } + + /// Same as [fetchReactions], but has no limit on the number of reactions returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + Stream streamReactions( + ReactionBuilder emoji, { + Snowflake? after, + Snowflake? before, + int? pageSize, + }) => + manager.streamReactions(id, emoji, after: after, before: before, pageSize: pageSize); } diff --git a/lib/src/extensions/role.dart b/lib/src/extensions/role.dart new file mode 100644 index 0000000..4111e39 --- /dev/null +++ b/lib/src/extensions/role.dart @@ -0,0 +1,31 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/formatters.dart'; + +/// Extensions on [PartialRole]s. +extension PartialRoleExtensions on PartialRole { + /// A mention of this role. + String get mention { + if (id == manager.guildId) { + return '@everyone'; + } + + return roleMention(id); + } +} + +/// Extensions on [List]s of [Role]s. +extension RoleList on List { + /// Compare two [Role]s by their positions. + static int compare(Role a, Role b) { + final position = a.position.compareTo(b.position); + if (position != 0) return position; + + return a.id.compareTo(b.id); + } + + /// The highest role in this list. + Role get highest => reduce((a, b) => compare(a, b) < 0 ? b : a); + + /// The roles in this list, sorted from lowest to highest. + List get sorted => List.of(this)..sort(compare); +} diff --git a/lib/src/extensions/scheduled_event.dart b/lib/src/extensions/scheduled_event.dart new file mode 100644 index 0000000..c865074 --- /dev/null +++ b/lib/src/extensions/scheduled_event.dart @@ -0,0 +1,27 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/extensions/managers/scheduled_event_manager.dart'; +import 'package:nyxx_extensions/src/utils/endpoint_paginator.dart'; + +/// Extensions on [PartialScheduledEvent]. +extension PartialScheduledEventExtensions on PartialScheduledEvent { + /// Same as [listUsers], but has no limit on the number of users returned. + /// + /// {@macro paginated_endpoint_streaming_parameters} + /// + /// {@macro paginated_endpoint_order_parameters} + Stream streamUsers({ + bool? withMembers, + Snowflake? before, + Snowflake? after, + int? pageSize, + StreamOrder? order, + }) => + manager.streamEventUsers( + id, + after: after, + before: before, + order: order, + pageSize: pageSize, + withMembers: withMembers, + ); +} diff --git a/lib/src/extensions/snowflake_entity.dart b/lib/src/extensions/snowflake_entity.dart new file mode 100644 index 0000000..111f405 --- /dev/null +++ b/lib/src/extensions/snowflake_entity.dart @@ -0,0 +1,20 @@ +import 'package:nyxx/nyxx.dart'; + +/// Extensions on [SnowflakeEntity]s. +extension SnowflakeEntityExtensions> on SnowflakeEntity { + /// [get] this entity, but return `null` if an exception is thrown (most + /// commonly indicating the entity does not exist). + Future getOrNull() async { + try { + return await get(); + } on Exception { + return null; + } + } +} + +/// Extensions on [ManagedSnowflakeEntity]s. +extension ManagedSnowflakeEntityExtensions> on ManagedSnowflakeEntity { + /// Return this entity from the manager's cache, or `null` if this entity is not cached. + T? getFromCache() => manager.cache[id]; +} diff --git a/lib/src/user.dart b/lib/src/extensions/user.dart similarity index 82% rename from lib/src/user.dart rename to lib/src/extensions/user.dart index 9557276..d9f340b 100644 --- a/lib/src/user.dart +++ b/lib/src/extensions/user.dart @@ -1,7 +1,8 @@ -import "package:nyxx/nyxx.dart"; -import "package:nyxx_extensions/src/utils/formatters.dart"; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/utils/formatters.dart'; -extension UserExtension on PartialUser { +/// Extensions on [PartialUser]. +extension PartialUserExtensions on PartialUser { /// Fetch all the mutual guilds the client shares with this user. /// /// Returns a mapping of the guilds the client and this user share mapped to this user's member in each guild. diff --git a/lib/src/guild.dart b/lib/src/guild.dart deleted file mode 100644 index a41fbb2..0000000 --- a/lib/src/guild.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -extension GuildExtension on Guild { - /// The acronym of the guild if no icon is chosen. - String get acronym { - return name.replaceAll(r"'s ", ' ').replaceAllMapped(RegExp(r'\w+'), (match) => match[0]![0]).replaceAll(RegExp(r'\s'), ''); - } - - /// A URL clients can visit to navigate to this guild. - Uri get url => Uri.https(manager.client.apiOptions.host, '/guilds/$id'); -} diff --git a/lib/src/role.dart b/lib/src/role.dart deleted file mode 100644 index 50aafd4..0000000 --- a/lib/src/role.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_extensions/src/utils/formatters.dart'; - -extension RoleExtension on PartialRole { - /// A mention of this role. - String get mention { - if (id == manager.guildId) { - return '@everyone'; - } - - return roleMention(id); - } -} diff --git a/lib/src/emoji.dart b/lib/src/utils/emoji.dart similarity index 74% rename from lib/src/emoji.dart rename to lib/src/utils/emoji.dart index 9f21361..a52e6ce 100644 --- a/lib/src/emoji.dart +++ b/lib/src/utils/emoji.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:nyxx/nyxx.dart'; import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart'; import 'package:nyxx/src/utils/parsing_helpers.dart'; @@ -51,24 +50,6 @@ class EmojiDefinition with ToStringHelper { }); } -/// Extensions relating to [EmojiDefinition]s on [TextEmoji]. -extension TextEmojiDefinition on TextEmoji { - /// Get the definition of this emoji. - Future getDefinition() async => - (await getEmojiDefinitions()).singleWhere((definition) => definition.surrogates == name || definition.alternateSurrogates == name); -} - -/// Extensions relating to [EmojiDefinition]s on [NyxxRest]. -extension NyxxEmojiDefinitions on NyxxRest { - /// List all the text emoji available to this client. - Future> getTextEmojis() async => (await getEmojiDefinitions()) - .map((definition) => TextEmoji(id: Snowflake.zero, manager: guilds[Snowflake.zero].emojis, name: definition.surrogates)) - .toList(); - - /// Get a text emoji by name. - TextEmoji getTextEmoji(String name) => TextEmoji(id: Snowflake.zero, manager: guilds[Snowflake.zero].emojis, name: name); -} - final _emojiDefinitionsUrl = Uri.parse("https://emzi0767.gl-pages.emzi0767.dev/discord-emoji/discordEmojiMap.min.json"); List? _cachedEmojiDefinitions; DateTime? _cachedAt; diff --git a/lib/src/utils/endpoint_paginator.dart b/lib/src/utils/endpoint_paginator.dart new file mode 100644 index 0000000..081317d --- /dev/null +++ b/lib/src/utils/endpoint_paginator.dart @@ -0,0 +1,88 @@ +import 'package:nyxx/nyxx.dart'; + +/// Controls the order in which entities from paginated endpoints are streamed. +enum StreamOrder { + /// Emit the entities in order of most recent to oldest. + mostRecentFirst, + + /// Emit the entities on order of oldest to most recent. + oldestFirst, +} + +/// Wrap the paginated API call [fetchPage] into a stream. +/// +/// Although this function supports bi-directional emitting of events using the +/// [order] parameter, it can be used for API endpoints that only support +/// pagination in one direction by hard-coding the [order] parameter to match +/// the API order. +Stream streamPaginatedEndpoint( + Future> Function({Snowflake? before, Snowflake? after, int? limit}) fetchPage, { + required Snowflake Function(T) extractId, + required Snowflake? before, + required Snowflake? after, + required int? pageSize, + required StreamOrder? order, +}) async* { + // Both after and before: oldest first + // Only after: oldest first + // Only before: most recent first + // Neither after nor before: oldest first + order ??= before != null && after == null ? StreamOrder.mostRecentFirst : StreamOrder.oldestFirst; + before ??= Snowflake.now(); + after ??= Snowflake.zero; + + var nextPageBefore = before; + var nextPageAfter = after; + + while (true) { + // We choose the order of the pages by passing either before or after + // depending on the stream order. + final page = await switch (order) { + StreamOrder.mostRecentFirst => fetchPage(limit: pageSize, before: nextPageBefore), + StreamOrder.oldestFirst => fetchPage(limit: pageSize, after: nextPageAfter), + }; + + if (page.isEmpty) { + break; + } + + final pageWithIds = [ + for (final entity in page) (id: extractId(entity), entity: entity), + ]; + + // Some endpoints return entities in the same order regardless of if before + // or after were passed. Sort the entities according to our stream order to + // fix this. + // This could probably be made more efficient by assuming that endpoints + // always return entities in either ascending or descending order, but for + // now it's a good sanity check. + if (order == StreamOrder.oldestFirst) { + // Oldest first: ascending order. + pageWithIds.sort((a, b) => a.id.compareTo(b.id)); + } else { + // Most recent first: descending order. + pageWithIds.sort((a, b) => -a.id.compareTo(b.id)); + } + + for (final (:id, :entity) in pageWithIds) { + if (id.isBefore(before) && id.isAfter(after)) { + yield entity; + } + } + + if (order == StreamOrder.oldestFirst) { + nextPageAfter = pageWithIds.last.id; + } else { + nextPageBefore = pageWithIds.last.id; + } + + // The extra == check isn't strictly necessary, but it saves us an API call + // in the common case of setting `before` or `after` to an entity's ID. + if (nextPageAfter.isAfter(before) || nextPageAfter == before) { + break; + } + if (nextPageBefore.isBefore(after) || nextPageBefore == after) { + break; + } + } +} diff --git a/lib/src/utils/formatters.dart b/lib/src/utils/formatters.dart index 7067d16..eb23179 100644 --- a/lib/src/utils/formatters.dart +++ b/lib/src/utils/formatters.dart @@ -1,4 +1,4 @@ -import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/nyxx.dart'; /// Wraps the [code] in a code block with the specified language, if any. String codeBlock(String code, [String language = '']) => '```$language\n$code\n```'; @@ -58,5 +58,9 @@ enum TimestampStyle { /// The style of the timestamp. final String style; + const TimestampStyle(this.style); + + /// Format [date] using this timestamp style. + String format(DateTime date) => formatDate(date, this); } diff --git a/lib/src/utils/guild_joins.dart b/lib/src/utils/guild_joins.dart new file mode 100644 index 0000000..0ddaaa6 --- /dev/null +++ b/lib/src/utils/guild_joins.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +/// A global instance of the [GuildJoins] plugin. +final guildJoins = GuildJoins(); + +/// Provides a way to know when the client joins or leaves a [Guild]. +/// +/// [NyxxGateway.onGuildCreate] and [NyxxGateway.onGuildDelete] can be +/// misleading, as although they do emit an event when the client is added to +/// or removed from a [Guild], they can also emit events in a variety of other +/// scenarios: +/// - When guilds become available or unavailable due to outages +/// - As part of session initialisation, to populate the cache with information +/// about the guild contained in the [ReadyEvent]. +/// +/// This plugin exposes two streams. [onGuildJoin] and [onGuildLeave], that +/// emit the same type of events as [NyxxGateway.onGuildCreate] and +/// [NyxxGateway.onGuildDelete], but only when the event is triggered by the +/// client joining or leaving a guild. +class GuildJoins extends NyxxPlugin { + final StreamController _onGuildJoinController = StreamController.broadcast(); + final StreamController _onGuildLeaveController = StreamController.broadcast(); + + /// A stream of [UnavailableGuildCreateEvent] triggered by the client being + /// added to a [Guild]. + /// + /// As with [NyxxGateway.onGuildCreate], this stream normally emits + /// [GuildCreateEvent]s, other than in the event of an outage. + Stream get onGuildJoin => _onGuildJoinController.stream; + + /// A stream of [GuildDeleteEvent]s triggered by the client being removed + /// from a [Guild]. + Stream get onGuildLeave => _onGuildLeaveController.stream; + + @override + NyxxPluginState createState() => _GuildJoinsState(this); +} + +class _GuildJoinsState extends NyxxPluginState { + final Set _currentGuildIds = {}; + + _GuildJoinsState(super.plugin); + + @override + void afterConnect(NyxxGateway client) { + super.afterConnect(client); + + client.onReady.listen( + (event) => _currentGuildIds.addAll(event.guilds.map((guild) => guild.id)), + ); + + client.onGuildCreate.listen((event) { + if (_currentGuildIds.contains(event.guild.id)) { + return; + } + + _currentGuildIds.add(event.guild.id); + plugin._onGuildJoinController.add(event); + }); + + client.onGuildDelete.listen((event) { + if (event.isUnavailable) { + return; + } + + _currentGuildIds.remove(event.guild.id); + plugin._onGuildLeaveController.add(event); + }); + } +} diff --git a/lib/src/pagination.dart b/lib/src/utils/pagination.dart similarity index 99% rename from lib/src/pagination.dart rename to lib/src/utils/pagination.dart index 927bc20..6896f4c 100644 --- a/lib/src/pagination.dart +++ b/lib/src/utils/pagination.dart @@ -108,7 +108,7 @@ final pagination = Pagination(PaginationOptions()); /// A plugin that adds support for pagination to nyxx clients. /// -/// This plugin must be registered to all client making use of the pagination features. +/// This plugin must be registered to all clients making use of the pagination features. class Pagination extends NyxxPlugin { @override String get name => 'Pagination'; diff --git a/lib/src/utils/permissions.dart b/lib/src/utils/permissions.dart new file mode 100644 index 0000000..40d444a --- /dev/null +++ b/lib/src/utils/permissions.dart @@ -0,0 +1,79 @@ +import 'package:nyxx/nyxx.dart'; + +/// Compute the permissions for [member] in a given [channel]. +/// +/// {@template compute_permissions_detail} +/// This method returns the permissions for [member] according to the +/// permissions granted to them by their roles at a guild level as well as +/// the permission overwrites for [channel]. +/// +/// Adapted from https://discord.com/developers/docs/topics/permissions#permission-overwrites +/// {@endtemplate} +Future computePermissions( + GuildChannel channel, + Member member, +) async { + final guild = await channel.guild.get(); + + Future computeBasePermissions() async { + if (guild.ownerId == member.id) { + return Permissions.allPermissions; + } + + final everyoneRole = await guild.roles[guild.id].get(); + Flags permissions = everyoneRole.permissions; + + for (final role in member.roles) { + final rolePermissions = (await role.get()).permissions; + + permissions |= rolePermissions; + } + + permissions = Permissions(permissions.value); + permissions as Permissions; + + if (permissions.isAdministrator) { + return Permissions.allPermissions; + } + + return permissions; + } + + Future computeOverwrites(Permissions basePermissions) async { + if (basePermissions.isAdministrator) { + return Permissions.allPermissions; + } + + Flags permissions = basePermissions; + + final everyoneOverwrite = channel.permissionOverwrites.where((overwrite) => overwrite.id == guild.id).singleOrNull; + if (everyoneOverwrite != null) { + permissions &= ~everyoneOverwrite.deny; + permissions |= everyoneOverwrite.allow; + } + + Flags allow = Permissions(0); + Flags deny = Permissions(0); + + for (final roleId in member.roleIds) { + final roleOverwrite = channel.permissionOverwrites.where((overwrite) => overwrite.id == roleId).singleOrNull; + if (roleOverwrite != null) { + allow |= roleOverwrite.allow; + deny |= roleOverwrite.deny; + } + } + + permissions &= ~deny; + permissions |= allow; + + final memberOverwrite = channel.permissionOverwrites.where((overwrite) => overwrite.id == member.id).singleOrNull; + if (memberOverwrite != null) { + permissions &= ~memberOverwrite.deny; + permissions |= memberOverwrite.allow; + } + + return Permissions(permissions.value); + } + + return computeOverwrites(await computeBasePermissions()); +} diff --git a/lib/src/sanitizer.dart b/lib/src/utils/sanitizer.dart similarity index 81% rename from lib/src/sanitizer.dart rename to lib/src/utils/sanitizer.dart index 899321c..ab7bb07 100644 --- a/lib/src/sanitizer.dart +++ b/lib/src/utils/sanitizer.dart @@ -1,14 +1,5 @@ import "package:nyxx/nyxx.dart"; - -extension> on SnowflakeEntity { - Future getOrNull() async { - try { - return await get(); - } on Exception { - return null; - } - } -} +import "package:nyxx_extensions/src/extensions/snowflake_entity.dart"; const _whitespaceCharacter = "‎"; @@ -27,6 +18,14 @@ final channelMentionRegex = RegExp(r"<#(\d+)>"); /// A pattern that matches guild emojis in a message. final guildEmojiRegex = RegExp(r"<(a?):(\w+):(\d+)>"); +const _baseCommandNamePattern = r"[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]+"; + +/// A pattern that matches slash commands in a message. +final commandMentionRegex = RegExp( + '<\\/(?(?:$_baseCommandNamePattern(?:\\s$_baseCommandNamePattern){0,2})):(\\d{17,19})>', + unicode: true, +); + /// A type of target [sanitizeContent] can operate on. enum SanitizerTarget { /// Sanitize user mentions that match [userMentionRegex]. @@ -43,6 +42,9 @@ enum SanitizerTarget { /// Sanitize guild emojis that match [guildEmojiRegex]. emojis, + + /// Sanitize slash commands mentions that match [commandMentionRegex]. + commands, } /// An action [sanitizeContent] can take on a target. @@ -86,9 +88,10 @@ Future sanitizeContent( SanitizerTarget.everyone => everyoneMentionRegex, SanitizerTarget.channels => channelMentionRegex, SanitizerTarget.emojis => guildEmojiRegex, + SanitizerTarget.commands => commandMentionRegex, }; - Future name(Match match, SanitizerTarget target) async => switch (target) { + Future name(RegExpMatch match, SanitizerTarget target) async => switch (target) { SanitizerTarget.everyone => match.group(1)!, SanitizerTarget.channels => switch (await client.channels[Snowflake.parse(match.group(1)!)].getOrNull()) { GuildChannel(:final name) || GroupDmChannel(:final name) => name, @@ -108,18 +111,23 @@ Future sanitizeContent( }, }, SanitizerTarget.emojis => match.group(2)!, + SanitizerTarget.commands => match.namedGroup('commandName')!, }; String prefix(SanitizerTarget target) => switch (target) { SanitizerTarget.users || SanitizerTarget.roles => '@', SanitizerTarget.everyone => '@$_whitespaceCharacter', SanitizerTarget.channels => '#', - SanitizerTarget.emojis => '', + SanitizerTarget.emojis => ':', + SanitizerTarget.commands => '/', }; - String suffix(SanitizerTarget target) => target == SanitizerTarget.emojis ? ':' : ''; + String suffix(SanitizerTarget target) => switch (target) { + SanitizerTarget.emojis => ':', + _ => '', + }; - Future resolve(Match match, SanitizerTarget target, SanitizerAction action) async => switch (action) { + Future resolve(RegExpMatch match, SanitizerTarget target, SanitizerAction action) async => switch (action) { SanitizerAction.ignore => match.group(0)!, SanitizerAction.remove => '', SanitizerAction.nameNoPrefix => await name(match, target), @@ -129,7 +137,8 @@ Future sanitizeContent( SanitizerTarget.roles => '<@&$_whitespaceCharacter${match.group(1)!}>', SanitizerTarget.everyone => '@$_whitespaceCharacter${match.group(1)!}', SanitizerTarget.channels => '<#$_whitespaceCharacter${match.group(1)!}>', - SanitizerTarget.emojis => '<$_whitespaceCharacter${match.group(1) ?? ''}:${match.group(2)}:${match.group(3)}>', + SanitizerTarget.emojis => '<$_whitespaceCharacter${match.group(1) ?? ''}\\:${match.group(2)}\\:${match.group(3)}>', + SanitizerTarget.commands => '', }, }; diff --git a/pubspec.yaml b/pubspec.yaml index 224f796..45e030d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_extensions -version: 4.1.0 +version: 4.2.0 description: Extensions to the Discord API provided in nyxx, including pagination support and message sanitization. repository: https://github.com/nyxx-discord/nyxx_extensions documentation: https://nyxx.l7ssha.xyz @@ -10,7 +10,7 @@ environment: dependencies: http: ^1.1.0 - nyxx: ^6.0.0 + nyxx: ^6.3.1 dev_dependencies: coverage: ^1.0.3 diff --git a/test/integration/endpoint_streaming_test.dart b/test/integration/endpoint_streaming_test.dart new file mode 100644 index 0000000..8b92456 --- /dev/null +++ b/test/integration/endpoint_streaming_test.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/nyxx_extensions.dart'; +import 'package:test/test.dart'; + +void main() { + final testToken = Platform.environment['TEST_TOKEN']!; + final testChannelId = Snowflake.parse(Platform.environment['TEST_TEXT_CHANNEL']!); + + group('streaming endpoint pagination', () { + late NyxxRest client; + late PartialTextChannel channel; + setUp(() async { + client = await Nyxx.connectRest(testToken); + channel = client.channels[testChannelId] as PartialTextChannel; + }); + tearDown(() => client.close()); + + test('returns items from endpoint', () { + final stream = channel.messages.stream().take(10); + expect(stream, emits(anything)); + }); + + test('respects before', () async { + final messages = await channel.messages.fetchMany(); + final middle = messages[messages.length ~/ 2].id; + // Force multiple pages to be fetched. + final streamedMessages = await channel.messages.stream(before: middle, pageSize: 10).take(50).toList(); + + expect(streamedMessages, isNotEmpty); + for (final message in streamedMessages) { + expect(message.id.isBefore(middle), isTrue); + } + }); + + test('respects after', () async { + final messages = await channel.messages.fetchMany(); + final middle = messages[messages.length ~/ 2].id; + // Force multiple pages to be fetched. + final streamedMessages = await channel.messages.stream(after: middle, pageSize: 10).take(50).toList(); + + expect(streamedMessages, isNotEmpty); + for (final message in streamedMessages) { + expect(message.id.isAfter(middle), isTrue); + } + }); + + test('returns items in order', () async { + final oldestFirstMessages = await channel.messages.stream(order: StreamOrder.oldestFirst).take(50).toList(); + for (int i = 0; i < oldestFirstMessages.length - 1; i++) { + expect(oldestFirstMessages[i].id.isBefore(oldestFirstMessages[i + 1].id), isTrue); + } + + final mostRecentFirstMessages = await channel.messages.stream(order: StreamOrder.mostRecentFirst).take(50).toList(); + for (int i = 0; i < mostRecentFirstMessages.length - 1; i++) { + expect(mostRecentFirstMessages[i].id.isAfter(mostRecentFirstMessages[i + 1].id), isTrue); + } + }); + }); +} diff --git a/test/integration/member_test.dart b/test/integration/member_test.dart index 7ba5111..14942f7 100644 --- a/test/integration/member_test.dart +++ b/test/integration/member_test.dart @@ -27,4 +27,15 @@ void main() { expect(mutualGuilds, isNotEmpty); }, ); + + test('avatar.get()', skip: testToken == null ? 'No token provided' : false, () async { + final client = await Nyxx.connectRest(testToken!); + + final self = await client.users.fetchCurrentUser(); + + expect( + self.avatar.get(size: 3072, format: CdnFormat.jpeg), + Uri.https('cdn.discordapp.com', 'avatars/${self.id}/${self.avatarHash}.${CdnFormat.jpeg.extension}', {'size': '3072'}), + ); + }); } diff --git a/test/unit/sanitizer_test.dart b/test/unit/sanitizer_test.dart index c56edb6..101f897 100644 --- a/test/unit/sanitizer_test.dart +++ b/test/unit/sanitizer_test.dart @@ -5,10 +5,11 @@ import 'package:test/test.dart'; const _whitespaceCharacter = "‎"; -final sampleContent = '<@1234> test <@!2345> test2 <@&3456> test3 <#4567> test4 test5 <:test_emoji:6789>'; -final removed = ' test test2 test3 test4 test5 '; +final sampleContent = + '<@1234> test <@!2345> test2 <@&3456> test3 <#4567> test4 test5 <:test_emoji:6789> test6 '; +final removed = ' test test2 test3 test4 test5 test6 '; final sanitized = - '<@${_whitespaceCharacter}1234> test <@${_whitespaceCharacter}2345> test2 <@&${_whitespaceCharacter}3456> test3 <#${_whitespaceCharacter}4567> test4 <${_whitespaceCharacter}a:test_emoji:5678> test5 <$_whitespaceCharacter:test_emoji:6789>'; + '<@${_whitespaceCharacter}1234> test <@${_whitespaceCharacter}2345> test2 <@&${_whitespaceCharacter}3456> test3 <#${_whitespaceCharacter}4567> test4 <${_whitespaceCharacter}a\\:test_emoji\\:5678> test5 <$_whitespaceCharacter\\:test_emoji\\:6789> test6 '; class MockNyxxRest with Mock implements NyxxRest {} diff --git a/test/unit/timestamp_style_test.dart b/test/unit/timestamp_style_test.dart index d84bd8d..989634f 100644 --- a/test/unit/timestamp_style_test.dart +++ b/test/unit/timestamp_style_test.dart @@ -6,37 +6,13 @@ final baseDate = Snowflake(846136758470443069).timestamp; void main() { group('Timestamp Test', () { - test( - 'None', - () => expect(baseDate.format(), - equals(''))); - test( - 'Short Time', - () => expect(baseDate.format(TimestampStyle.shortTime), - equals(''))); - test( - 'Long Time', - () => expect(baseDate.format(TimestampStyle.longTime), - equals(''))); - test( - 'Short Date', - () => expect(baseDate.format(TimestampStyle.shortDate), - equals(''))); - test( - 'Long Date', - () => expect(baseDate.format(TimestampStyle.longDate), - equals(''))); - test( - 'Short Date Time', - () => expect(baseDate.format(TimestampStyle.shortDateTime), - equals(''))); - test( - 'Long Date Time', - () => expect(baseDate.format(TimestampStyle.longDateTime), - equals(''))); - test( - 'Relative Time', - () => expect(baseDate.format(TimestampStyle.relativeTime), - equals(''))); + test('None', () => expect(baseDate.format(), equals(''))); + test('Short Time', () => expect(baseDate.format(TimestampStyle.shortTime), equals(''))); + test('Long Time', () => expect(baseDate.format(TimestampStyle.longTime), equals(''))); + test('Short Date', () => expect(baseDate.format(TimestampStyle.shortDate), equals(''))); + test('Long Date', () => expect(baseDate.format(TimestampStyle.longDate), equals(''))); + test('Short Date Time', () => expect(baseDate.format(TimestampStyle.shortDateTime), equals(''))); + test('Long Date Time', () => expect(baseDate.format(TimestampStyle.longDateTime), equals(''))); + test('Relative Time', () => expect(baseDate.format(TimestampStyle.relativeTime), equals(''))); }); }