diff --git a/CHANGELOG.md b/CHANGELOG.md index afcac703e..a33b4a4cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,34 @@ +## 4.3.0 +__19.11.2022__ + +- feature: Add retry with backoff to network operations (gateway and http) (#395) +- feature: automoderation regexes (#393) +- feature: add support for interaction webhooks (#397) +- feature: Forward `RetryOptions` +- bug: Fixed bug when getting IInviteWithMeta (#398) +- bug: Emit bot start to plugins only when ready +- bug: fix builder not building when editing a guild member (#405) + +## 4.3.0-dev.1 +__15.11.2022__ + +- feature: add support for interaction webhooks (#397) +- bug: Fixed bug when getting IInviteWithMeta (#398) + +This version also includes fixes from 4.2.1 + ## 4.2.1 __15.11.2022__ - hotfix: fix component deserialization failing when `customId` is `null` +## 4.3.0-dev.0 +__14.11.2022__ + +- feature: Add retry with backoff to network operations (gateway and http) (#395) +- feature: automoderation regexes (#393) +- bug: Emit bot start to plugins only when ready + ## 4.2.0 __13.11.2022__ diff --git a/analysis_options.yaml b/analysis_options.yaml index 53ec6785d..9a84263c2 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -6,7 +6,7 @@ linter: implementation_imports: false analyzer: - exclude: [build/**, example/**] + exclude: [build/**] language: strict-raw-types: true strong-mode: diff --git a/example/embeds.dart b/example/embeds.dart index 0e0ca405d..d3496ec17 100644 --- a/example/embeds.dart +++ b/example/embeds.dart @@ -14,7 +14,7 @@ void main() async { final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged | GatewayIntents.messageContent) // Here we use the privilegied intent message content to receive incoming messages. ..registerPlugin(Logging()) // Default logging plugin ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur + ..registerPlugin(IgnoreExceptions()); // Plugin that handles uncaught exceptions that may occur // Listen to ready event. Invoked when bot is connected to all shards. Note that cache can be empty or not incomplete. bot.eventsWs.onReady.listen((IReadyEvent e) { diff --git a/example/permissions.dart b/example/permissions.dart index 818331449..0b80cafeb 100644 --- a/example/permissions.dart +++ b/example/permissions.dart @@ -1,3 +1,4 @@ +// ignore_for_file: unused_local_variable import "package:nyxx/nyxx.dart"; // Main function @@ -34,7 +35,7 @@ void main() { final permissions = await messageChannel.effectivePermissions(member); // Get current member permissions as builder - final permissionsAsBuilder = permissions.toBuilder()..sendMessages = true; + final permissionsAsBuilder = permissions.toBuilder()..sendMessages = true; // @ig // Get first channel override as builder and edit sendMessages property to allow sending messages for entities included in this override final channelOverridesAsBuilder = messageChannel.permissionOverrides.first.toBuilder()..sendMessages = true; diff --git a/example/private_emoji.dart b/example/private_emoji.dart new file mode 100644 index 000000000..5f177bf69 --- /dev/null +++ b/example/private_emoji.dart @@ -0,0 +1,32 @@ +//<:Pepega:547759324836003842> + +import "package:nyxx/nyxx.dart"; +import 'dart:io'; + +// Main function +void main() { + // Create new bot instance + final bot = NyxxFactory.createNyxxWebsocket(Platform.environment['BOT_TOKEN']!, GatewayIntents.allUnprivileged | GatewayIntents.messageContent) + ..registerPlugin(Logging()) // Default logging plugin + ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl + ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur + ..connect(); + + // Listen to all incoming messages + bot.eventsWs.onMessageReceived.listen((e) async { + // Check if message content equals "!ping" + if (e.message.content == "!ping") { + bot.httpEndpoints.fetchChannel(Snowflake(961916452967944223)); + + e.message.guild?.getFromCache()?.shard; + // Send "Pong!" to channel where message was received + e.message.channel.sendMessage(MessageBuilder.content(IBaseGuildEmoji.fromId(Snowflake(502563517774299156)).formatForMessage())); + } + + print(await (await e.message.guild?.getOrDownload())! .getBans().toList()); + + if (e.message.content == "!create-thread") { + bot.httpEndpoints.startForumThread(Snowflake(961916452967944223), ForumThreadBuilder('test', MessageBuilder.content('this is test content <@${e.message.author.id}>'))); + } + }); +} diff --git a/lib/nyxx.dart b/lib/nyxx.dart index 3769a1f96..78b6f161f 100644 --- a/lib/nyxx.dart +++ b/lib/nyxx.dart @@ -197,3 +197,6 @@ export 'src/plugin/plugin_manager.dart' show IPluginManager; export 'src/plugin/plugins/cli_integration.dart' show CliIntegration; export 'src/plugin/plugins/ignore_exception.dart' show IgnoreExceptions; export 'src/plugin/plugins/logging.dart' show Logging; + +// Forward `RetryOptions` to allow the usage of the class without importing the package +export 'package:retry/retry.dart' show RetryOptions; diff --git a/lib/src/client_options.dart b/lib/src/client_options.dart index 15732e2aa..2f93e3656 100644 --- a/lib/src/client_options.dart +++ b/lib/src/client_options.dart @@ -1,6 +1,7 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx/src/nyxx.dart'; import 'package:nyxx/src/internal/shard/shard.dart'; +import 'package:retry/retry.dart'; /// Options for configuring cache. Allows to specify where and which entities should be cached and preserved in cache class CacheOptions { @@ -69,19 +70,31 @@ class ClientOptions { /// Allows to enable receiving raw gateway event bool dispatchRawShardEvent; + /// The [RetryOptions] to use when a shard fails to connect to the gateway. + RetryOptions shardReconnectOptions; + + /// The [RetryOptions] to use when a HTTP request fails. + /// + /// Note that this will not retry requests that fail because of their HTTP response code (e.g a 4xx response) but rather requests that fail due to native + /// errors (e.g failed host lookup) which can occur if there is no internet. + RetryOptions httpRetryOptions; + /// Makes a new `ClientOptions` object. - ClientOptions( - {this.allowedMentions, - this.shardCount, - this.messageCacheSize = 100, - this.largeThreshold = 50, - this.compressedGatewayPayloads = true, - this.guildSubscriptions = true, - this.initialPresence, - this.shutdownHook, - this.shutdownShardHook, - this.dispatchRawShardEvent = false, - this.shardIds}); + ClientOptions({ + this.allowedMentions, + this.shardCount, + this.messageCacheSize = 100, + this.largeThreshold = 50, + this.compressedGatewayPayloads = true, + this.guildSubscriptions = true, + this.initialPresence, + this.shutdownHook, + this.shutdownShardHook, + this.dispatchRawShardEvent = false, + this.shardIds, + this.shardReconnectOptions = const RetryOptions(), + this.httpRetryOptions = const RetryOptions(), + }); } /// When identifying to the gateway, you can specify an intents parameter which diff --git a/lib/src/core/guild/auto_moderation.dart b/lib/src/core/guild/auto_moderation.dart index b1703c9c9..7e799752c 100644 --- a/lib/src/core/guild/auto_moderation.dart +++ b/lib/src/core/guild/auto_moderation.dart @@ -134,8 +134,11 @@ abstract class ITriggerMetadata { /// The total number of mentions (either role and user) allowed per message. /// (Maximum of 50) /// The associated trigger type is [TriggerTypes.mentionSpam] - // Pr still not merged int? get mentionLimit; + + /// Regular expression patterns which will be matched against content + /// The associated trigger type is [TriggerTypes.keyword] + Iterable? get regexPatterns; } abstract class IActionStructure { @@ -218,16 +221,21 @@ class TriggerMetadata implements ITriggerMetadata { @override late final int? mentionLimit; + @override + late final Iterable? regexPatterns; + /// Creates an instance of [TriggerMetadata] TriggerMetadata(RawApiMap data) { keywordsFilter = data['keyword_filter'] != null ? (data['keyword_filter'] as RawApiList).cast() : null; keywordPresets = data['presets'] != null ? (data['presets'] as RawApiList).map((p) => KeywordPresets._fromValue(p as int)) : null; allowList = (data['allow_list'] as RawApiList?)?.cast().toList(); mentionLimit = data['mention_total_limit'] as int?; + regexPatterns = data['regex_patterns'] != null ? (data['regex_patterns'] as RawApiList).cast() : null; } @override - String toString() => 'ITriggerMetadata(keywordPresets: $keywordPresets, keywordFilter: $keywordsFilter, allowList: $allowList, mentionLimit: $mentionLimit)'; + String toString() => + 'ITriggerMetadata(keywordPresets: $keywordPresets, keywordFilter: $keywordsFilter, allowList: $allowList, mentionLimit: $mentionLimit, regexPatterns: $regexPatterns)'; } class ActionStructure implements IActionStructure { diff --git a/lib/src/core/guild/webhook.dart b/lib/src/core/guild/webhook.dart index e1eed8d3c..c299e28c4 100644 --- a/lib/src/core/guild/webhook.dart +++ b/lib/src/core/guild/webhook.dart @@ -127,7 +127,7 @@ class Webhook extends SnowflakeEntity implements IWebhook { String get username => name.toString(); @override - int get discriminator => -1; + late final int discriminator; @override bool get bot => true; @@ -139,11 +139,18 @@ class Webhook extends SnowflakeEntity implements IWebhook { @override final INyxx client; + @override + bool get isInteractionWebhook => discriminator != -1; + + @override + String get formattedDiscriminator => discriminator.toString().padLeft(4, "0"); + /// Creates an instance of [Webhook] Webhook(RawApiMap raw, this.client) : super(Snowflake(raw["id"] as String)) { - name = raw["name"] as String?; + name = raw["name"] as String? ?? raw['username'] as String?; token = raw["token"] as String? ?? ""; avatarHash = raw["avatar"] as String?; + discriminator = int.tryParse(raw['discriminator'] as String? ?? '-1') ?? -1; if (raw["type"] != null) { type = WebhookType.from(raw["type"] as int); diff --git a/lib/src/core/user/user.dart b/lib/src/core/user/user.dart index de0133016..6f29ae280 100644 --- a/lib/src/core/user/user.dart +++ b/lib/src/core/user/user.dart @@ -20,9 +20,6 @@ abstract class IUser implements SnowflakeEntity, ISend, Mentionable, IMessageAut /// Reference to client INyxx get client; - /// Formatted discriminator with leading zeros if needed - String get formattedDiscriminator; - /// The user's avatar hash. String? get avatar; @@ -118,6 +115,9 @@ class User extends SnowflakeEntity implements IUser { @override late final DiscordColor? accentColor; + @override + bool get isInteractionWebhook => false; + /// Creates an instance of [User] User(this.client, RawApiMap raw) : super(Snowflake(raw["id"])) { username = raw["username"] as String; diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index 2d89bbc0f..2e807abbb 100644 --- a/lib/src/internal/constants.dart +++ b/lib/src/internal/constants.dart @@ -33,7 +33,7 @@ class Constants { static const int apiVersion = 10; /// Version of Nyxx - static const String version = "4.2.1"; + static const String version = "4.3.0"; /// Url to Nyxx repo static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; diff --git a/lib/src/internal/exceptions/http_client_exception.dart b/lib/src/internal/exceptions/http_client_exception.dart index 3a7fe8d40..ec40424c2 100644 --- a/lib/src/internal/exceptions/http_client_exception.dart +++ b/lib/src/internal/exceptions/http_client_exception.dart @@ -1,6 +1,7 @@ import 'package:http/http.dart' as http; /// Exception of http client +@Deprecated('Unused, will be removed in the next major release') class HttpClientException extends http.ClientException { /// Raw response from server final http.BaseResponse? response; diff --git a/lib/src/internal/http/http_handler.dart b/lib/src/internal/http/http_handler.dart index d793bfae4..4d0704585 100644 --- a/lib/src/internal/http/http_handler.dart +++ b/lib/src/internal/http/http_handler.dart @@ -78,25 +78,14 @@ class HttpHandler { } // Execute request - try { - currentBucket?.addInFlightRequest(request); - final response = await httpClient.send(await request.prepareRequest()); - currentBucket?.removeInFlightRequest(request); - currentBucket = _upsertBucket(request, response); - return _handle(request, response); - } on HttpClientException catch (e) { - currentBucket?.removeInFlightRequest(request); - if (e.response == null) { - logger.warning("Http Error on endpoint: ${request.uri}. Error: [${e.message.toString()}]."); - rethrow; - } - - final response = e.response as http.StreamedResponse; - _upsertBucket(request, response); - - // Return http error - return _handle(request, response); - } + currentBucket?.addInFlightRequest(request); + final response = await client.options.httpRetryOptions.retry( + () async => httpClient.send(await request.prepareRequest()), + onRetry: (ex) => logger.shout('Exception when sending HTTP request (retrying automatically): $ex'), + ); + currentBucket?.removeInFlightRequest(request); + currentBucket = _upsertBucket(request, response); + return _handle(request, response); } Future _handle(HttpRequest request, http.StreamedResponse response) async { diff --git a/lib/src/internal/http/http_route.dart b/lib/src/internal/http/http_route.dart index 1a0d98c83..0fa5f095a 100644 --- a/lib/src/internal/http/http_route.dart +++ b/lib/src/internal/http/http_route.dart @@ -150,7 +150,7 @@ class HttpRoute implements IHttpRoute { ]) .toList(); - String get path => "/" + pathSegments.join("/"); + String get path => "/${pathSegments.join("/")}"; String get routeId => _httpRouteParts .expand((part) => [ diff --git a/lib/src/internal/http_endpoints.dart b/lib/src/internal/http_endpoints.dart index 4a75057bb..164b27ccc 100644 --- a/lib/src/internal/http_endpoints.dart +++ b/lib/src/internal/http_endpoints.dart @@ -978,7 +978,7 @@ class HttpEndpoints implements IHttpEndpoints { Future fetchUser(Snowflake userId) async { final response = await executeSafe(BasicRequest(HttpRoute()..users(id: userId.toString()))); - final user = User(client, (response as HttpResponseSuccess).jsonBody as RawApiMap); + final user = User(client, response.jsonBody as RawApiMap); if (client.cacheOptions.userCachePolicyLocation.http) { client.users[user.id] = user; @@ -995,7 +995,7 @@ class HttpEndpoints implements IHttpEndpoints { ..members(id: memberId.toString()), method: "PATCH", auditLog: auditReason, - body: builder)); + body: builder.build())); } @override @@ -1015,10 +1015,10 @@ class HttpEndpoints implements IHttpEndpoints { ..invites(), )); - final bodyValues = response.jsonBody.values.first; + final bodyValues = response.jsonBody; - for (final val in bodyValues as Iterable) { - yield InviteWithMeta(val, client); + for (final val in bodyValues) { + yield InviteWithMeta(val as RawApiMap, client); } } diff --git a/lib/src/internal/interfaces/message_author.dart b/lib/src/internal/interfaces/message_author.dart index 7a28024d6..5454fd130 100644 --- a/lib/src/internal/interfaces/message_author.dart +++ b/lib/src/internal/interfaces/message_author.dart @@ -15,6 +15,12 @@ abstract class IMessageAuthor implements SnowflakeEntity { /// User tag: `l7ssha#6712` String get tag; + /// Whether this [IMessageAuthor] is a webhook received by an interaction. + bool get isInteractionWebhook; + + /// Formatted discriminator with leading zeros if needed + String get formattedDiscriminator; + /// Url to user avatar String avatarURL({String format = "webp", int size = 128}); } diff --git a/lib/src/internal/shard/message.dart b/lib/src/internal/shard/message.dart index 6f4ada577..e95a282d9 100644 --- a/lib/src/internal/shard/message.dart +++ b/lib/src/internal/shard/message.dart @@ -2,7 +2,9 @@ class ShardMessage { final T type; final dynamic data; - const ShardMessage(this.type, {this.data}); + final int seq; + + const ShardMessage(this.type, {required this.seq, this.data}); } enum ShardToManager { @@ -28,6 +30,9 @@ enum ShardToManager { /// Sent when the shard is connected connected, + /// Send when the shard successfully reconnects + reconnected, + /// Send when the shard is disconnected /// /// Data payload includes: diff --git a/lib/src/internal/shard/shard.dart b/lib/src/internal/shard/shard.dart index 8fce72564..3d1a5d311 100644 --- a/lib/src/internal/shard/shard.dart +++ b/lib/src/internal/shard/shard.dart @@ -115,16 +115,15 @@ class Shard implements IShard { /// The URL to which this shard should make the initial connection. final String gatewayHost; + /// The last sequence number + // Start at 0 and count up to avoid collisions with seq from the shard handler + int seq = 0; + Shard(this.id, this.manager, this.gatewayHost) { readyFuture = spawn(); // Automatically connect once the shard runner is ready. - readyFuture.then((_) => execute( - ShardMessage(ManagerToShard.connect, data: { - 'gatewayHost': gatewayHost, - 'useCompression': manager.connectionManager.client.options.compressedGatewayPayloads, - }), - )); + readyFuture.then((_) => connect()); // Start handling messages from the shard. readyFuture.then((_) => shardMessages.listen(handle)); @@ -152,20 +151,58 @@ class Shard implements IShard { sendPort.send(message); } + Future _connectReconnectHelper(int seq, {required bool isReconnect}) async { + // These need to be accessible both in the main callback, in retryIf and in the catch block below + bool shouldReconnect = false; + late String errorMessage; + + try { + await manager.connectionManager.client.options.shardReconnectOptions.retry( + retryIf: (_) => shouldReconnect, + () async { + execute(ShardMessage( + isReconnect ? ManagerToShard.reconnect : ManagerToShard.connect, + seq: seq, + data: { + 'gatewayHost': shouldResume && canResume ? resumeGatewayUrl : gatewayHost, + 'useCompression': manager.connectionManager.client.options.compressedGatewayPayloads, + }, + )); + + final message = await shardMessages.firstWhere((element) => element.seq == seq); + + switch (message.type) { + case ShardToManager.connected: + case ShardToManager.reconnected: + return; + case ShardToManager.error: + shouldReconnect = message.data['shouldReconnect'] as bool? ?? false; + errorMessage = message.data['message'] as String; + throw Exception(); + default: + assert(false, 'Unreachable'); + return; + } + }, + ); + } on Exception { + // Callback failed too many times, throw an unrecoverable error with the message we were given + throw UnrecoverableNyxxError(errorMessage); + } + } + + Future connect() => _connectReconnectHelper(seq, isReconnect: false); + /// Triggers a reconnection to the shard. /// /// If the connection is to be resumed, [resumeGatewayUrl] is used as the connection. Otherwise, [gatewayHost] is used. - void reconnect() { + Future reconnect([int? seq]) async { manager.logger.info('Reconnecting to gateway on shard $id'); resetConnectionProperties(); - execute(ShardMessage( - ManagerToShard.reconnect, - data: { - 'gatewayHost': shouldResume && canResume ? resumeGatewayUrl : gatewayHost, - 'useCompression': manager.connectionManager.client.options.compressedGatewayPayloads, - }, - )); + int realSeq = seq ?? (this.seq++); + + await _connectReconnectHelper(realSeq, isReconnect: true); } void resetConnectionProperties() { @@ -184,11 +221,12 @@ class Shard implements IShard { case ShardToManager.received: return handlePayload(message.data); case ShardToManager.connected: + case ShardToManager.reconnected: return handleConnected(); case ShardToManager.disconnected: - return handleDisconnect(message.data['closeCode'] as int, message.data['closeReason'] as String?); + return handleDisconnect(message.data['closeCode'] as int, message.data['closeReason'] as String?, message.seq); case ShardToManager.error: - return handleError(message.data['message'] as String, message.data['shouldReconnect'] as bool?); + return handleError(message.data['message'] as String, message.seq); case ShardToManager.disposed: manager.logger.info("Shard $id disposed."); break; @@ -196,7 +234,7 @@ class Shard implements IShard { } /// A handler for when the shard connection disconnects. - Future handleDisconnect(int closeCode, String? closeReason) async { + Future handleDisconnect(int closeCode, String? closeReason, int seq) async { resetConnectionProperties(); manager.onDisconnectController.add(this); @@ -239,7 +277,7 @@ class Shard implements IShard { } // Reconnect by default - reconnect(); + reconnect(seq); } /// A handler for when the shard establishes a connection to the Gateway. @@ -254,16 +292,12 @@ class Shard implements IShard { } /// A handler for when the shard encounters an error. These can occur if the runner is in an invalid state or fails to open the websocket connection. - Future handleError(String message, bool? shouldReconnect) async { + Future handleError(String message, int seq) async { manager.logger.shout('Shard $id reported error: $message'); for (final element in manager.connectionManager.client.plugins) { element.onConnectionError(manager.connectionManager.client, manager.logger, message); } - - if (shouldReconnect ?? false) { - Future.delayed(const Duration(seconds: 10), reconnect); - } } /// A handler for when a payload from the gateway is received. @@ -629,6 +663,7 @@ class Shard implements IShard { @override void send(int opCode, dynamic d) => execute(ShardMessage( ManagerToShard.send, + seq: seq++, data: { "op": opCode, "d": d, @@ -702,7 +737,7 @@ class Shard implements IShard { @override Future dispose() async { - execute(ShardMessage(ManagerToShard.dispose)); + execute(ShardMessage(ManagerToShard.dispose, seq: seq++)); // Wait for shard to dispose correctly await shardMessages.firstWhere((message) => message.type == ShardToManager.disposed); diff --git a/lib/src/internal/shard/shard_handler.dart b/lib/src/internal/shard/shard_handler.dart index a9079594a..1747bd94f 100644 --- a/lib/src/internal/shard/shard_handler.dart +++ b/lib/src/internal/shard/shard_handler.dart @@ -42,6 +42,13 @@ class ShardRunner implements Disposable { /// [ShardToManager.disconnected] will not be dispatched if this is true. bool disposing = false; + /// Whether this shard is currently connecting to the gateway. + bool connecting = false; + + /// The last sequence number + // Start at -1 and count down to avoid collisions with seq from the shard handler + int seq = -1; + ShardRunner(this.sendPort) { managerMessages.listen(handle); } @@ -54,6 +61,7 @@ class ShardRunner implements Disposable { /// Calls jsonDecode and sends the data back to the manager. void receive(String payload) => execute(ShardMessage( ShardToManager.received, + seq: seq--, data: jsonDecode(payload), )); @@ -61,27 +69,42 @@ class ShardRunner implements Disposable { Future handle(ShardMessage message) async { switch (message.type) { case ManagerToShard.send: - return send(message.data); + return send(message.data, message.seq); case ManagerToShard.connect: - return connect(message.data['gatewayHost'] as String, message.data['useCompression'] as bool); + return connect(message.data['gatewayHost'] as String, message.data['useCompression'] as bool, message.seq); case ManagerToShard.reconnect: - return reconnect(message.data['gatewayHost'] as String, message.data['useCompression'] as bool); + return reconnect(message.data['gatewayHost'] as String, message.data['useCompression'] as bool, message.seq); case ManagerToShard.disconnect: - return disconnect(); + return disconnect(message.seq); case ManagerToShard.dispose: - return dispose(); + return dispose(message.seq); } } /// Initiate the connection on this shard. /// /// Sends [ShardToManager.connected] upon completion. - Future connect(String gatewayHost, bool useCompression) async { + Future connect(String gatewayHost, bool useCompression, int seq) async { if (connected) { - execute(ShardMessage(ShardToManager.error, data: {'message': 'Shard is already connected'})); + execute(ShardMessage( + ShardToManager.error, + seq: seq, + data: {'message': 'Shard is already connected'}, + )); + return; + } + + if (connecting) { + execute(ShardMessage( + ShardToManager.error, + seq: seq, + data: {'message': 'Shard is already connecting'}, + )); return; } + connecting = true; + try { final gatewayUri = Constants.gatewayUri(gatewayHost, useCompression); @@ -95,6 +118,7 @@ class ShardRunner implements Disposable { execute(ShardMessage( ShardToManager.disconnected, + seq: seq, data: { 'closeCode': connection!.closeCode!, 'closeReason': connection!.closeReason, @@ -123,38 +147,49 @@ class ShardRunner implements Disposable { connectionSubscription = connection!.cast().listen(receive); } - execute(ShardMessage(ShardToManager.connected)); + execute(ShardMessage(reconnecting ? ShardToManager.reconnected : ShardToManager.connected, seq: seq)); } on WebSocketException catch (err) { - execute(ShardMessage(ShardToManager.error, data: {'message': err.message, 'shouldReconnect': true})); + execute(ShardMessage(ShardToManager.error, seq: seq, data: {'message': err.message, 'shouldReconnect': true})); } on SocketException catch (err) { - execute(ShardMessage(ShardToManager.error, data: {'message': err.message, 'shouldReconnect': true})); + execute(ShardMessage(ShardToManager.error, seq: seq, data: {'message': err.message, 'shouldReconnect': true})); } catch (err) { - execute(ShardMessage(ShardToManager.error, data: {'message': 'Unhanded exception $err'})); + execute(ShardMessage(ShardToManager.error, seq: seq, data: {'message': 'Unhanded exception $err'})); } + + connecting = false; } /// Reconnect to the server, closing the connection if necessary. - Future reconnect(String gatewayHost, bool useCompression) async { + Future reconnect(String gatewayHost, bool useCompression, int seq) async { if (reconnecting) { - execute(ShardMessage(ShardToManager.error, data: {'message': 'Shard is already reconnecting'})); + execute(ShardMessage( + ShardToManager.error, + seq: seq, + data: {'message': 'Shard is already reconnecting'}, + )); } reconnecting = true; if (connected) { // Don't send a normal close code so that the bot doesn't appear offline during the reconnect. - await disconnect(3001); + await disconnect(seq, 3001); } - await connect(gatewayHost, useCompression); + // Sends reconnected instead of connected so we don't have to send it here + await connect(gatewayHost, useCompression, seq); reconnecting = false; } /// Terminate the connection on this shard. /// /// Sends [ShardToManager.disconnected]. - Future disconnect([int closeCode = 1000]) async { + Future disconnect(int seq, [int closeCode = 1000]) async { if (!connected) { - execute(ShardMessage(ShardToManager.error, data: {'message': 'Cannot disconnect shard if no connection is active'})); + execute(ShardMessage( + ShardToManager.error, + seq: seq, + data: {'message': 'Cannot disconnect shard if no connection is active'}, + )); } // Closing the connection will trigger the `connection.done` future we listened to when connecting, which will execute the [ShardToManager.disconnected] @@ -167,9 +202,13 @@ class ShardRunner implements Disposable { } /// Sends data on this shard. - Future send(dynamic data) async { + Future send(dynamic data, int seq) async { if (!connected) { - execute(ShardMessage(ShardToManager.error, data: {'message': 'Cannot send data when connection is closed'})); + execute(ShardMessage( + ShardToManager.error, + seq: seq, + data: {'message': 'Cannot send data when connection is closed'}, + )); } connection!.add(jsonEncode(data)); @@ -179,14 +218,15 @@ class ShardRunner implements Disposable { /// /// Sends [ShardToManager.disposed] upon completion. @override - Future dispose() async { + Future dispose([int? seq]) async { disposing = true; + seq ??= (this.seq--); if (connected) { - await disconnect(); + await disconnect(seq); } receivePort.close(); - execute(ShardMessage(ShardToManager.disposed)); + execute(ShardMessage(ShardToManager.disposed, seq: seq)); } } diff --git a/lib/src/nyxx.dart b/lib/src/nyxx.dart index 041f8b399..1613ad4d6 100644 --- a/lib/src/nyxx.dart +++ b/lib/src/nyxx.dart @@ -207,10 +207,10 @@ class NyxxRest extends INyxxRest { if (propagateReady) { onReadyController.add(ReadyEvent(this)); - } - for (final plugin in _plugins) { - await plugin.onBotStart(this, _logger); + for (final plugin in _plugins) { + await plugin.onBotStart(this, _logger); + } } } @@ -372,6 +372,10 @@ class NyxxWebsocket extends NyxxRest implements INyxxWebsocket { if (propagateReady) { onReadyController.add(ReadyEvent(this)); + + for (final plugin in _plugins) { + await plugin.onBotStart(this, _logger); + } } } diff --git a/lib/src/utils/builders/auto_moderation_builder.dart b/lib/src/utils/builders/auto_moderation_builder.dart index 55ccd6423..991a6571c 100644 --- a/lib/src/utils/builders/auto_moderation_builder.dart +++ b/lib/src/utils/builders/auto_moderation_builder.dart @@ -94,14 +94,18 @@ class TriggerMetadataBuilder implements Builder { /// The total number of mentions (either role and user) allowed per message. /// (Maximum of 50) - // Pr still not merged int? mentionLimit; + /// Regular expression patterns which will be matched against content + ///(Maximum of 10) + List? regexPatterns; + @override RawApiMap build() => { if (keywordFilter != null) 'keyword_filter': keywordFilter, if (presets != null) 'presets': presets!.map((e) => e.value).toList(), if (allowList != null) 'allow_list': allowList, if (mentionLimit != null) 'mention_total_limit': mentionLimit, + if (regexPatterns != null) 'regex_patterns': regexPatterns }; } diff --git a/pubspec.yaml b/pubspec.yaml index 6c87afe8c..830f5a33b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx -version: 4.2.1 +version: 4.3.0 description: A 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 @@ -13,10 +13,11 @@ dependencies: http: ^0.13.3 logging: ^1.0.1 path: ^1.8.0 + retry: ^3.1.0 dev_dependencies: test: ^1.19.0 mockito: ^5.0.16 build_runner: ^2.1.4 - lints: ^1.0.1 + lints: ^2.0.0 coverage: ^1.0.3