From 5906fb1ee97d6cd5aa0c188fba48c3ccff38490c Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Sun, 10 Sep 2023 12:57:11 -0700 Subject: [PATCH 1/9] Update to nyxx 6.0.0 (#10) * maintenance: Fixup docs deployment * Update to support nyxx 5.0.0 * Export guild_extension * Add changelog * Bump 3.2.0 * Clean up files * Update to nyxx 6.0.0 * Fix analysis errors --------- Co-authored-by: Szymon Uglis --- .github/workflows/deploy_docs.yml | 3 +- .github/workflows/unit_tests.yml | 9 +- .pubignore | 39 --- CHANGELOG.md | 5 + analysis_options.yaml | 5 +- example/example.dart | 42 +-- lib/attachment_extension.dart | 3 - lib/embed_builder_extension.dart | 3 - lib/emoji.dart | 11 - lib/member_extension.dart | 3 - lib/message_resolver.dart | 4 - lib/nyxx_extensions.dart | 10 +- lib/src/attachment_extension.dart | 18 -- lib/src/embed_builder_extension.dart | 55 ---- lib/src/emoji.dart | 102 ++++++ lib/src/emoji/emoji_definition.dart | 34 -- lib/src/emoji/emoji_utils.dart | 37 --- lib/src/{guild_extension.dart => guild.dart} | 2 +- lib/src/member_extension.dart | 21 -- .../message_resolver/message_resolver.dart | 296 ------------------ lib/src/message_resolver/regexes.dart | 17 - lib/src/sanitizer.dart | 153 +++++++++ lib/src/user.dart | 22 ++ lib/src/utils.dart | 40 --- lib/utils.dart | 3 - pubspec.yaml | 18 +- test/integration/emoji_test.dart | 10 + test/integration/member_test.dart | 30 ++ test/interaction/.gitkeep | 0 test/unit/sanitizer_test.dart | 47 +++ test/unit/unit.dart | 134 -------- 31 files changed, 411 insertions(+), 765 deletions(-) delete mode 100644 .pubignore delete mode 100644 lib/attachment_extension.dart delete mode 100644 lib/embed_builder_extension.dart delete mode 100644 lib/emoji.dart delete mode 100644 lib/member_extension.dart delete mode 100644 lib/message_resolver.dart delete mode 100644 lib/src/attachment_extension.dart delete mode 100644 lib/src/embed_builder_extension.dart create mode 100644 lib/src/emoji.dart delete mode 100644 lib/src/emoji/emoji_definition.dart delete mode 100644 lib/src/emoji/emoji_utils.dart rename lib/src/{guild_extension.dart => guild.dart} (86%) delete mode 100644 lib/src/member_extension.dart delete mode 100644 lib/src/message_resolver/message_resolver.dart delete mode 100644 lib/src/message_resolver/regexes.dart create mode 100644 lib/src/sanitizer.dart create mode 100644 lib/src/user.dart delete mode 100644 lib/src/utils.dart delete mode 100644 lib/utils.dart create mode 100644 test/integration/emoji_test.dart create mode 100644 test/integration/member_test.dart delete mode 100644 test/interaction/.gitkeep create mode 100644 test/unit/sanitizer_test.dart delete mode 100644 test/unit/unit.dart diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 388d635..9e98858 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -1,4 +1,3 @@ - name: deploy dev docs on: @@ -30,7 +29,7 @@ jobs: run: dart pub get - name: Generate docs - run: dartdoc + run: dart doc - name: Extract branch name shell: bash diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 36ffb00..ef3a8e0 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -9,8 +9,6 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest - env: - TEST_TOKEN: ${{ secrets.TEST_TOKEN }} steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 @@ -25,6 +23,7 @@ jobs: key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} restore-keys: | ${{ runner.os }}-pubspec- + - name: Install dependencies run: dart pub get @@ -34,8 +33,6 @@ jobs: format: name: Format runs-on: ubuntu-latest - env: - TEST_TOKEN: ${{ secrets.TEST_TOKEN }} steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 @@ -50,6 +47,7 @@ jobs: key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} restore-keys: | ${{ runner.os }}-pubspec- + - name: Install dependencies run: dart pub get @@ -62,6 +60,7 @@ jobs: runs-on: ubuntu-latest env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + TEST_GUILD: ${{ secrets.TEST_GUILD }} steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 @@ -81,4 +80,4 @@ jobs: run: dart pub get - name: Unit tests - run: dart run test --coverage="coverage" test/unit/** + run: dart run test --coverage="coverage" diff --git a/.pubignore b/.pubignore deleted file mode 100644 index 69ad02c..0000000 --- a/.pubignore +++ /dev/null @@ -1,39 +0,0 @@ -local/ -.atom/ -.vscode/ -index.html -docs/ -.buildlog -.packages -.project -.pub -**/build -**/packages -*.dart.js -*.part.js -*.js.deps -*.js.map -*.info.json -doc/api/ -pubspec.lock -*.iml -.idea -*~ -*# -.#* -.dart_tool/ -/README.html -/log.txt -/nyxx.wiki/ -/test/private.dart -/publish_docs.sh -/test/mirrors.dart -/private -private-*.dart -test-*.dart -[Rr]pc* -**/doc/api/** -**/coverage/** -coverage.json -lcov.info -.github/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8e407..d401895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 3.2.0 + +- Bump nyxx to `4.2.0` +- Correctly export `acronym` property on guild + ## 3.1.0 __15.11.2022__ diff --git a/analysis_options.yaml b/analysis_options.yaml index 53ec678..6cd73fb 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,12 +2,9 @@ include: package:lints/recommended.yaml linter: rules: - unrelated_type_equality_checks: false implementation_imports: false analyzer: - exclude: [build/**, example/**] language: strict-raw-types: true - strong-mode: - implicit-casts: false + strict-casts: false diff --git a/example/example.dart b/example/example.dart index 169e094..45eee97 100644 --- a/example/example.dart +++ b/example/example.dart @@ -1,28 +1,30 @@ +import "dart:io"; + import "package:nyxx/nyxx.dart"; -import "package:nyxx_extensions/emoji.dart" as emoji_extension; -import "package:nyxx_extensions/src/message_resolver/message_resolver.dart" as message_resolver_extension; +import "package:nyxx_extensions/nyxx_extensions.dart"; -// nyxx.extensions contains several different extensions -// that could simplify making and implementing bots. void main() async { - // Emoji extension would allow to download and fetch discord emoji definitions - // from resource shared by Emzi. - // Emoji utils can cache results to do not download json document each time - final allEmojis = emoji_extension.getAllEmojiDefinitions(); + final client = await Nyxx.connectGateway(Platform.environment['TOKEN']!, GatewayIntents.guildMessages | GatewayIntents.messageContent); + + // Get an emoji by its unicode character... + final heartEmoji = client.getTextEmoji('❤️'); - // Its also possible to filter the emojis based on predicate - final filteredEmojis = emoji_extension.filterEmojiDefinitions( - (emojiDefinition) => emojiDefinition.primaryName.startsWith("smile") - ); + // ...or list all available emojis + final allEmojis = await client.getTextEmojis(); + print('There are currently ${allEmojis.length} emojis!'); - // Needed for next extension - final bot = Nyxx("token", GatewayIntents.allUnprivileged); + // Get information about a text emoji! + final heartEmojiInformation = await heartEmoji.getDefinition(); + print('The primary name of ${heartEmojiInformation.surrogates} is ${heartEmojiInformation.primaryName}'); - // Message Resolver extension allows to transform raw string message content - // to format that user is seeing - final messageResolver = message_resolver_extension.MessageResolver(bot); + // 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( + content: 'Sanitized content: ${await sanitizeContent(event.message.content, channel: event.message.channel)}', + )); + } + }); - // resolve method will return message according to set handling settings in - // MessageResolver constructor. - final resolvedMessage = messageResolver.resolve("This is raw content. "); + // ...and more! } diff --git a/lib/attachment_extension.dart b/lib/attachment_extension.dart deleted file mode 100644 index aa9a078..0000000 --- a/lib/attachment_extension.dart +++ /dev/null @@ -1,3 +0,0 @@ -library attachment_extension; - -export "src/attachment_extension.dart"; diff --git a/lib/embed_builder_extension.dart b/lib/embed_builder_extension.dart deleted file mode 100644 index 9b01105..0000000 --- a/lib/embed_builder_extension.dart +++ /dev/null @@ -1,3 +0,0 @@ -library embed_builder_extension; - -export "src/embed_builder_extension.dart"; diff --git a/lib/emoji.dart b/lib/emoji.dart deleted file mode 100644 index 86865b7..0000000 --- a/lib/emoji.dart +++ /dev/null @@ -1,11 +0,0 @@ -library emoji; - -import "dart:convert"; - -import "package:nyxx/nyxx.dart"; -import "package:http/http.dart" as http; - -part "src/emoji/emoji_definition.dart"; -part "src/emoji/emoji_utils.dart"; - -typedef RawApiMap = Map; diff --git a/lib/member_extension.dart b/lib/member_extension.dart deleted file mode 100644 index 2f9233a..0000000 --- a/lib/member_extension.dart +++ /dev/null @@ -1,3 +0,0 @@ -library member_extension; - -export "package:nyxx_extensions/member_extension.dart"; diff --git a/lib/message_resolver.dart b/lib/message_resolver.dart deleted file mode 100644 index caf8993..0000000 --- a/lib/message_resolver.dart +++ /dev/null @@ -1,4 +0,0 @@ -library message_resolver; - -export "src/message_resolver/regexes.dart"; -export "src/message_resolver/message_resolver.dart"; diff --git a/lib/nyxx_extensions.dart b/lib/nyxx_extensions.dart index 8b4b62a..d8f3a6b 100644 --- a/lib/nyxx_extensions.dart +++ b/lib/nyxx_extensions.dart @@ -1,8 +1,6 @@ library nyxx_extensions; -export 'attachment_extension.dart'; -export 'embed_builder_extension.dart'; -export 'emoji.dart'; -export 'member_extension.dart'; -export 'message_resolver.dart'; -export 'utils.dart'; +export 'src/emoji.dart'; +export 'src/user.dart'; +export 'src/sanitizer.dart'; +export 'src/guild.dart'; diff --git a/lib/src/attachment_extension.dart b/lib/src/attachment_extension.dart deleted file mode 100644 index de6d071..0000000 --- a/lib/src/attachment_extension.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "dart:io" show File, FileMode; -import "dart:typed_data"; - -import "package:http/http.dart" as http; -import "package:nyxx/nyxx.dart" show IAttachment; - -/// Extensions for downloading attachment -extension DownloadAttachmentExtensions on IAttachment { - /// Downloads [Attachment] and saves to given [file]. - /// Returns modified file - Future downloadAsFile(File file) async { - final dataBytes = await downloadAsBytes(); - return file.writeAsBytes(dataBytes, mode: FileMode.writeOnly); - } - - /// Downloads attachment as returns bytes of downloaded file. - Future downloadAsBytes() async => (await http.get(Uri.parse(url))).bodyBytes; -} diff --git a/lib/src/embed_builder_extension.dart b/lib/src/embed_builder_extension.dart deleted file mode 100644 index 9325614..0000000 --- a/lib/src/embed_builder_extension.dart +++ /dev/null @@ -1,55 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -/// Collection of extensions for [EmbedFieldBuilder] -extension EmbedFieldBuilderJson on EmbedFieldBuilder { - /// Returns a [EmbedFieldBuilder] with data from the raw json - EmbedFieldBuilder importJson(RawApiMap raw) { - name = raw["name"]; - content = raw["value"]; - inline = raw["inline"] as bool?; - return this; - } -} - -/// Collection of extensions for [EmbedFooterBuilder] -extension EmbedFooterBuilderJson on EmbedFooterBuilder { - /// Returns a [EmbedFooterBuilder] with data from the raw json - EmbedFooterBuilder importJson(Map raw) { - text = raw["text"]; - iconUrl = raw["icon_url"]; - return this; - } -} - -/// Collection of extensions for [EmbedAuthorBuilder] -extension EmbedAuthorBuilderJson on EmbedAuthorBuilder { - /// Returns a [EmbedAuthorBuilder] with data from the raw json - EmbedAuthorBuilder importJson(Map raw) { - name = raw["name"]; - url = raw["url"]; - iconUrl = raw["icon_url"]; - return this; - } -} - -/// Collection of extensions for [EmbedBuilder] -extension EmbedBuilderJson on EmbedBuilder { - /// Returns a [EmbedBuilder] with data from the raw json - EmbedBuilder importJson(RawApiMap raw) { - title = raw["title"] as String?; - description = raw["description"] as String?; - url = raw["url"] as String?; - color = raw["color"] != null ? DiscordColor.fromInt(raw["color"] as int) : null; - timestamp = raw["timestamp"] != null ? DateTime.parse(raw["timestamp"] as String) : null; - footer = raw["footer"] != null ? EmbedFooterBuilder().importJson(raw["footer"] as Map) : null; - imageUrl = raw["image"]["url"] as String?; - thumbnailUrl = raw["thumbnail"]["url"] as String?; - author = raw["author"] != null ? EmbedAuthorBuilder().importJson(raw["author"] as Map) : null; - - for (final rawFields in raw["fields"] as List) { - fields.add(EmbedFieldBuilder().importJson(rawFields as RawApiMap)); - } - - return this; - } -} diff --git a/lib/src/emoji.dart b/lib/src/emoji.dart new file mode 100644 index 0000000..4fe3d63 --- /dev/null +++ b/lib/src/emoji.dart @@ -0,0 +1,102 @@ +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'; + +/// {@template emoji_definition} +/// Information about a text emoji. +/// {@endtemplate} +class EmojiDefinition with ToStringHelper { + /// The primary name of this emoji. + final String primaryName; + + /// A list of all the names of this emoji. + final List names; + + /// The surrogates (string) that make up this emoji. + final String surrogates; + + /// The UTF-32 codepoints that make up this emoji. + final List utf32Codepoints; + + /// The filename of the asset containing this emoji's image. + final String assetFilename; + + /// The URI to this emoji's asset image. + final Uri assetUrl; + + /// The category this emoji belongs to. + final String category; + + /// An alternate representation of this emoji. + final String? alternateSurrogates; + + /// Alternate UTF-32 codepoints for this emoji. + final List? alternateUtf32Codepoints; + + /// {@macro emoji_definition} + EmojiDefinition({ + required this.primaryName, + required this.names, + required this.surrogates, + required this.utf32Codepoints, + required this.assetFilename, + required this.assetUrl, + required this.category, + required this.alternateSurrogates, + required this.alternateUtf32Codepoints, + }); +} + +/// 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; + +/// List all the emoji definitions currently available. +/// +/// This method caches results for 4 hours. +Future> getEmojiDefinitions() async { + if (_cachedEmojiDefinitions != null && _cachedAt!.add(Duration(hours: 4)).isAfter(DateTime.timestamp())) { + return _cachedEmojiDefinitions!; + } else { + final response = await http.get(_emojiDefinitionsUrl); + final data = jsonDecode(response.body)['emojiDefinitions']; + + _cachedAt = DateTime.timestamp(); + return _cachedEmojiDefinitions = [ + for (final raw in data) + EmojiDefinition( + primaryName: raw['primaryName'] as String, + names: parseMany(raw['names'] as List), + surrogates: raw['surrogates'] as String, + utf32Codepoints: parseMany(raw['utf32codepoints'] as List), + assetFilename: raw['assetFileName'] as String, + assetUrl: Uri.parse(raw['assetUrl']), + category: raw['category'] as String, + alternateSurrogates: raw['alternativeSurrogates'] as String?, + alternateUtf32Codepoints: maybeParseMany(raw['alternativeUtf32codepoints']), + ), + ]; + } +} diff --git a/lib/src/emoji/emoji_definition.dart b/lib/src/emoji/emoji_definition.dart deleted file mode 100644 index 1419cf1..0000000 --- a/lib/src/emoji/emoji_definition.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of emoji; - -/// Wrapper class around discords emojis data -class EmojiDefinition { - /// Name of emoji - late final String primaryName; - - /// List of alternative names of emoji - late final Iterable names; - - /// Literal emoji - late final String rawEmoji; - - /// List of utf32 code points - late final Iterable codePoints; - - /// Name of asset used in discords frontend for this emoji - late final String assetFileName; - - /// Url of emoji picture - late final String assetUrl; - - EmojiDefinition._new(RawApiMap raw) { - primaryName = raw["primaryName"] as String; - names = (raw["names"] as Iterable).cast(); - rawEmoji = raw["surrogates"] as String; - codePoints = (raw["utf32codepoints"] as Iterable).cast(); - assetFileName = raw["assetFileName"] as String; - assetUrl = raw["assetUrl"] as String; - } - - /// Returns [UnicodeEmoji] object of this - UnicodeEmoji toEmoji() => UnicodeEmoji(rawEmoji); -} diff --git a/lib/src/emoji/emoji_utils.dart b/lib/src/emoji/emoji_utils.dart deleted file mode 100644 index 626c861..0000000 --- a/lib/src/emoji/emoji_utils.dart +++ /dev/null @@ -1,37 +0,0 @@ -part of emoji; - -List _emojisCache = []; - -Future _downloadEmojiData() async { - final request = http.Request("GET", emojiDataUri); - final requestBody = await (await request.send()).stream.bytesToString(); - - return jsonDecode(requestBody) as RawApiMap; -} - -/// Emoji definitions uri -final Uri emojiDataUri = Uri.parse("https://emzi0767.gl-pages.emzi0767.dev/discord-emoji/discordEmojiMap.min.json"); - -/// Returns emoji based on given [predicate]. Allows to cache results via [cache] parameter. -Stream filterEmojiDefinitions(bool Function(EmojiDefinition) predicate, {bool cache = false}) => - getAllEmojiDefinitions(cache: cache).where(predicate); - -/// Returns all possible [EmojiDefinition]s. Allows to cache results via [cache] parameter. -/// If emojis are cached it will resolve immediately with result. -Stream getAllEmojiDefinitions({bool cache = false}) async* { - if (_emojisCache.isNotEmpty) { - yield* Stream.fromIterable(_emojisCache); - } - - final rawData = await _downloadEmojiData(); - - for (final emojiDefinition in rawData["emojiDefinitions"]) { - final definition = EmojiDefinition._new(emojiDefinition as RawApiMap); - - if (cache) { - _emojisCache.add(definition); - } - - yield definition; - } -} diff --git a/lib/src/guild_extension.dart b/lib/src/guild.dart similarity index 86% rename from lib/src/guild_extension.dart rename to lib/src/guild.dart index f31a99f..893d62a 100644 --- a/lib/src/guild_extension.dart +++ b/lib/src/guild.dart @@ -1,6 +1,6 @@ import 'package:nyxx/nyxx.dart'; -extension GuildExtension on IGuild { +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'), ''); diff --git a/lib/src/member_extension.dart b/lib/src/member_extension.dart deleted file mode 100644 index b0914c8..0000000 --- a/lib/src/member_extension.dart +++ /dev/null @@ -1,21 +0,0 @@ -import "package:nyxx/nyxx.dart"; - -/// Collection of extensions for [Member] -extension MemberExtension on IUser { - /// Fetches all mutual guilds for [User] that bot has access to. - /// Note it will try to download users via REST api so bot could get rate limited. - Future> fetchMutualGuilds() async { - final result = {}; - - for (final guild in client.guilds.values) { - try { - final member = guild.members[id] ?? await guild.fetchMember(id); - - result[guild] = member; - // ignore: empty_catches - } on Exception {} - } - - return result; - } -} diff --git a/lib/src/message_resolver/message_resolver.dart b/lib/src/message_resolver/message_resolver.dart deleted file mode 100644 index b4a70d9..0000000 --- a/lib/src/message_resolver/message_resolver.dart +++ /dev/null @@ -1,296 +0,0 @@ -import "dart:async"; - -import "package:nyxx/nyxx.dart" show IMessage, INyxx, ITextGuildChannel, Snowflake; - -import "regexes.dart" show Regexes; - -/// Possible types of tag handling for [MessageResolver] -enum TagHandling { - /// Ignores tag handling completely - leaves content as is. - ignore, - - /// Removes tag completely. - remove, - - /// Returns name of tag, eg. `<@932489234> -> @l7ssha` - name, - - /// Returns name of tag without mention prefix, eg. `<@932489234> -> l7ssha` - nameNoPrefix, - - /// Returns name of the tag with full possible data, eg. `<@932489234> -> @l7ssha#6712` - fullName, - - /// Returns name of the tag with full possible data without mention prefix, eg. `<@932489234> -> l7ssha#6712` - fullNameNoPrefix, - - /// Sanitizes tag to form that client wont treat it as valid tag - sanitize -} - -/// Extends [Message] class with [MessageResolver] -extension MessageResolverExtension on IMessage { - /// Resolves raw message content to human readable string. - /// Allows to set what to do with particular parts of message. - /// Each mention, channel reference and emoji can be resolved by [TagHandling] - FutureOr resolveContent( - {TagHandling userTagHandling = TagHandling.sanitize, - TagHandling roleTagHandling = TagHandling.sanitize, - TagHandling everyoneTagHandling = TagHandling.sanitize, - TagHandling channelTagHandling = TagHandling.sanitize, - TagHandling emojiTagHandling = TagHandling.sanitize}) { - if (content.isEmpty) { - return ""; - } - - return MessageResolver(client, - userTagHandling: userTagHandling, - roleTagHandling: roleTagHandling, - everyoneTagHandling: everyoneTagHandling, - channelTagHandling: channelTagHandling, - emojiTagHandling: everyoneTagHandling) - .resolve(content); - } -} - -/// Allows to return custom messages in case of missing entities when resolving message content. -/// [entityType] could be either `role`, `channel` or `user`. -typedef MissingEntityHandler = FutureOr Function(String entityType); - -/// Resolves raw message content to human readable string. -/// Allows to set what to do with particular parts of message. -/// Each mention, channel reference and emoji can be resolved by [TagHandling] -class MessageResolver { - final INyxx _client; - static const String _whiteSpaceCharacter = "‎"; - - /// Handles resolving of user mentions - final TagHandling userTagHandling; - - /// Handles resolving of role mentions - final TagHandling roleTagHandling; - - /// Handles resolving of everyone/here mentions - final TagHandling everyoneTagHandling; - - /// Handles resolving of channels mentions - final TagHandling channelTagHandling; - - /// Handles resolving of guild emojis - final TagHandling emojiTagHandling; - - /// Handles what will be returned in case if entity cannot be resolved - late final MissingEntityHandler missingEntityHandler; - - /// Create message resolver with given options - MessageResolver(this._client, - {this.userTagHandling = TagHandling.sanitize, - this.roleTagHandling = TagHandling.sanitize, - this.everyoneTagHandling = TagHandling.sanitize, - this.channelTagHandling = TagHandling.sanitize, - this.emojiTagHandling = TagHandling.sanitize, - MissingEntityHandler? missingEntityHandler}) { - if (missingEntityHandler == null) { - this.missingEntityHandler = _defaultMissingEntityHandler; - } else { - this.missingEntityHandler = missingEntityHandler; - } - } - - /// Create message resolver with tag handlers set to [tagHandling]. - factory MessageResolver.uniform(INyxx client, TagHandling tagHandling) => MessageResolver(client, - userTagHandling: tagHandling, - roleTagHandling: tagHandling, - everyoneTagHandling: tagHandling, - channelTagHandling: tagHandling, - emojiTagHandling: tagHandling); - - /// Resolves raw [messageContent] into human readable form. - Future resolve(String messageContent) async { - if (messageContent.trim().isEmpty) { - return ""; - } - - final messageParts = messageContent.split(" "); - final outputBuffer = StringBuffer(); - - for (final part in messageParts) { - outputBuffer.write(" "); - - final userMatch = Regexes.userMentionRegex.firstMatch(part); - if (userMatch != null) { - outputBuffer.write(await _resoleUserMention(userMatch, part)); - continue; - } - - final roleMatch = Regexes.roleMentionRegex.firstMatch(part); - if (roleMatch != null) { - outputBuffer.write(await _resolveRoleMention(roleMatch, part)); - continue; - } - - final everyoneMatch = Regexes.everyoneMentionRegex.firstMatch(part); - if (everyoneMatch != null) { - outputBuffer.write(await _resolveEveryone(everyoneMatch, part)); - continue; - } - - final channelMatch = Regexes.channelMentionRegex.firstMatch(part); - if (channelMatch != null) { - outputBuffer.write(await _resolveChannel(channelMatch, part)); - continue; - } - - final emojiMatch = Regexes.emojiMentionRegex.firstMatch(part); - if (emojiMatch != null) { - outputBuffer.write(await _resolveEmoji(emojiMatch, part)); - continue; - } - - outputBuffer.write(part); - } - - return outputBuffer.toString().trim(); - } - - FutureOr _resolveEmoji(RegExpMatch match, String content) async { - if (emojiTagHandling == TagHandling.ignore) { - return content; - } - - if (emojiTagHandling == TagHandling.remove) { - return ""; - } - - if (emojiTagHandling == TagHandling.sanitize) { - // TODO: check how to escape emoji properly - return "<${match.group(1)}:${match.group(2)}$_whiteSpaceCharacter:$_whiteSpaceCharacter${match.group(3)}>"; - } - - if (emojiTagHandling == TagHandling.name || emojiTagHandling == TagHandling.fullName) { - return ":${match.group(2)}:"; - } - - if (emojiTagHandling == TagHandling.nameNoPrefix || emojiTagHandling == TagHandling.fullNameNoPrefix) { - return match.group(2).toString(); - } - - return content; - } - - FutureOr _resolveChannel(RegExpMatch match, String content) async { - if (channelTagHandling == TagHandling.ignore) { - return content; - } - - if (channelTagHandling == TagHandling.remove) { - return ""; - } - - if (channelTagHandling == TagHandling.sanitize) { - return "<#$_whiteSpaceCharacter${match.group(1)}>"; - } - - final channel = _client.channels.values.firstWhere((ch) => ch is ITextGuildChannel && ch.id == match.group(1)) as ITextGuildChannel?; - - if (channelTagHandling == TagHandling.name || channelTagHandling == TagHandling.fullName) { - return channel != null ? "#${channel.name}" : missingEntityHandler("channel"); - } - - if (channelTagHandling == TagHandling.nameNoPrefix || channelTagHandling == TagHandling.fullNameNoPrefix) { - return channel != null ? channel.name : missingEntityHandler("channel"); - } - - return content; - } - - FutureOr _resolveEveryone(RegExpMatch match, String content) async { - if (roleTagHandling == TagHandling.remove) { - return ""; - } - - if (roleTagHandling == TagHandling.sanitize) { - return "@$_whiteSpaceCharacter${match.group(1)}"; - } - - return content; - } - - FutureOr _resolveRoleMention(RegExpMatch match, String content) async { - if (roleTagHandling == TagHandling.ignore) { - return content; - } - - if (roleTagHandling == TagHandling.remove) { - return ""; - } - - if (roleTagHandling == TagHandling.sanitize) { - return "<@&$_whiteSpaceCharacter${match.group(1)}>"; - } - - try { - final role = - _client.guilds.values.map((e) => e.roles.values).expand((element) => element).firstWhere((element) => element.id == Snowflake(match.group(1))); - - if (roleTagHandling == TagHandling.name || roleTagHandling == TagHandling.fullName) { - return "@${role.name}"; - } - - if (roleTagHandling == TagHandling.nameNoPrefix || roleTagHandling == TagHandling.fullNameNoPrefix) { - return role.name; - } - } on Exception { - return missingEntityHandler("role"); - } - - return content; - } - - FutureOr _resoleUserMention(RegExpMatch match, String content) async { - if (userTagHandling == TagHandling.ignore) { - return content; - } - - if (userTagHandling == TagHandling.remove) { - return ""; - } - - if (userTagHandling == TagHandling.sanitize) { - return "<@$_whiteSpaceCharacter${match.group(1)}>"; - } - - final user = _client.users[Snowflake(match.group(1))]; - - if (userTagHandling == TagHandling.name) { - return user != null ? "@${user.username}" : missingEntityHandler("user"); - } - - if (userTagHandling == TagHandling.nameNoPrefix) { - return user != null ? user.username : missingEntityHandler("user"); - } - - if (userTagHandling == TagHandling.fullName) { - return user != null ? "@${user.tag}" : missingEntityHandler("user"); - } - - if (userTagHandling == TagHandling.fullNameNoPrefix) { - return user != null ? user.tag : missingEntityHandler("user"); - } - - return ""; - } -} - -FutureOr _defaultMissingEntityHandler(String entityType) { - switch (entityType) { - case "user": - return "[Could not get user]"; - case "role": - return "[Could not get role]"; - case "channel": - return "[Could not get channel]"; - default: - return ""; - } -} diff --git a/lib/src/message_resolver/regexes.dart b/lib/src/message_resolver/regexes.dart deleted file mode 100644 index e83549a..0000000 --- a/lib/src/message_resolver/regexes.dart +++ /dev/null @@ -1,17 +0,0 @@ -/// Collection of regexes for message entities -class Regexes { - /// Matches user mention - static final userMentionRegex = RegExp(r"<@!?(\d+)>"); - - /// Matches role mention - static final roleMentionRegex = RegExp(r"<@&(\d+)>"); - - /// Matches everyone/here mention - static final everyoneMentionRegex = RegExp("(@(?:(everyone|here)))"); - - /// Matches channel mention - static final channelMentionRegex = RegExp(r"<#(\d+)>"); - - /// Matches guild emoji - static final emojiMentionRegex = RegExp(r"<(a?):(\w+):(\d+)>"); -} diff --git a/lib/src/sanitizer.dart b/lib/src/sanitizer.dart new file mode 100644 index 0000000..899321c --- /dev/null +++ b/lib/src/sanitizer.dart @@ -0,0 +1,153 @@ +import "package:nyxx/nyxx.dart"; + +extension> on SnowflakeEntity { + Future getOrNull() async { + try { + return await get(); + } on Exception { + return null; + } + } +} + +const _whitespaceCharacter = "‎"; + +/// A pattern that matches user mentions in a message. +final userMentionRegex = RegExp(r"<@!?(\d+)>"); + +/// A pattern that matches role mentions in a message. +final roleMentionRegex = RegExp(r"<@&(\d+)>"); + +/// A pattern that matches `@everyone` and `@here` mentions in a message. +final everyoneMentionRegex = RegExp("@(everyone|here)"); + +/// A pattern that matches channel mentions in a message. +final channelMentionRegex = RegExp(r"<#(\d+)>"); + +/// A pattern that matches guild emojis in a message. +final guildEmojiRegex = RegExp(r"<(a?):(\w+):(\d+)>"); + +/// A type of target [sanitizeContent] can operate on. +enum SanitizerTarget { + /// Sanitize user mentions that match [userMentionRegex]. + users, + + /// Sanitize role mentions that match [roleMentionRegex]. + roles, + + /// Sanitize `@everyone` and `@here` mentions that match [everyoneMentionRegex]. + everyone, + + /// Sanitize channel mentions that match [channelMentionRegex]. + channels, + + /// Sanitize guild emojis that match [guildEmojiRegex]. + emojis, +} + +/// An action [sanitizeContent] can take on a target. +enum SanitizerAction { + /// Leave the target as-is. + ignore, + + /// Remove the target completely. + remove, + + /// Replace the target with its name and a prefix/suffix to indicate the target type. + name, + + /// Replace the target with its name. + nameNoPrefix, + + /// Insert an invisible character into the target to prevent Discord from parsing it. + sanitize, +} + +/// Find [SanitizerTarget]s in [content] and sanitize them according to [action]. +/// +/// [channel] must be provided as the channel to which the sanitized content will be sent. It is used to resolve user, role and channel IDs to their names. +/// +/// [actionOverrides] can be provided to change the action taken for each [SanitizerTarget] type. +Future sanitizeContent( + String content, { + required PartialTextChannel channel, + SanitizerAction action = SanitizerAction.sanitize, + Map? actionOverrides, +}) async { + final client = channel.manager.client; + final guild = await switch (await channel.get()) { + GuildChannel(:final guild) => guild.get(), + _ => null, + }; + + RegExp patternForTarget(SanitizerTarget target) => switch (target) { + SanitizerTarget.users => userMentionRegex, + SanitizerTarget.roles => roleMentionRegex, + SanitizerTarget.everyone => everyoneMentionRegex, + SanitizerTarget.channels => channelMentionRegex, + SanitizerTarget.emojis => guildEmojiRegex, + }; + + Future name(Match 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, + DmChannel(:final recipient) => recipient.globalName ?? recipient.username, + _ => 'Unknown Channel', + }, + SanitizerTarget.roles => switch (await guild?.roles[Snowflake.parse(match.group(1)!)].getOrNull()) { + Role(:final name) => name, + _ => 'Unknown Role', + }, + SanitizerTarget.users => switch (await guild?.members[Snowflake.parse(match.group(1)!)].getOrNull()) { + Member(:final nick?) => nick, + Member(:final user?) => user.globalName ?? user.username, + _ => switch (await client.users[Snowflake.parse(match.group(1)!)].getOrNull()) { + User(:final username, :final globalName) => globalName ?? username, + _ => 'Unknown User', + }, + }, + SanitizerTarget.emojis => match.group(2)!, + }; + + String prefix(SanitizerTarget target) => switch (target) { + SanitizerTarget.users || SanitizerTarget.roles => '@', + SanitizerTarget.everyone => '@$_whitespaceCharacter', + SanitizerTarget.channels => '#', + SanitizerTarget.emojis => '', + }; + + String suffix(SanitizerTarget target) => target == SanitizerTarget.emojis ? ':' : ''; + + Future resolve(Match match, SanitizerTarget target, SanitizerAction action) async => switch (action) { + SanitizerAction.ignore => match.group(0)!, + SanitizerAction.remove => '', + SanitizerAction.nameNoPrefix => await name(match, target), + SanitizerAction.name => '${prefix(target)}${await name(match, target)}${suffix(target)}', + SanitizerAction.sanitize => switch (target) { + SanitizerTarget.users => '<@$_whitespaceCharacter${match.group(1)!}>', + 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)}>', + }, + }; + + String result = content; + for (final target in SanitizerTarget.values) { + final targetAction = actionOverrides?[target] ?? action; + final pattern = patternForTarget(target); + + int shift = 0; + + for (final match in pattern.allMatches(result)) { + final sanitized = await resolve(match, target, targetAction); + + result = result.replaceRange(match.start - shift, match.end - shift, sanitized); + + shift += (match.end - match.start) - sanitized.length; + } + } + + return result; +} diff --git a/lib/src/user.dart b/lib/src/user.dart new file mode 100644 index 0000000..181dd29 --- /dev/null +++ b/lib/src/user.dart @@ -0,0 +1,22 @@ +import "package:nyxx/nyxx.dart"; + +extension UserExtension 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. + /// + /// This method only operates on guilds in the client's cache. + Future> fetchMutualGuilds() async { + final result = {}; + + for (final guild in List.of(manager.client.guilds.cache.values)) { + try { + result[guild] = await guild.members[id].get(); + } on HttpResponseError { + // Member was not found in the guild + } + } + + return result; + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart deleted file mode 100644 index 4f4b643..0000000 --- a/lib/src/utils.dart +++ /dev/null @@ -1,40 +0,0 @@ -import "dart:async"; - -// ignore: public_member_api_docs -class StreamUtils { - /// Merges list of stream into one stream - static Stream merge(List> streams) { - var _open = streams.length; - final streamController = StreamController(); - for (final stream in streams) { - stream.listen(streamController.add) - ..onError(streamController.addError) - ..onDone(() { - if (--_open == 0) { - streamController.close(); - } - }); - } - return streamController.stream; - } -} - -// ignore: public_member_api_docs -class StringUtils { - /// Splits string based on desired length - static Iterable split(String str, int length) sync* { - var last = 0; - while (last < str.length && ((last + length) < str.length)) { - yield str.substring(last, last + length); - last += length; - } - yield str.substring(last, str.length); - } - - /// Splits string based on number of wanted substrings - static Iterable splitEqually(String str, int pieces) { - final len = (str.length / pieces).round(); - - return split(str, len); - } -} diff --git a/lib/utils.dart b/lib/utils.dart deleted file mode 100644 index eb53185..0000000 --- a/lib/utils.dart +++ /dev/null @@ -1,3 +0,0 @@ -library utils; - -export "src/utils.dart"; diff --git a/pubspec.yaml b/pubspec.yaml index 71e3cf0..b90c56e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,19 +1,19 @@ name: nyxx_extensions -version: 3.1.0 -description: Nyxx Extensions Module. Discord library for Dart. Simple, robust framework for creating discord bots for Dart language. -homepage: https://github.com/nyxx-discord/nyxx -repository: https://github.com/nyxx-discord/nyxx +version: 4.0.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 -issue_tracker: https://github.com/nyxx-discord/nyxx/issues +issue_tracker: https://github.com/nyxx-discord/nyxx_extensions/issues environment: - sdk: '>=2.13.0 <3.0.0' + sdk: ^3.0.0 dependencies: - http: ^0.13.3 - nyxx: ^4.2.0 + http: ^1.1.0 + nyxx: ^6.0.0-dev.2 dev_dependencies: coverage: ^1.0.3 - lints: ^1.0.1 + lints: ^2.0.1 + mockito: ^5.4.2 test: ^1.17.0 diff --git a/test/integration/emoji_test.dart b/test/integration/emoji_test.dart new file mode 100644 index 0000000..18af2c9 --- /dev/null +++ b/test/integration/emoji_test.dart @@ -0,0 +1,10 @@ +import 'package:nyxx_extensions/nyxx_extensions.dart'; +import 'package:test/test.dart'; + +void main() { + test('getEmojiDefinitions', () async { + final emojis = await getEmojiDefinitions(); + + expect(emojis, isNotEmpty); + }); +} diff --git a/test/integration/member_test.dart b/test/integration/member_test.dart new file mode 100644 index 0000000..7ba5111 --- /dev/null +++ b/test/integration/member_test.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; +import 'package:test/test.dart'; +import 'package:nyxx_extensions/nyxx_extensions.dart'; + +void main() { + final testToken = Platform.environment['TEST_TOKEN']; + final testGuild = Platform.environment['TEST_GUILD']; + + test( + 'fetchMutualGuilds', + skip: testToken == null + ? 'No token provided' + : testGuild == null + ? 'No test guild provided' + : false, + () async { + final client = await Nyxx.connectRest(testToken!); + + // Populate guild in cache. + await client.guilds.fetch(Snowflake.parse(testGuild!)); + final self = await client.users.fetchCurrentUser(); + + final mutualGuilds = await self.fetchMutualGuilds(); + + expect(mutualGuilds, isNotEmpty); + }, + ); +} diff --git a/test/interaction/.gitkeep b/test/interaction/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/unit/sanitizer_test.dart b/test/unit/sanitizer_test.dart new file mode 100644 index 0000000..c56edb6 --- /dev/null +++ b/test/unit/sanitizer_test.dart @@ -0,0 +1,47 @@ +import 'package:mockito/mockito.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/nyxx_extensions.dart'; +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 sanitized = + '<@${_whitespaceCharacter}1234> test <@${_whitespaceCharacter}2345> test2 <@&${_whitespaceCharacter}3456> test3 <#${_whitespaceCharacter}4567> test4 <${_whitespaceCharacter}a:test_emoji:5678> test5 <$_whitespaceCharacter:test_emoji:6789>'; + +class MockNyxxRest with Mock implements NyxxRest {} + +class MockChannelManager with Mock implements ChannelManager { + @override + NyxxRest get client => MockNyxxRest(); +} + +class MockTextChannel with Mock implements TextChannel { + @override + ChannelManager get manager => MockChannelManager(); + + @override + Future get() async => this; +} + +void main() { + group('sanitizeContent', () { + final channel = MockTextChannel(); + + test( + 'ignore', + () async => expect(await sanitizeContent(sampleContent, channel: channel, action: SanitizerAction.ignore), equals(sampleContent)), + ); + + test( + 'remove', + () async => expect(await sanitizeContent(sampleContent, channel: channel, action: SanitizerAction.remove), equals(removed)), + ); + + test( + 'sanitize', + () async => expect(await sanitizeContent(sampleContent, channel: channel, action: SanitizerAction.sanitize), equals(sanitized)), + ); + }); +} diff --git a/test/unit/unit.dart b/test/unit/unit.dart deleted file mode 100644 index 0b1cdfd..0000000 --- a/test/unit/unit.dart +++ /dev/null @@ -1,134 +0,0 @@ -import "dart:async"; - -import "package:nyxx/nyxx.dart"; -import "package:nyxx_extensions/embed_builder_extension.dart"; -import "package:nyxx_extensions/emoji.dart"; -import "package:nyxx_extensions/src/utils.dart"; -import "package:test/expect.dart"; -import "package:test/scaffolding.dart"; - -void main() async { - test("emoji tests", () async { - final emojis = await getAllEmojiDefinitions(cache: true).toList(); - expect(emojis, isNotEmpty); - - final emojisFromCache = await getAllEmojiDefinitions().toList(); - expect(emojisFromCache, isNotEmpty); - - final emojiDefinition = emojisFromCache.first; - final unicodeEmoji = emojiDefinition.toEmoji(); - - expect(unicodeEmoji.code, equals(emojiDefinition.rawEmoji)); - - final filteredEmojis = filterEmojiDefinitions((definition) => definition.rawEmoji.isEmpty); - expect(await filteredEmojis.toList(), isEmpty); - }); - - group("Embed Builder from Json", () { - test("Embed Footer Builder test", () async { - final data = { - "text": "Footer Text", - "icon_url": "https://cdn.discordapp.com/avatars/281314080923320321/e7e716c1a1efb236f9ff0e29a54f1ba2.png?size=128", - }; - final footer = EmbedFooterBuilder().importJson(data); - - expect(footer.text, equals("Footer Text")); - expect(footer.iconUrl, equals("https://cdn.discordapp.com/avatars/281314080923320321/e7e716c1a1efb236f9ff0e29a54f1ba2.png?size=128")); - }); - - test("Embed Author Builder test", () async { - final data = { - "name": "HarryET", - "url": "https://discord.com", - "icon_url": "https://cdn.discordapp.com/avatars/281314080923320321/e7e716c1a1efb236f9ff0e29a54f1ba2.png?size=128", - }; - final author = EmbedAuthorBuilder().importJson(data); - - expect(author.name, equals("HarryET")); - expect(author.url, equals("https://discord.com")); - expect(author.iconUrl, equals("https://cdn.discordapp.com/avatars/281314080923320321/e7e716c1a1efb236f9ff0e29a54f1ba2.png?size=128")); - }); - - group("Embed Field Builder tests", () { - test("with inline", () async { - final data = {"name": "Example", "value": "This is an example text", "inline": true}; - final field = EmbedFieldBuilder().importJson(data); - - expect(field.name, equals("Example")); - expect(field.content, equals("This is an example text")); - expect(field.inline, equals(true)); - }); - - test("without inline", () async { - final data = { - "name": "Example", - "value": "This is an example text", - }; - final field = EmbedFieldBuilder().importJson(data); - - expect(field.name, equals("Example")); - expect(field.content, equals("This is an example text")); - expect(field.inline, isNull); - }); - - test("accept empty values", () async { - final data = {"name": "Example", "value": null, "inline": true}; - final field = EmbedFieldBuilder().importJson(data); - - expect(field.name, isNotEmpty); - expect(field.content, isNull); - }); - }); - - test("Create embed from json", () async { - final data = { - "title": "title ~~(did you know you can have markdown here too?)~~", - "description": "this supports [named links](https://discordapp.com) on top of the previously shown subset of markdown. ```\nyes, even code blocks```", - "url": "https://discordapp.com", - "color": 14515245, - "timestamp": "2021-06-05T10:02:06.400Z", - "footer": {"icon_url": "https://cdn.discordapp.com/embed/avatars/0.png", "text": "footer text"}, - "thumbnail": {"url": "https://cdn.discordapp.com/embed/avatars/0.png"}, - "image": {"url": "https://cdn.discordapp.com/embed/avatars/0.png"}, - "author": {"name": "author name", "url": "https://discordapp.com", "icon_url": "https://cdn.discordapp.com/embed/avatars/0.png"}, - "fields": [ - {"name": "🤔", "value": "some of these properties have certain limits...", "inline": false}, - {"name": "<:thonkang:219069250692841473>", "value": "are inline fields", "inline": true} - ] - }; - final embed = EmbedBuilder().importJson(data); - expect(embed.build(), equals(data)); - }); - }); - - group("Utils tests", () { - test("StreamUtils.merge test", () { - final streamController1 = StreamController.broadcast(); - final stream1 = streamController1.stream; - - final streamController2 = StreamController.broadcast(); - final stream2 = streamController2.stream; - - final combinedStream = StreamUtils.merge([stream1, stream2]); - - expect(combinedStream, emitsInOrder([1, 2, 3])); - - streamController1.add(1); - streamController2.add(2); - streamController2.add(3); - - streamController1.close(); - streamController2.close(); - }); - - test("StringUtils.split", () { - const str = "Five Five Five"; - expect(["Five ", "Five ", "Five"], StringUtils.split(str, 5)); - }); - - test("StringUtils.splitEqually", () { - const str = "Five"; - expect(["Fi", "ve"], StringUtils.splitEqually(str, 2)); - }); - }); -} From 3847d38b6543099771907930e38e9e1cfa8bdcab Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Mon, 11 Sep 2023 02:39:35 -0700 Subject: [PATCH 2/9] Correctly decode emoji data as UTF-8 (#11) * Correctly decode emoji data as UTF-8 * Add tests --- lib/src/emoji.dart | 2 +- test/integration/emoji_test.dart | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/emoji.dart b/lib/src/emoji.dart index 4fe3d63..9f21361 100644 --- a/lib/src/emoji.dart +++ b/lib/src/emoji.dart @@ -81,7 +81,7 @@ Future> getEmojiDefinitions() async { return _cachedEmojiDefinitions!; } else { final response = await http.get(_emojiDefinitionsUrl); - final data = jsonDecode(response.body)['emojiDefinitions']; + final data = jsonDecode(utf8.decode(response.bodyBytes))['emojiDefinitions']; _cachedAt = DateTime.timestamp(); return _cachedEmojiDefinitions = [ diff --git a/test/integration/emoji_test.dart b/test/integration/emoji_test.dart index 18af2c9..9d8ff2d 100644 --- a/test/integration/emoji_test.dart +++ b/test/integration/emoji_test.dart @@ -7,4 +7,10 @@ void main() { expect(emojis, isNotEmpty); }); + + test('getEmojiDefinitions correcly decodes emojis', () async { + final emoji = (await getEmojiDefinitions()).singleWhere((element) => element.primaryName == 'heart'); + + expect(emoji.surrogates, equals('❤️')); + }); } From c3243edb7343ed77e6cb4777e08675dac981c79f Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Mon, 11 Sep 2023 12:58:06 -0700 Subject: [PATCH 3/9] Add url getters to some types (#13) --- lib/nyxx_extensions.dart | 2 ++ lib/src/channel.dart | 6 ++++++ lib/src/guild.dart | 3 +++ lib/src/message.dart | 7 +++++++ 4 files changed, 18 insertions(+) create mode 100644 lib/src/channel.dart create mode 100644 lib/src/message.dart diff --git a/lib/nyxx_extensions.dart b/lib/nyxx_extensions.dart index d8f3a6b..6c63a21 100644 --- a/lib/nyxx_extensions.dart +++ b/lib/nyxx_extensions.dart @@ -4,3 +4,5 @@ export 'src/emoji.dart'; export 'src/user.dart'; export 'src/sanitizer.dart'; export 'src/guild.dart'; +export 'src/channel.dart'; +export 'src/message.dart'; diff --git a/lib/src/channel.dart b/lib/src/channel.dart new file mode 100644 index 0000000..534d04d --- /dev/null +++ b/lib/src/channel.dart @@ -0,0 +1,6 @@ +import 'package:nyxx/nyxx.dart'; + +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/guild.dart b/lib/src/guild.dart index 893d62a..e421c88 100644 --- a/lib/src/guild.dart +++ b/lib/src/guild.dart @@ -5,4 +5,7 @@ extension GuildExtension on Guild { 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, '/channels/$id'); } diff --git a/lib/src/message.dart b/lib/src/message.dart new file mode 100644 index 0000000..352469d --- /dev/null +++ b/lib/src/message.dart @@ -0,0 +1,7 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/src/channel.dart'; + +extension MessageUtils 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'); +} From e67cab596c25fb90d941aafe74bef9f7003539e8 Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Mon, 11 Sep 2023 13:25:16 -0700 Subject: [PATCH 4/9] Add pagination support (#12) --- example/example.dart | 43 ++- lib/nyxx_extensions.dart | 1 + lib/src/pagination.dart | 504 ++++++++++++++++++++++++++ test/integration/pagination_test.dart | 43 +++ 4 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 lib/src/pagination.dart create mode 100644 test/integration/pagination_test.dart diff --git a/example/example.dart b/example/example.dart index 45eee97..eb12212 100644 --- a/example/example.dart +++ b/example/example.dart @@ -4,7 +4,11 @@ import "package:nyxx/nyxx.dart"; import "package:nyxx_extensions/nyxx_extensions.dart"; void main() async { - final client = await Nyxx.connectGateway(Platform.environment['TOKEN']!, GatewayIntents.guildMessages | GatewayIntents.messageContent); + final client = await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.guildMessages | GatewayIntents.messageContent, + options: GatewayClientOptions(plugins: [logging, cliIntegration, pagination]), + ); // Get an emoji by its unicode character... final heartEmoji = client.getTextEmoji('❤️'); @@ -26,5 +30,42 @@ void main() async { } }); + // Pagination allows for long segments of text to be sent as one chunk. + const loreumIpsum = ''' +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Donec massa sapien faucibus et. Et +malesuada fames ac turpis egestas maecenas pharetra convallis. Vulputate eu +scelerisque felis imperdiet proin fermentum leo. Amet risus nullam eget felis +eget nunc lobortis mattis aliquam. Id leo in vitae turpis. Adipiscing elit +pellentesque habitant morbi tristique. Adipiscing commodo elit at imperdiet +dui. Ridiculus mus mauris vitae ultricies leo. Sapien pellentesque habitant +morbi tristique senectus. Mauris pellentesque pulvinar pellentesque habitant. +Mus mauris vitae ultricies leo integer malesuada. Sit amet est placerat in +egestas erat. Id leo in vitae turpis massa sed elementum tempus egestas. +Posuere sollicitudin aliquam ultrices sagittis orci a scelerisque purus. + +Sagittis orci a scelerisque purus semper eget. Nisl purus in mollis nunc. +Curabitur vitae nunc sed velit. At lectus urna duis convallis convallis tellus +id. Risus nec feugiat in fermentum posuere urna nec. At elementum eu facilisis +sed odio morbi quis. Est ante in nibh mauris. Dictumst quisque sagittis purus +sit amet volutpat consequat. Quis imperdiet massa tincidunt nunc pulvinar +sapien. Viverra tellus in hac habitasse platea dictumst vestibulum. Eu +consequat ac felis donec et. Mauris a diam maecenas sed enim ut sem. Placerat +in egestas erat imperdiet sed. Orci eu lobortis elementum nibh tellus molestie +nunc non blandit. Rutrum tellus pellentesque eu tincidunt tortor aliquam. +Imperdiet proin fermentum leo vel orci porta non. Ullamcorper velit sed +ullamcorper morbi tincidunt ornare. +'''; + + client.onMessageCreate.listen((event) async { + if (event.message.content.startsWith('!pages')) { + await event.message.channel.sendMessage(await pagination.split( + loreumIpsum, + // Split into chunks 100 characters long. + maxLength: 100, + )); + } + }); + // ...and more! } diff --git a/lib/nyxx_extensions.dart b/lib/nyxx_extensions.dart index 6c63a21..23dead7 100644 --- a/lib/nyxx_extensions.dart +++ b/lib/nyxx_extensions.dart @@ -4,5 +4,6 @@ export 'src/emoji.dart'; export 'src/user.dart'; export 'src/sanitizer.dart'; export 'src/guild.dart'; +export 'src/pagination.dart'; export 'src/channel.dart'; export 'src/message.dart'; diff --git a/lib/src/pagination.dart b/lib/src/pagination.dart new file mode 100644 index 0000000..4faf0ee --- /dev/null +++ b/lib/src/pagination.dart @@ -0,0 +1,504 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +/// {@template pagination_options} +/// Options for controlling pagination. +/// {@endtemplate} +class PaginationOptions { + /// Whether to show the jump to start and jump to end buttons. + /// + /// Defaults to `true`. + final bool? showJumpToEnds; + + /// Whether to show the page index in a button centered between the navigation buttons. + /// + /// Defaults to true. + final bool? showPageIndex; + + /// The button style to use for the page index button. + /// + /// Defaults to [ButtonStyle.secondary]. + final ButtonStyle? pageIndexStyle; + + /// The label to display on the jump to start button. + /// + /// Defaults to `<<`. + final String? jumpToStartLabel; + + /// The style to use for the jump to start button. + /// + /// Defaults to [ButtonStyle.secondary]. + final ButtonStyle? jumpToStartStyle; + + /// The emoji to show on the jump to start button. + final Emoji? jumpToStartEmoji; + + /// The label to show on the jump to end button. + /// + /// Defaults to `>>`. + final String? jumpToEndLabel; + + /// The style to use for the jump to end button. + /// + /// Defaults to [ButtonStyle.secondary]. + final ButtonStyle? jumpToEndStyle; + + /// The emoji to show on the jump to end button. + final Emoji? jumpToEndEmoji; + + /// The label to show on the previous button. + /// + /// Defaults to `<`. + final String? previousLabel; + + /// The style to use for the previous button. + /// + /// Defaults to [ButtonStyle.primary]. + final ButtonStyle? previousStyle; + + /// The emoji to show on the previous button. + final Emoji? previousEmoji; + + /// The label to show on the next button. + /// + /// Defaults to `>`. + final String? nextLabel; + + /// The style to use for the next button. + /// + /// Defaults to [ButtonStyle.primary]. + final ButtonStyle? nextStyle; + + /// The emoji to show on the next button. + final Emoji? nextEmoji; + + /// The time after which the pagination should be disabled. + final Duration? timeout; + + /// Whether to disable all active paginated messages when the client closes. + /// + /// This prevents paginated messages from being left "dangling" and resulting in [Pagination.onUnhandledInteraction]. + final bool? disableOnClientClose; + + /// {@macro pagination_options} + PaginationOptions({ + this.showJumpToEnds, + this.showPageIndex, + this.pageIndexStyle, + this.jumpToStartLabel, + this.jumpToStartStyle, + this.jumpToStartEmoji, + this.jumpToEndLabel, + this.jumpToEndStyle, + this.jumpToEndEmoji, + this.previousLabel, + this.previousStyle, + this.previousEmoji, + this.nextLabel, + this.nextStyle, + this.nextEmoji, + this.timeout, + this.disableOnClientClose, + }); +} + +/// A global instance of the [Pagination] plugin with default options. +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. +class Pagination extends NyxxPlugin { + @override + String get name => 'Pagination'; + + /// The default options to use for all paginated messages. + final PaginationOptions options; + + final Map _states = {}; + final Map> _clientStates = {}; + + /// A stream of interactions that were recognized as being created by a [Pagination] plugin different to this one. + /// + /// This is often a sign of leftover menus from a previous client session. + Stream> get onUnhandledInteraction => _unhandledInteractionsController.stream; + final StreamController> _unhandledInteractionsController = StreamController.broadcast(); + + /// A stream of interactions that were recognized by this plugin but were not handled because the wrong user triggered the interaction. + Stream> get onDisallowedUse => _disallowedUseController.stream; + final StreamController> _disallowedUseController = StreamController.broadcast(); + + /// Create a new [Pagination] instance. + Pagination(this.options); + + void _registerPagination(_PaginationState state) { + _states[state.jumpToStartId] = state; + _states[state.previousId] = state; + _states[state.nextId] = state; + _states[state.jumpToEndId] = state; + } + + void _unregisterPagination(_PaginationState state) { + _states.remove(state.jumpToStartId); + _states.remove(state.previousId); + _states.remove(state.nextId); + _states.remove(state.jumpToEndId); + + for (final clientStates in _clientStates.values) { + clientStates.remove(state); + } + } + + @override + Future connect(ApiOptions apiOptions, ClientOptions clientOptions, Future Function() connect) async { + final client = await connect(); + + if (client is! NyxxGateway) { + throw NyxxException('Pagination requires NyxxGateway'); + } + + client.onMessageComponentInteraction.listen((event) async { + final interaction = event.interaction; + final data = interaction.data; + + if (data.type != MessageComponentType.button) { + return; + } + + final state = _states[data.customId]; + + if (state == null) { + if (data.customId.startsWith('nyxx_pagination/')) { + _unhandledInteractionsController.add(event); + } + return; + } + + if (state.userId != null && (interaction.user?.id ?? interaction.member!.id) != state.userId) { + _disallowedUseController.add(event); + return; + } + + if (data.customId == state.jumpToStartId) { + state.currentIndex = 0; + } else if (data.customId == state.previousId) { + state.currentIndex--; + } else if (data.customId == state.nextId) { + state.currentIndex++; + } else if (data.customId == state.jumpToEndId) { + state.currentIndex = state.builders.length - 1; + } + + await interaction.respond( + updateMessage: true, + _PaginationMessageUpdateBuilder(await state.builderForIndex(state.currentIndex)), + ); + }); + + client.onMessageCreate.listen((event) { + final rows = (event.message.components?.cast()) ?? []; + final components = rows.expand((element) => element.components); + + for (final component in components.whereType()) { + final state = _states[component.customId]; + + if (state == null) continue; + + (_clientStates[event.gateway.client] ??= {}).add(state); + state.message = event.message; + + final timeout = state.options?.timeout ?? options.timeout; + if (timeout != null) { + state.disableTimer = Timer(timeout, () async { + state.isDisabled = true; + + await event.message.update(_PaginationMessageUpdateBuilder( + await state.builderForIndex(state.currentIndex), + )); + + _unregisterPagination(state); + }); + } + + break; + } + }); + + return client; + } + + @override + Future close(Nyxx client, Future Function() close) async { + final clientStates = _clientStates.remove(client); + if (clientStates != null) { + for (final state in clientStates) { + _unregisterPagination(state); + + state.disableTimer?.cancel(); + if (state.options?.disableOnClientClose ?? options.disableOnClientClose ?? true) { + state.isDisabled = true; + + await state.message?.update(_PaginationMessageUpdateBuilder( + await state.builderForIndex(state.currentIndex), + )); + } + } + } + + await close(); + } + + /// Create a [MessageBuilder] for a paginated message created by a list of builder factories. + /// + /// Each page in the paginated message is created by calling the associated function in [builders]. + /// + /// {@template pagination_parameters} + /// [options] can be specified to override some of the default options. + /// [startIndex] can be set to change the index of the page the pagination starts at. + /// [userId] can be set to restrict usage of the menu to a single user. Users other than this user trying to use the paginated message will emit an + /// interaction to [onDisallowedUse]. + /// {@endtemplate} + Future factories( + List Function()> builders, { + PaginationOptions? options, + int startIndex = 0, + Snowflake? userId, + }) async { + final state = _PaginationState(this, options, builders, startIndex, userId); + _registerPagination(state); + + return await state.builderForIndex(startIndex); + } + + /// Create a [MessageBuilder] for a paginated message created by a builder factory. + /// + /// Each page in the paginated message is created by calling [builder] and passing the index of the page. + /// [pages] controls the number of pages in the message. + /// + /// {@macro pagination_parameters} + Future generate( + FutureOr Function(int index) builder, { + required int pages, + PaginationOptions? options, + int startIndex = 0, + Snowflake? userId, + }) { + final builders = List.generate( + pages, + (index) => () => builder(index), + growable: false, + ); + + return factories(builders, options: options, startIndex: startIndex, userId: userId); + } + + /// Create a [MessageBuilder] for a paginated message created from a list of [MessageBuilder]s. + /// + /// {@macro pagination_parameters} + Future builders( + List builders, { + PaginationOptions? options, + int startIndex = 0, + Snowflake? userId, + }) { + return generate( + pages: builders.length, + (index) => builders[index], + options: options, + startIndex: startIndex, + userId: userId, + ); + } + + /// Create a [MessageBuilder] for a paginated message created by splitting a long [String] into parts. + /// + /// [splitAt] can be specified to control where the content can be split. The default is to split at whitespace characters. + /// [buildChunk] can be specified to configure how the message is created from a chunk of test. The default is to create a message containing the chunk's + /// content. + /// [maxLength] can be specified to set the maximum chunk length. + /// {@macro pagination_parameters} + /// + /// If the split text contains chunks larger than [maxLength], the large chunks will be split at arbitrary characters to fit the length limit. + Future split( + String content, { + Pattern? splitAt, + FutureOr Function(String content)? buildChunk, + int maxLength = 2000, + PaginationOptions? options, + Snowflake? userId, + }) { + buildChunk ??= (content) => MessageBuilder(content: content); + splitAt ??= RegExp(r'\s+'); + + final potentialSplices = splitAt.allMatches(content).map((match) => match.start); + + // 0 is the start of the very first chunk + final splices = [0]; + int currentSplice = 0; + int lastSplice = 0; + + for (final potentialSplice in [...potentialSplices, content.length]) { + while (potentialSplice - currentSplice > maxLength) { + // Chunk between [potentialSplice] and the previous possible splice is > [maxLength]. + // Cut the chunk forcefully down the middle. + final remaining = maxLength - (currentSplice - lastSplice); + currentSplice += remaining; + + splices.add(currentSplice); + lastSplice = currentSplice; + } + + if (potentialSplice - lastSplice > maxLength) { + // Choosing this splice would make the current chunk too long. Add the previous chunk and move on. + splices.add(currentSplice); + lastSplice = currentSplice; + currentSplice = potentialSplice; + } else { + // Just grow the current chunk. + currentSplice = potentialSplice; + } + } + + splices.add(currentSplice); + + return generate( + pages: splices.length - 1, + (index) => buildChunk!(content.substring(splices[index], splices[index + 1])), + options: options, + userId: userId, + ); + } + + /// Create a [MessageBuilder] for a paginated message created by splitting a long [String] into parts and placing the resulting text in [Embed]s. + /// + /// [splitAt] can be specified to control where the content can be split. The default is to split at whitespace characters. + /// [buildChunk] can be specified to configure how the message is created from a chunk of test. The default is to create an containing the chunk's + /// content as its description. + /// [maxLength] can be specified to set the maximum chunk length. + /// {@macro pagination_parameters} + /// + /// If the split text contains chunks larger than [maxLength], the large chunks will be split at arbitrary characters to fit the length limit. + Future splitEmbeds( + String content, { + Pattern? splitAt, + FutureOr Function(String content)? buildChunk, + int maxLength = 4096, + PaginationOptions? options, + Snowflake? userId, + }) { + buildChunk ??= (content) => EmbedBuilder(description: content); + + return split( + content, + splitAt: splitAt, + maxLength: maxLength, + buildChunk: (content) async => MessageBuilder(embeds: [await buildChunk!(content)]), + options: options, + userId: userId, + ); + } +} + +class _PaginationState { + final Pagination pagination; + final PaginationOptions? options; + final List Function()> builders; + + final String jumpToStartId = generateId(); + final String jumpToEndId = generateId(); + final String previousId = generateId(); + final String nextId = generateId(); + + static int idCounter = 0; + static String generateId() => 'nyxx_pagination/${DateTime.now().millisecondsSinceEpoch}/${idCounter++}'; + + final Snowflake? userId; + + Timer? disableTimer; + Message? message; + + int currentIndex; + bool isDisabled = false; + + _PaginationState(this.pagination, this.options, this.builders, this.currentIndex, this.userId); + + FutureOr builderForIndex(int index) { + final builder = builders[index](); + + if (builder is MessageBuilder) { + addControls(builder, index); + return builder; + } else { + return builder.then((builder) { + addControls(builder, index); + return builder; + }); + } + } + + void addControls(MessageBuilder builder, int index) { + if (builder.components case List(length: >= 5)) { + throw NyxxException('Cannot add pagination controls to builder: too many component rows'); + } + + final showJumpToEnds = options?.showJumpToEnds ?? pagination.options.showJumpToEnds ?? true; + final showPageIndex = options?.showPageIndex ?? pagination.options.showPageIndex ?? true; + + builder + ..components ??= [] + ..components!.add(ActionRowBuilder(components: [ + if (showJumpToEnds) + ButtonBuilder( + style: options?.jumpToStartStyle ?? pagination.options.jumpToStartStyle ?? ButtonStyle.secondary, + label: options?.jumpToStartLabel ?? pagination.options.jumpToStartLabel ?? '<<', + emoji: options?.jumpToStartEmoji ?? pagination.options.jumpToStartEmoji, + customId: jumpToStartId, + isDisabled: isDisabled || index == 0, + ), + ButtonBuilder( + style: options?.previousStyle ?? pagination.options.previousStyle ?? ButtonStyle.primary, + label: options?.previousLabel ?? pagination.options.previousLabel ?? '<', + emoji: options?.previousEmoji ?? pagination.options.previousEmoji, + customId: previousId, + isDisabled: isDisabled || index == 0, + ), + if (showPageIndex) + ButtonBuilder( + style: options?.pageIndexStyle ?? pagination.options.pageIndexStyle ?? ButtonStyle.secondary, + label: '${index + 1}/${builders.length}', + customId: 'nyxx_pagination/page_index_display', + isDisabled: true, + ), + ButtonBuilder( + style: options?.nextStyle ?? pagination.options.nextStyle ?? ButtonStyle.primary, + label: options?.nextLabel ?? pagination.options.nextLabel ?? '>', + emoji: options?.nextEmoji ?? pagination.options.nextEmoji, + customId: nextId, + isDisabled: isDisabled || index == builders.length - 1, + ), + if (showJumpToEnds) + ButtonBuilder( + style: options?.jumpToEndStyle ?? pagination.options.jumpToEndStyle ?? ButtonStyle.secondary, + label: options?.jumpToEndLabel ?? pagination.options.jumpToEndLabel ?? '>>', + emoji: options?.jumpToEndEmoji ?? pagination.options.jumpToEndEmoji, + customId: jumpToEndId, + isDisabled: isDisabled || index == builders.length - 1, + ), + ])); + } +} + +class _PaginationMessageUpdateBuilder extends MessageUpdateBuilder { + final MessageBuilder target; + + _PaginationMessageUpdateBuilder(this.target) + : super( + content: target.content, + embeds: target.embeds ?? [], + suppressEmbeds: target.suppressEmbeds == true, + attachments: target.attachments ?? [], + components: target.components ?? [], + allowedMentions: target.allowedMentions, + ); +} diff --git a/test/integration/pagination_test.dart b/test/integration/pagination_test.dart new file mode 100644 index 0000000..200f209 --- /dev/null +++ b/test/integration/pagination_test.dart @@ -0,0 +1,43 @@ +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 testTextChannel = Platform.environment['TEST_TEXT_CHANNEL']; + + test( + 'Pagination', + skip: testTextChannel == null ? 'No text channel provided' : false, + () async { + final pagination = Pagination(PaginationOptions()); + + final client = await Nyxx.connectGateway( + testToken, + GatewayIntents.guildMessages, + options: GatewayClientOptions(plugins: [pagination]), + ); + + final channel = client.channels[Snowflake.parse(testTextChannel!)] as PartialTextChannel; + + late Message message; + + await expectLater( + channel + .sendMessage(await pagination.builders([ + MessageBuilder(content: 'Test page 1'), + MessageBuilder(content: 'Test page 2'), + MessageBuilder(content: 'Test page 3'), + ])) + .then((value) => message = value), + completes, + ); + + await message.delete(); + + await client.close(); + }, + ); +} From 6aed7c0bbab1de8519d3cb6cad5e48d9a437cdbc Mon Sep 17 00:00:00 2001 From: Rapougnac Date: Tue, 12 Sep 2023 15:16:58 +0200 Subject: [PATCH 5/9] Add misc helpers on some structures (#14) --- lib/nyxx_extensions.dart | 12 ++-- lib/src/channel.dart | 6 ++ lib/src/date_time.dart | 12 ++++ lib/src/embed_builder.dart | 38 +++++++++++ lib/src/guild.dart | 2 +- lib/src/role.dart | 13 ++++ lib/src/user.dart | 7 ++ lib/src/utils/formatters.dart | 62 +++++++++++++++++ test/unit/formatters_test.dart | 101 ++++++++++++++++++++++++++++ test/unit/timestamp_style_test.dart | 42 ++++++++++++ 10 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 lib/src/date_time.dart create mode 100644 lib/src/embed_builder.dart create mode 100644 lib/src/role.dart create mode 100644 lib/src/utils/formatters.dart create mode 100644 test/unit/formatters_test.dart create mode 100644 test/unit/timestamp_style_test.dart diff --git a/lib/nyxx_extensions.dart b/lib/nyxx_extensions.dart index 23dead7..394f59e 100644 --- a/lib/nyxx_extensions.dart +++ b/lib/nyxx_extensions.dart @@ -1,9 +1,13 @@ library nyxx_extensions; +export 'src/channel.dart'; +export 'src/date_time.dart'; +export 'src/embed_builder.dart'; export 'src/emoji.dart'; -export 'src/user.dart'; -export 'src/sanitizer.dart'; export 'src/guild.dart'; -export 'src/pagination.dart'; -export 'src/channel.dart'; export 'src/message.dart'; +export 'src/pagination.dart'; +export 'src/role.dart'; +export 'src/sanitizer.dart'; +export 'src/user.dart'; +export 'src/utils/formatters.dart'; diff --git a/lib/src/channel.dart b/lib/src/channel.dart index 534d04d..5dfde75 100644 --- a/lib/src/channel.dart +++ b/lib/src/channel.dart @@ -1,4 +1,10 @@ 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. diff --git a/lib/src/date_time.dart b/lib/src/date_time.dart new file mode 100644 index 0000000..f341d6f --- /dev/null +++ b/lib/src/date_time.dart @@ -0,0 +1,12 @@ +import 'package:nyxx_extensions/src/utils/formatters.dart'; + +extension TimestampStyleDateTime on DateTime { + /// Formats the [DateTime] into a date string timestamp. + String format([TimestampStyle style = TimestampStyle.none]) => formatDate(this, style); +} + +extension TimestampStyleDuration 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/embed_builder.dart new file mode 100644 index 0000000..b8d976f --- /dev/null +++ b/lib/src/embed_builder.dart @@ -0,0 +1,38 @@ +import 'package:nyxx/nyxx.dart'; + +extension EmbedExtension on Embed { + EmbedBuilder toEmbedBuilder() { + return EmbedBuilder( + author: author?._toEmbedAuthorBuilder(), + color: color, + description: description, + fields: fields?.map((field) => field._toEmbedFieldBuilder()).toList(), + footer: footer?._toEmbedFooterBuilder(), + image: image?._toEmbedImageBuilder(), + thumbnail: thumbnail?._toEmbedThumbnailBuilder(), + timestamp: timestamp, + title: title, + url: url, + ); + } +} + +extension on EmbedAuthor { + EmbedAuthorBuilder _toEmbedAuthorBuilder() => EmbedAuthorBuilder(name: name, iconUrl: iconUrl, url: url); +} + +extension on EmbedField { + EmbedFieldBuilder _toEmbedFieldBuilder() => EmbedFieldBuilder(name: name, value: value, isInline: inline); +} + +extension on EmbedFooter { + EmbedFooterBuilder _toEmbedFooterBuilder() => EmbedFooterBuilder(text: text, iconUrl: iconUrl); +} + +extension on EmbedImage { + EmbedImageBuilder _toEmbedImageBuilder() => EmbedImageBuilder(url: url); +} + +extension on EmbedThumbnail { + EmbedThumbnailBuilder _toEmbedThumbnailBuilder() => EmbedThumbnailBuilder(url: url); +} diff --git a/lib/src/guild.dart b/lib/src/guild.dart index e421c88..a41fbb2 100644 --- a/lib/src/guild.dart +++ b/lib/src/guild.dart @@ -7,5 +7,5 @@ extension GuildExtension on Guild { } /// A URL clients can visit to navigate to this guild. - Uri get url => Uri.https(manager.client.apiOptions.host, '/channels/$id'); + Uri get url => Uri.https(manager.client.apiOptions.host, '/guilds/$id'); } diff --git a/lib/src/role.dart b/lib/src/role.dart new file mode 100644 index 0000000..50aafd4 --- /dev/null +++ b/lib/src/role.dart @@ -0,0 +1,13 @@ +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/user.dart b/lib/src/user.dart index 181dd29..9557276 100644 --- a/lib/src/user.dart +++ b/lib/src/user.dart @@ -1,4 +1,5 @@ import "package:nyxx/nyxx.dart"; +import "package:nyxx_extensions/src/utils/formatters.dart"; extension UserExtension on PartialUser { /// Fetch all the mutual guilds the client shares with this user. @@ -19,4 +20,10 @@ extension UserExtension on PartialUser { return result; } + + /// A URL clients can visit to open the user's profile. + Uri get url => Uri.https(manager.client.apiOptions.host, '/users/$id'); + + /// A mention of this user. + String get mention => userMention(id); } diff --git a/lib/src/utils/formatters.dart b/lib/src/utils/formatters.dart new file mode 100644 index 0000000..5114c08 --- /dev/null +++ b/lib/src/utils/formatters.dart @@ -0,0 +1,62 @@ +import 'package:nyxx/src/models/snowflake.dart'; + +/// Wraps the [code] in a code block with the specified language, if any. +String codeBlock(String code, [String language = '']) => '```$language\n$code\n```'; + +/// Wraps the [content] inside `backticks`. +String inlineCode(String content) => content.contains('`') ? '``$content``' : '`$content`'; + +/// Wraps the [content] inside `*`. +String italic(String content) => '*$content*'; + +/// Wraps the [content] inside `**`. +String bold(String content) => '**$content**'; + +/// Wraps the [content] inside `__`. +String underline(String content) => '__${content}__'; + +/// Wraps the [content] inside `~~`. +String strikethrough(String content) => '~~$content~~'; + +/// Quotes the [content]. +String quote(String content) => '> $content'; + +/// Quotes the [content] in a quote block. +String quoteBlock(String content) => '>>> $content'; + +/// Wraps the [url] inside `<>`, used to remove its embed. +String hideEmbed(String url) => '<$url>'; + +/// Format the [content] and the URL into a hyperlink (aka [Markdown link](https://www.markdownguide.org/basic-syntax/#links)), and optionally, add a [title] that will be displayed on hover. +String hyperlink(String content, String url, [String? title]) => '[$content](<$url${title != null ? ' "$title"' : ''}>)'; + +/// Wraps the [content] inside `||`. +String spoiler(String content) => '||$content||'; + +/// Formats a user ID into a user mention. +String userMention(Snowflake id) => '<@$id>'; + +/// Formats a channel ID into a channel mention. +String channelMention(Snowflake id) => '<#$id>'; + +/// Formats a role ID into a role mention. +String roleMention(Snowflake id) => '<@&$id>'; + +/// Formats the [date] into a date string timestamp. +String formatDate(DateTime date, [TimestampStyle style = TimestampStyle.none]) => + ''; + +enum TimestampStyle { + none(''), + shortTime('t'), + longTime('T'), + shortDate('d'), + longDate('D'), + shortDateTime('f'), + longDateTime('F'), + relativeTime('R'); + + /// The style of the timestamp. + final String style; + const TimestampStyle(this.style); +} diff --git a/test/unit/formatters_test.dart b/test/unit/formatters_test.dart new file mode 100644 index 0000000..a02e8f1 --- /dev/null +++ b/test/unit/formatters_test.dart @@ -0,0 +1,101 @@ +import 'package:nyxx_extensions/nyxx_extensions.dart'; +import 'package:test/test.dart'; + +const testContent = 'This is a `test` content'; + +void main() { + group('Format Content', () { + test( + 'Code Block', + () => expect( + codeBlock(testContent, 'dart'), + equals( + ''' +```dart +$testContent +```''', + ), + ), + ); + + test( + 'Inline Code', + () => expect( + inlineCode(testContent), + equals('``$testContent``'), + ), + ); + + test( + 'Italic', + () => expect( + italic(testContent), + equals('*$testContent*'), + ), + ); + + test( + 'Bold', + () => expect( + bold(testContent), + equals('**$testContent**'), + ), + ); + + test( + 'Underline', + () => expect( + underline(testContent), + equals('__${testContent}__'), + ), + ); + + test( + 'Strikethrough', + () => expect( + strikethrough(testContent), + equals('~~$testContent~~'), + ), + ); + + test( + 'Quote', + () => expect( + quote(testContent), + equals('> $testContent'), + ), + ); + + test( + 'Quote Block', + () => expect( + quoteBlock(testContent), + equals('>>> $testContent'), + ), + ); + + test( + 'Hide Embed', + () => expect( + hideEmbed(testContent), + equals('<$testContent>'), + ), + ); + + test( + 'Hyperlink', + () => expect( + hyperlink(testContent, 'https://example.com', 'Example'), + equals('[$testContent]()'), + ), + ); + + test( + 'Spoiler', + () => expect( + spoiler(testContent), + equals('||$testContent||'), + ), + ); + }); +} diff --git a/test/unit/timestamp_style_test.dart b/test/unit/timestamp_style_test.dart new file mode 100644 index 0000000..d84bd8d --- /dev/null +++ b/test/unit/timestamp_style_test.dart @@ -0,0 +1,42 @@ +import 'package:test/test.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_extensions/nyxx_extensions.dart'; + +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(''))); + }); +} From 3fdee4b3681d52225da4a920474e093664c9f4d0 Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Sun, 17 Sep 2023 09:40:49 -0700 Subject: [PATCH 6/9] Release 4.0.0-dev.1 (#15) --- CHANGELOG.md | 6 ++++++ lib/src/pagination.dart | 20 +++++--------------- pubspec.yaml | 4 ++-- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d401895..a6d525f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 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 - Bump nyxx to `4.2.0` diff --git a/lib/src/pagination.dart b/lib/src/pagination.dart index 4faf0ee..b55e0da 100644 --- a/lib/src/pagination.dart +++ b/lib/src/pagination.dart @@ -109,7 +109,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. -class Pagination extends NyxxPlugin { +class Pagination extends NyxxPlugin { @override String get name => 'Pagination'; @@ -151,13 +151,7 @@ class Pagination extends NyxxPlugin { } @override - Future connect(ApiOptions apiOptions, ClientOptions clientOptions, Future Function() connect) async { - final client = await connect(); - - if (client is! NyxxGateway) { - throw NyxxException('Pagination requires NyxxGateway'); - } - + void afterConnect(NyxxGateway client) async { client.onMessageComponentInteraction.listen((event) async { final interaction = event.interaction; final data = interaction.data; @@ -224,15 +218,13 @@ class Pagination extends NyxxPlugin { break; } }); - - return client; } @override - Future close(Nyxx client, Future Function() close) async { + Future beforeClose(NyxxGateway client) async { final clientStates = _clientStates.remove(client); if (clientStates != null) { - for (final state in clientStates) { + await Future.wait(clientStates.map((state) async { _unregisterPagination(state); state.disableTimer?.cancel(); @@ -243,10 +235,8 @@ class Pagination extends NyxxPlugin { await state.builderForIndex(state.currentIndex), )); } - } + })); } - - await close(); } /// Create a [MessageBuilder] for a paginated message created by a list of builder factories. diff --git a/pubspec.yaml b/pubspec.yaml index b90c56e..60f688e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_extensions -version: 4.0.0 +version: 4.0.0-dev.1 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-dev.2 + nyxx: ^6.0.0-dev.3 dev_dependencies: coverage: ^1.0.3 From e224e3b783c92d657c39250f1a38b41a38df181c Mon Sep 17 00:00:00 2001 From: Rapougnac Date: Wed, 18 Oct 2023 13:37:43 +0200 Subject: [PATCH 7/9] Fix markdown hyperlink (#16) --- lib/src/utils/formatters.dart | 2 +- test/unit/formatters_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/utils/formatters.dart b/lib/src/utils/formatters.dart index 5114c08..7067d16 100644 --- a/lib/src/utils/formatters.dart +++ b/lib/src/utils/formatters.dart @@ -28,7 +28,7 @@ String quoteBlock(String content) => '>>> $content'; String hideEmbed(String url) => '<$url>'; /// Format the [content] and the URL into a hyperlink (aka [Markdown link](https://www.markdownguide.org/basic-syntax/#links)), and optionally, add a [title] that will be displayed on hover. -String hyperlink(String content, String url, [String? title]) => '[$content](<$url${title != null ? ' "$title"' : ''}>)'; +String hyperlink(String content, String url, [String? title]) => '[$content](<$url>${title != null ? ' "$title"' : ''})'; /// Wraps the [content] inside `||`. String spoiler(String content) => '||$content||'; diff --git a/test/unit/formatters_test.dart b/test/unit/formatters_test.dart index a02e8f1..dc02407 100644 --- a/test/unit/formatters_test.dart +++ b/test/unit/formatters_test.dart @@ -86,7 +86,7 @@ $testContent 'Hyperlink', () => expect( hyperlink(testContent, 'https://example.com', 'Example'), - equals('[$testContent]()'), + equals('[$testContent]( "Example")'), ), ); From 019233af383ee934008ca1f6e0b6b2fe3d6a3a92 Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Fri, 20 Oct 2023 23:01:59 +0200 Subject: [PATCH 8/9] Release 4.0.0 (#17) --- CHANGELOG.md | 4 ++++ pubspec.yaml | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d525f..f1fd2be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 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. diff --git a/pubspec.yaml b/pubspec.yaml index 60f688e..4837d9c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_extensions -version: 4.0.0-dev.1 +version: 4.0.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,10 +10,10 @@ environment: dependencies: http: ^1.1.0 - nyxx: ^6.0.0-dev.3 + nyxx: ^6.0.0 dev_dependencies: coverage: ^1.0.3 - lints: ^2.0.1 + lints: ^3.0.0 mockito: ^5.4.2 test: ^1.17.0 From d942a5e3f95c167159cbd764077c6abc8a6400a7 Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Fri, 20 Oct 2023 23:07:20 +0200 Subject: [PATCH 9/9] Resolve merge conflicts from next -> main (#18) Co-authored-by: Szymon Uglis