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 => '$_whitespaceCharacter${match.namedGroup('commandName')}:${match.group(2)}>',
},
};
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 ${_whitespaceCharacter}test command:123456789123456789>';
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('')));
});
}