diff --git a/.gitignore b/.gitignore index b0ac6f9c0..04ab2398c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ test-*.dart **/coverage/** coverage.json lcov.info +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 016acf7bd..aecf0b2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 3.1.0 +__28.12.2021__ + +- Implement patches needed for external sharding feature (#266) +- Implement boost progress bar (#266) +- Implement timeouts (#267) + - deprecation of edit method parameters in favor of `MemberBuilder` class. In next major release all parameters except `builder` + and `auditReason` will be removed +- Fix incorrectly initialised onDmReceived and onSelfMention streams (#270) + ## 3.0.1 __21.12.2021__ diff --git a/lib/nyxx.dart b/lib/nyxx.dart index dfab87bec..b9275bdf4 100644 --- a/lib/nyxx.dart +++ b/lib/nyxx.dart @@ -156,6 +156,7 @@ export 'src/utils/builders/embed_footer_builder.dart' show EmbedFooterBuilder; export 'src/utils/builders/guild_builder.dart' show GuildBuilder, RoleBuilder; export 'src/utils/builders/channel_builder.dart'; export 'src/utils/builders/message_builder.dart' show MessageBuilder, MessageDecoration; +export 'src/utils/builders/member_builder.dart' show MemberBuilder; export 'src/utils/builders/permissions_builder.dart' show PermissionOverrideBuilder, PermissionsBuilder; export 'src/utils/builders/presence_builder.dart' show PresenceBuilder, ActivityBuilder; export 'src/utils/builders/reply_builder.dart' show ReplyBuilder; diff --git a/lib/src/client_options.dart b/lib/src/client_options.dart index a73f82088..7cad12336 100644 --- a/lib/src/client_options.dart +++ b/lib/src/client_options.dart @@ -41,6 +41,9 @@ class ClientOptions { /// The total number of shards. int? shardCount; + /// A list of shards to spawn on this instance of nyxx. + List? shardIds; + /// The number of messages to cache for each channel. int messageCacheSize; @@ -81,7 +84,8 @@ class ClientOptions { this.initialPresence, this.shutdownHook, this.shutdownShardHook, - this.dispatchRawShardEvent = false}); + this.dispatchRawShardEvent = false, + this.shardIds}); } /// When identifying to the gateway, you can specify an intents parameter which diff --git a/lib/src/core/guild/guild.dart b/lib/src/core/guild/guild.dart index c14dda666..00bfe9181 100644 --- a/lib/src/core/guild/guild.dart +++ b/lib/src/core/guild/guild.dart @@ -1,4 +1,5 @@ import 'package:nyxx/src/core/guild/scheduled_event.dart'; +import 'package:nyxx/src/internal/exceptions/invalid_shard_exception.dart'; import 'package:nyxx/src/nyxx.dart'; import 'package:nyxx/src/core/channel/invite.dart'; import 'package:nyxx/src/core/snowflake.dart'; @@ -147,6 +148,9 @@ abstract class IGuild implements SnowflakeEntity { /// Returns this guilds shard IShard get shard; + /// Whether the guild has the boost progress bar enabled + bool get boostProgressBarEnabled; + /// The guild's icon, represented as URL. /// If guild doesn't have icon it returns null. String? iconURL({String format = "webp", int size = 128}); @@ -427,6 +431,9 @@ class Guild extends SnowflakeEntity implements IGuild { @override late final Iterable stickers; + @override + late final bool boostProgressBarEnabled; + /// Returns url to this guild. @override String get url => "https://discordapp.com/channels/${id.toString()}"; @@ -468,7 +475,10 @@ class Guild extends SnowflakeEntity implements IGuild { throw UnsupportedError("Cannot use this property with NyxxRest"); } - return (client as NyxxWebsocket).shardManager.shards.firstWhere((_shard) => _shard.guilds.contains(id)); + return (client as NyxxWebsocket).shardManager.shards.firstWhere( + (_shard) => _shard.guilds.contains(id), + orElse: throw InvalidShardException('Cannot find shard for this guild!'), + ); } /// Creates an instance of [Guild] @@ -489,6 +499,7 @@ class Guild extends SnowflakeEntity implements IGuild { premiumTier = PremiumTier.from(raw["premium_tier"] as int); premiumSubscriptionCount = raw["premium_subscription_count"] as int?; preferredLocale = raw["preferred_locale"] as String; + boostProgressBarEnabled = raw['premium_progress_bar_enabled'] as bool; owner = UserCacheable(client, Snowflake(raw["owner_id"])); diff --git a/lib/src/core/user/member.dart b/lib/src/core/user/member.dart index 7c2a2e0bd..308d004b6 100644 --- a/lib/src/core/user/member.dart +++ b/lib/src/core/user/member.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:nyxx/nyxx.dart'; import 'package:nyxx/src/nyxx.dart'; import 'package:nyxx/src/core/snowflake.dart'; import 'package:nyxx/src/core/snowflake_entity.dart'; @@ -58,6 +59,12 @@ abstract class IMember implements SnowflakeEntity, Mentionable { /// Returns total permissions of user. Future get effectivePermissions; + /// When the user's timeout will expire and the user will be able to communicate in the guild again, null or a time in the past if the user is not timed out + DateTime? get timeoutUntil; + + /// True if user is timed out + bool get isTimedOut; + /// Returns url to member avatar String? avatarURL({String format = "webp"}); @@ -136,6 +143,12 @@ class Member extends SnowflakeEntity implements IMember { @override String get mention => "<@$id>"; + @override + bool get isTimedOut => timeoutUntil != null && timeoutUntil!.isAfter(DateTime.now()); + + @override + late final DateTime? timeoutUntil; + /// Returns total permissions of user. @override Future get effectivePermissions async { @@ -168,6 +181,7 @@ class Member extends SnowflakeEntity implements IMember { guild = GuildCacheable(client, guildId); boostingSince = DateTime.tryParse(raw["premium_since"] as String? ?? ""); avatarHash = raw["avatar"] as String?; + timeoutUntil = raw['communication_disabled_until'] != null ? DateTime.parse(raw['communication_disabled_until'] as String) : null; roles = [for (var id in raw["roles"]) RoleCacheable(client, Snowflake(id), guild)]; @@ -224,8 +238,15 @@ class Member extends SnowflakeEntity implements IMember { /// Edits members. Allows to move user in voice channel, mute or deaf, change nick, roles. @override Future edit( - {String? nick = "", List? roles, bool? mute, bool? deaf, Snowflake? channel = const Snowflake.zero(), String? auditReason}) => - client.httpEndpoints.editGuildMember(guild.id, id, nick: nick, roles: roles, mute: mute, deaf: deaf, channel: channel, auditReason: auditReason); + {@Deprecated('Use "builder" parameter') String? nick = "", + @Deprecated('Use "builder" parameter') List? roles, + @Deprecated('Use "builder" parameter') bool? mute, + @Deprecated('Use "builder" parameter') bool? deaf, + @Deprecated('Use "builder" parameter') Snowflake? channel = const Snowflake.zero(), + MemberBuilder? builder, + String? auditReason}) => + client.httpEndpoints + .editGuildMember(guild.id, id, nick: nick, roles: roles, mute: mute, deaf: deaf, channel: channel, builder: builder, auditReason: auditReason); void updateMember(String? nickname, List roles, DateTime? boostingSince) { if (this.nickname != nickname) { diff --git a/lib/src/internal/connection_manager.dart b/lib/src/internal/connection_manager.dart index b09c0dab1..2dadd936f 100644 --- a/lib/src/internal/connection_manager.dart +++ b/lib/src/internal/connection_manager.dart @@ -63,13 +63,12 @@ class ConnectionManager { Future propagateReady() async { _shardsReady++; - if (client.ready || _shardsReady < (client.options.shardCount ?? 1)) { + if (client.ready || _shardsReady < client.shardManager.numShards) { return; } - if (!client.ready) { - (client.eventsWs as WebsocketEventController).onReadyController.add(ReadyEvent(client)); - } + (client.eventsWs as WebsocketEventController).onReadyController.add(ReadyEvent(client)); + client.ready = true; _logger.info("Connected and ready! Logged as `${client.self.tag}`"); } diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index e451b7b4c..471c81a6d 100644 --- a/lib/src/internal/constants.dart +++ b/lib/src/internal/constants.dart @@ -33,7 +33,7 @@ class Constants { static const int apiVersion = 9; /// Version of Nyxx - static const String version = "3.0.1"; + static const String version = "3.1.0"; /// Url to Nyxx repo static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; diff --git a/lib/src/internal/event_controller.dart b/lib/src/internal/event_controller.dart index b0d56c434..638ee02ac 100644 --- a/lib/src/internal/event_controller.dart +++ b/lib/src/internal/event_controller.dart @@ -17,6 +17,7 @@ import 'package:nyxx/src/events/user_update_event.dart'; import 'package:nyxx/src/events/voice_server_update_event.dart'; import 'package:nyxx/src/events/voice_state_update_event.dart'; import 'package:nyxx/src/internal/interfaces/disposable.dart'; +import 'package:nyxx/src/nyxx.dart'; abstract class IRestEventController implements Disposable { /// Emitted when a successful HTTP response is received. @@ -357,7 +358,7 @@ class WebsocketEventController extends RestEventController implements IWebsocket /// Emitted when private message is received. @override - late final Stream onDmReceived; + late final Stream onDmReceived = onMessageReceived.where((event) => event.message.guild == null); /// Emitted when channel"s pins are updated. @override @@ -470,7 +471,8 @@ class WebsocketEventController extends RestEventController implements IWebsocket /// Emitted when bot is mentioned @override - late final Stream onSelfMention; + late final Stream onSelfMention = + onMessageReceived.where((event) => event.message.mentions.map((e) => e.id).contains(_client.self.id)); /// Emitted when invite is created @override @@ -524,8 +526,10 @@ class WebsocketEventController extends RestEventController implements IWebsocket @override late final Stream onGuildEventUpdate; + final INyxxWebsocket _client; + /// Makes a new `EventController`. - WebsocketEventController() : super() { + WebsocketEventController(this._client) : super() { onDisconnectController = StreamController.broadcast(); onDisconnect = onDisconnectController.stream; diff --git a/lib/src/internal/http_endpoints.dart b/lib/src/internal/http_endpoints.dart index fbbaabcb3..66ffa5266 100644 --- a/lib/src/internal/http_endpoints.dart +++ b/lib/src/internal/http_endpoints.dart @@ -1,3 +1,4 @@ +import 'package:nyxx/nyxx.dart'; import 'package:nyxx/src/core/guild/scheduled_event.dart'; import 'package:nyxx/src/nyxx.dart'; import 'package:nyxx/src/core/channel/invite.dart'; @@ -174,7 +175,13 @@ abstract class IHttpEndpoints { /// "Edits" guild member. Allows to manipulate other guild users. Future editGuildMember(Snowflake guildId, Snowflake memberId, - {String? nick, List? roles, bool? mute, bool? deaf, Snowflake? channel = const Snowflake.zero(), String? auditReason}); + {@Deprecated('Use "builder" parameter') String? nick, + @Deprecated('Use "builder" parameter') List? roles, + @Deprecated('Use "builder" parameter') bool? mute, + @Deprecated('Use "builder" parameter') bool? deaf, + @Deprecated('Use "builder" parameter') Snowflake? channel = const Snowflake.zero(), + MemberBuilder? builder, + String? auditReason}); /// Removes role from user Future removeRoleFromUser(Snowflake guildId, Snowflake roleId, Snowflake userId, {String? auditReason}); @@ -844,16 +851,21 @@ class HttpEndpoints implements IHttpEndpoints { @override Future editGuildMember(Snowflake guildId, Snowflake memberId, - {String? nick = "", List? roles, bool? mute, bool? deaf, Snowflake? channel = const Snowflake.zero(), String? auditReason}) { - final body = { - if (nick != "") "nick": nick, - if (roles != null) "roles": roles.map((f) => f.id.toString()).toList(), - if (mute != null) "mute": mute, - if (deaf != null) "deaf": deaf, - if (channel == null || !channel.isZero) "channel_id": channel.toString() - }; - - return executeSafe(BasicRequest("/guilds/$guildId/members/$memberId", method: "PATCH", auditLog: auditReason, body: body)); + {String? nick = "", + List? roles, + bool? mute, + bool? deaf, + Snowflake? channel = const Snowflake.zero(), + MemberBuilder? builder, + String? auditReason}) { + final finalBuilder = builder ?? MemberBuilder() + ..nick = nick + ..roles = roles?.map((e) => e.id).toList() + ..mute = mute + ..deaf = deaf + ..channel = channel; + + return executeSafe(BasicRequest("/guilds/$guildId/members/$memberId", method: "PATCH", auditLog: auditReason, body: finalBuilder)); } @override diff --git a/lib/src/internal/shard/shard.dart b/lib/src/internal/shard/shard.dart index a3a9069e0..715011176 100644 --- a/lib/src/internal/shard/shard.dart +++ b/lib/src/internal/shard/shard.dart @@ -322,7 +322,7 @@ class Shard implements IShard { "guild_subscriptions": manager.connectionManager.client.options.guildSubscriptions, "intents": manager.connectionManager.client.intents, if (manager.connectionManager.client.options.initialPresence != null) "presence": manager.connectionManager.client.options.initialPresence!.build(), - "shard": [id, manager.numShards] + "shard": [id, manager.totalNumShards] }; send(OPCodes.identify, identifyMsg); diff --git a/lib/src/internal/shard/shard_manager.dart b/lib/src/internal/shard/shard_manager.dart index f8766251a..2d070d214 100644 --- a/lib/src/internal/shard/shard_manager.dart +++ b/lib/src/internal/shard/shard_manager.dart @@ -47,6 +47,9 @@ abstract class IShardManager implements Disposable { /// Number of shards spawned int get numShards; + /// Total number of shards for this client + int get totalNumShards; + /// Sets presences on every shard void setPresence(PresenceBuilder presenceBuilder); } @@ -109,6 +112,10 @@ class ShardManager implements IShardManager { @override late final int numShards; + /// Total number of shards for this client + @override + late final int totalNumShards; + final Map _shards = {}; Duration get _identifyDelay { @@ -119,14 +126,36 @@ class ShardManager implements IShardManager { /// Starts shard manager ShardManager(this.connectionManager, this.maxConcurrency) { - numShards = connectionManager.client.options.shardCount != null ? connectionManager.client.options.shardCount! : connectionManager.recommendedShardsNum; + totalNumShards = connectionManager.client.options.shardCount ?? connectionManager.recommendedShardsNum; + numShards = connectionManager.client.options.shardIds?.length ?? totalNumShards; - if (numShards < 1) { + if (totalNumShards < 1) { throw UnrecoverableNyxxError("Number of shards cannot be lower than 1."); } + List toSpawn = _getShardsToSpawn(); + logger.fine("Starting shard manager. Number of shards to spawn: $numShards"); - _connect(numShards - 1); + _connect(toSpawn); + } + + List _getShardsToSpawn() { + if (connectionManager.client.options.shardIds != null) { + if (connectionManager.client.options.shardCount == null) { + throw UnrecoverableNyxxError('Cannot specify shards to spawn without specifying total number of shards'); + } + + for (final id in connectionManager.client.options.shardIds!) { + if (id < 0 || id >= totalNumShards) { + throw UnrecoverableNyxxError('Invalid shard ID: $id'); + } + } + + // Clone list to prevent original list from being modified with removeLast() + return List.of(connectionManager.client.options.shardIds!); + } else { + return List.generate(totalNumShards, (id) => id); + } } /// Sets presences on every shard @@ -137,16 +166,18 @@ class ShardManager implements IShardManager { } } - void _connect(int shardId) { - logger.fine("Setting up shard with id: $shardId"); - - if (shardId < 0) { + void _connect(List toSpawn) { + if (toSpawn.isEmpty) { return; } + int shardId = toSpawn.removeLast(); + + logger.fine("Setting up shard with id: $shardId"); + _shards[shardId] = Shard(shardId, this, connectionManager.gateway); - Future.delayed(_identifyDelay, () => _connect(shardId - 1)); + Future.delayed(_identifyDelay, () => _connect(toSpawn)); } @override diff --git a/lib/src/nyxx.dart b/lib/src/nyxx.dart index 530969fec..940bf38e2 100644 --- a/lib/src/nyxx.dart +++ b/lib/src/nyxx.dart @@ -340,7 +340,7 @@ class NyxxWebsocket extends NyxxRest implements INyxxWebsocket { ignoreExceptions: ignoreExceptions, useDefaultLogger: useDefaultLogger, ) { - eventsWs = WebsocketEventController(); + eventsWs = WebsocketEventController(this); } @override diff --git a/lib/src/plugin/plugins/ignore_exception.dart b/lib/src/plugin/plugins/ignore_exception.dart index 62a77c721..f060da24c 100644 --- a/lib/src/plugin/plugins/ignore_exception.dart +++ b/lib/src/plugin/plugins/ignore_exception.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'dart:isolate'; import 'package:logging/logging.dart'; diff --git a/lib/src/utils/builders/member_builder.dart b/lib/src/utils/builders/member_builder.dart new file mode 100644 index 000000000..b7a9c7bf2 --- /dev/null +++ b/lib/src/utils/builders/member_builder.dart @@ -0,0 +1,31 @@ +import 'package:nyxx/nyxx.dart'; + +class MemberBuilder implements Builder { + /// Value to set user's nickname to + String? nick; + + /// Array of role ids the member is assigned + List? roles; + + /// Whether the user is muted in voice channels. + bool? mute; + + /// Whether the user is deafened in voice channels. + bool? deaf; + + /// Id of channel to move user to (if they are connected to voice) + Snowflake? channel = Snowflake.zero(); + + /// When the user's timeout will expire and the user will be able to communicate in the guild again (up to 28 days in the future), set to null to remove timeout + DateTime? timeoutUntil = DateTime.fromMillisecondsSinceEpoch(0); + + @override + RawApiMap build() => { + if (nick != null) 'nick': nick, + if (roles != null) 'roles': roles!.map((e) => e.toString()).toList(), + if (mute != null) 'mute': mute, + if (deaf != null) 'deaf': deaf, + if (channel != Snowflake.zero()) 'channel_id': channel, + if (timeoutUntil?.millisecondsSinceEpoch != 0) 'communication_disabled_until': timeoutUntil?.toIso8601String(), + }; +} diff --git a/pubspec.yaml b/pubspec.yaml index 5e6d56768..01e2810a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx -version: 3.0.1 +version: 3.1.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 diff --git a/test/unit/builders_test.dart b/test/unit/builders_test.dart index dc8dbd985..a0e71ea92 100644 --- a/test/unit/builders_test.dart +++ b/test/unit/builders_test.dart @@ -102,6 +102,38 @@ main() { expect(ofBuilder.calculatePermissionValue(), equals(1 << 11)); }); + group('MemberBuilder', () { + test('channel empty', () { + final builder = MemberBuilder() + ..channel = Snowflake.zero(); + + expect({}, builder.build()); + }); + + test('channel with value', () { + final builder = MemberBuilder() + ..channel = Snowflake(123); + + expect({'channel_id': '123'}, builder.build()); + }); + + test('timeout empty', () { + final now = DateTime.now(); + + final builder = MemberBuilder() + ..timeoutUntil = now; + + expect({'communication_disabled_until': now.toIso8601String()}, builder.build()); + }); + + test('roles serialization', () { + final builder = MemberBuilder() + ..roles = [Snowflake(1), Snowflake(2)]; + + expect({'roles': ['1', '2']}, builder.build()); + }); + }); + group('MessageBuilder', () { test('clear character', () { final builder = MessageBuilder.empty();