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] 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)); - }); - }); -}