From 7163ba66923c941d4d968d286f2c2394fc500d57 Mon Sep 17 00:00:00 2001 From: Rapougnac <74512338+Rapougnac@users.noreply.github.com> Date: Sat, 20 Aug 2022 14:15:58 +0200 Subject: [PATCH 01/13] feature: auto moderation (#353) * Add autoMod feature * Add abstract methods/all stuff * Adress changes * WIP * Format * fix: some nullable types and add allowList Also export classes and enums * Add `mentionLimit` and document `ActionTypes` * Fix documentation on mentionLimit * Create builders for creating rules Not tested * Add tests for `AutoModerationBuilder` * Add autoModerationConfig intents Still missing one other. To-do * Cache instead of raw snowflakes Also fix some serialization when the payload incomes * Remove unnecessary doc * Add auto-mod related events Still missing other event To-Do Also add webhookUpdate event * Expose builders globally * F, auto import * Client in autimod rule * Implement http endpoints rules to guild * Add Automod action execution * Remove global import * Add missing audit log entries * Oops, format * Where did it came from? * Add unknow entry type * Add auto moderation rule obj on logs Also fix some inconsistency with the docs * [no ci] Docfix * Port events for audit logs * Add threads to audit logs * Wrong value in intents, my bad * Initialize fields (Also fix nullable type on targetId * Refactor, use `Cacheable` instead of raw * Cache auto mod rules * Cache on http endpoints * Fix doc * [ci skip] format * Fix caching on endpoints * Right cast instead of spread * Remove spread on roles & channels * Remove type casts * Remove switch/cases in favor of firstWhere Co-Authored-By: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> * Add ICache, and update rule instead of removing it Co-Authored-By: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> * Fix child typing * Call toList instead of spreading * [ci skip] Fix doc * Fix invalid type cast and firstWhere * Fix useless empties lists Co-Authored-By: Szymon Uglis <23033957+l7ssha@users.noreply.github.com> * Remove spread Co-Authored-By: Szymon Uglis <23033957+l7ssha@users.noreply.github.com> * `Future` ->`Stream` Co-Authored-By: Szymon Uglis <23033957+l7ssha@users.noreply.github.com> * [no ci] Rename enum error Co-Authored-By: Szymon Uglis <23033957+l7ssha@users.noreply.github.com> * Reflect discord docs changes see ddocs 5242 * Typooos * Fix typo (again) Co-authored-by: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Co-authored-by: Szymon Uglis <23033957+l7ssha@users.noreply.github.com> --- lib/nyxx.dart | 13 +- lib/src/client_options.dart | 10 +- lib/src/core/audit_logs/audit_log.dart | 63 ++++- lib/src/core/audit_logs/audit_log_entry.dart | 12 +- .../core/audit_logs/audit_log_options.dart | 6 +- lib/src/core/guild/auto_moderation.dart | 263 ++++++++++++++++++ lib/src/core/guild/guild.dart | 43 +++ lib/src/core/guild/guild_feature.dart | 3 + lib/src/events/guild_events.dart | 175 +++++++++++- lib/src/internal/event_controller.dart | 63 +++++ .../exceptions/unknown_enum_value.dart | 10 + lib/src/internal/http/http_route.dart | 12 + lib/src/internal/http_endpoints.dart | 126 +++++++++ lib/src/internal/shard/shard.dart | 20 ++ .../builders/auto_moderation_builder.dart | 107 +++++++ .../utils/builders/forum_thread_builder.dart | 2 +- pubspec.yaml | 2 +- test/unit/builders_test.dart | 49 ++++ 18 files changed, 954 insertions(+), 25 deletions(-) create mode 100644 lib/src/core/guild/auto_moderation.dart create mode 100644 lib/src/internal/exceptions/unknown_enum_value.dart create mode 100644 lib/src/utils/builders/auto_moderation_builder.dart diff --git a/lib/nyxx.dart b/lib/nyxx.dart index 4b3fe5493..3d1c86495 100644 --- a/lib/nyxx.dart +++ b/lib/nyxx.dart @@ -40,6 +40,8 @@ export 'src/core/embed/embed_provider.dart' show IEmbedProvider; export 'src/core/embed/embed_thumbnail.dart' show IEmbedThumbnail; export 'src/core/embed/embed_video.dart' show IEmbedVideo; export 'src/core/guild/ban.dart' show IBan; +export 'src/core/guild/auto_moderation.dart' + show IActionMetadata, IActionStructure, IAutoModerationRule, ITriggerMetadata, ActionTypes, EventTypes, TriggerTypes; export 'src/core/guild/client_user.dart' show IClientUser; export 'src/core/guild/guild.dart' show IGuild; export 'src/core/guild/guild_feature.dart' show GuildFeature; @@ -103,7 +105,15 @@ export 'src/events/guild_events.dart' IGuildUpdateEvent, IRoleCreateEvent, IRoleDeleteEvent, - IRoleUpdateEvent; + IRoleUpdateEvent, + IAutoModerationRuleCreateEvent, + IAutoModerationRuleUpdateEvent, + IAutoModerationRuleDeleteEvent, + IAutoModerationActionExecutionEvent, + IGuildEventCreateEvent, + IGuildEventUpdateEvent, + IGuildEventDeleteEvent, + IWebhookUpdateEvent; export 'src/events/http_events.dart' show IHttpResponseEvent, IHttpErrorEvent; export 'src/events/invite_events.dart' show IInviteCreatedEvent, IInviteDeletedEvent; export 'src/events/member_chunk_event.dart' show IMemberChunkEvent; @@ -173,6 +183,7 @@ export 'src/utils/builders/sticker_builder.dart' show StickerBuilder; export 'src/utils/builders/thread_builder.dart' show ThreadArchiveTime, ThreadBuilder; export 'src/utils/builders/guild_event_builder.dart' show GuildEventBuilder; export 'src/utils/builders/forum_thread_builder.dart' show ForumThreadBuilder; +export 'src/utils/builders/auto_moderation_builder.dart' show ActionMetadataBuilder, ActionStructureBuilder, AutoModerationRuleBuilder, TriggerMetadataBuilder; export 'src/utils/extensions.dart' show IntExtensions, SnowflakeEntityListExtensions, StringExtensions; export 'src/utils/permissions.dart' show PermissionsUtils; export 'src/utils/utils.dart' show ListSafeFirstWhere; diff --git a/lib/src/client_options.dart b/lib/src/client_options.dart index e0862364a..7dbc2ae26 100644 --- a/lib/src/client_options.dart +++ b/lib/src/client_options.dart @@ -141,6 +141,12 @@ class GatewayIntents { /// Includes events: `GUILD_SCHEDULED_EVENT_CREATE`, `GUILD_SCHEDULED_EVENT_DELETE`, `GUILD_SCHEDULED_EVENT_UPDATE`, `GUILD_SCHEDULED_EVENT_USER_ADD`, `GUILD_SCHEDULED_EVENT_USER_REMOVE` static const int guildScheduledEvents = 1 << 16; + /// Includes events: `AUTO_MODERATION_RULE_CREATE`, `AUTO_MODERATION_RULE_UPDATE`, `AUTO_MODERATION_RULE_DELETE` + static const int autoModerationConfiguration = 1 << 20; + + /// Includes events: `AUTO_MODERATION_ACTION_EXECUTION` + static const int autoModerationExecution = 1 << 21; + /// All unprivileged intents static const int allUnprivileged = guilds | guildBans | @@ -155,7 +161,9 @@ class GatewayIntents { directMessages | directMessageReactions | directMessageTyping | - guildScheduledEvents; + guildScheduledEvents | + autoModerationConfiguration | + autoModerationExecution; /// All privileged intents static const int allPrivileged = guildMembers | guildPresences | messageContent; diff --git a/lib/src/core/audit_logs/audit_log.dart b/lib/src/core/audit_logs/audit_log.dart index 5877d1144..21d412d8c 100644 --- a/lib/src/core/audit_logs/audit_log.dart +++ b/lib/src/core/audit_logs/audit_log.dart @@ -1,20 +1,32 @@ -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; import 'package:nyxx/src/core/audit_logs/audit_log_entry.dart'; +import 'package:nyxx/src/core/channel/thread_channel.dart'; +import 'package:nyxx/src/core/guild/auto_moderation.dart'; +import 'package:nyxx/src/core/guild/scheduled_event.dart'; import 'package:nyxx/src/core/guild/webhook.dart'; +import 'package:nyxx/src/core/snowflake.dart'; import 'package:nyxx/src/core/user/user.dart'; +import 'package:nyxx/src/nyxx.dart'; import 'package:nyxx/src/typedefs.dart'; abstract class IAuditLog { - /// List of webhooks found in the audit log + /// Map of webhooks found in the audit log. late final Map webhooks; - /// List of users found in the audit log + /// Map of users found in the audit log. late final Map users; - /// List of audit log entries + /// Map of audit log entries. late final Map entries; + /// Map of auto moderation rules referenced in the audit log. + late final Map autoModerationRules; + + /// Map of guild scheduled events referenced in the audit log. + late final Map events; + + /// Map of threads referenced in the audit log. + late final Map threads; + /// Filters audit log by [users] Iterable filter(bool Function(IAuditLogEntry) test); } @@ -23,38 +35,65 @@ abstract class IAuditLog { /// /// [Look here for more](https://discordapp.com/developers/docs/resources/audit-log) class AuditLog implements IAuditLog { - /// List of webhooks found in the audit log + /// Map of webhooks found in the audit log @override late final Map webhooks; - /// List of users found in the audit log + /// Map of users found in the audit log @override late final Map users; - /// List of audit log entries + /// Map of audit log entries @override late final Map entries; + /// Map of auto moderation rules referenced in the audit log + @override + late final Map autoModerationRules; + + /// Map of guild scheduled events referenced in the audit log + @override + late final Map events; + + /// Map of threads referenced in the audit log. + @override + late final Map threads; + /// Creates an instance of [AuditLog] AuditLog(RawApiMap raw, INyxx client) { webhooks = {}; users = {}; entries = {}; + autoModerationRules = {}; + events = {}; + threads = {}; raw["webhooks"].forEach((o) { - webhooks[Snowflake(o["id"] as String)] = Webhook(o as RawApiMap, client); + webhooks[Snowflake(o["id"])] = Webhook(o as RawApiMap, client); }); raw["users"].forEach((o) { - users[Snowflake(o["id"] as String)] = User(client, o as RawApiMap); + users[Snowflake(o["id"])] = User(client, o as RawApiMap); }); raw["audit_log_entries"].forEach((o) { - entries[Snowflake(o["id"] as String)] = AuditLogEntry(o as RawApiMap, client); + entries[Snowflake(o["id"])] = AuditLogEntry(o as RawApiMap, client); + }); + + raw['auto_moderation_rules'].forEach((o) { + autoModerationRules[Snowflake(o['id'])] = AutoModerationRule(o as RawApiMap, client); + }); + + raw['guild_scheduled_events'].forEach((o) { + events[Snowflake(o['id'])] = GuildEvent(o as RawApiMap, client); + }); + + raw['threads'].forEach((o) { + threads[Snowflake(o['id'])] = ThreadChannel(client, o as RawApiMap); }); } - /// Filters audit log by [users] + /// Filters audit log by [entries] @override Iterable filter(bool Function(IAuditLogEntry) test) => entries.values.where(test); } diff --git a/lib/src/core/audit_logs/audit_log_entry.dart b/lib/src/core/audit_logs/audit_log_entry.dart index 6b0b3c77f..4e52fe00d 100644 --- a/lib/src/core/audit_logs/audit_log_entry.dart +++ b/lib/src/core/audit_logs/audit_log_entry.dart @@ -10,7 +10,7 @@ import 'package:nyxx/src/core/audit_logs/audit_log_options.dart'; abstract class IAuditLogEntry implements SnowflakeEntity { /// Id of the affected entity (webhook, user, role, etc.) - String get targetId; + String? get targetId; /// Changes made to the target_id List get changes; @@ -34,7 +34,7 @@ abstract class IAuditLogEntry implements SnowflakeEntity { class AuditLogEntry extends SnowflakeEntity implements IAuditLogEntry { /// Id of the affected entity (webhook, user, role, etc.) @override - late final String targetId; + late final String? targetId; /// Changes made to the target_id @override @@ -58,7 +58,7 @@ class AuditLogEntry extends SnowflakeEntity implements IAuditLogEntry { /// Creates an instance of [AuditLogEntry] AuditLogEntry(RawApiMap raw, INyxx client) : super(Snowflake(raw["id"] as String)) { - targetId = raw["target_id"] as String; + targetId = raw["target_id"] as String?; changes = [ if (raw["changes"] != null) @@ -79,6 +79,7 @@ class AuditLogEntry extends SnowflakeEntity implements IAuditLogEntry { } class AuditLogEntryType extends IEnum { + static const AuditLogEntryType unknown = AuditLogEntryType._create(0); static const AuditLogEntryType guildUpdate = AuditLogEntryType._create(1); static const AuditLogEntryType channelCreate = AuditLogEntryType._create(10); static const AuditLogEntryType channelUpdate = AuditLogEntryType._create(11); @@ -126,6 +127,11 @@ class AuditLogEntryType extends IEnum { static const AuditLogEntryType threadCreate = AuditLogEntryType._create(110); static const AuditLogEntryType threadUpdate = AuditLogEntryType._create(111); static const AuditLogEntryType threadDelete = AuditLogEntryType._create(112); + static const AuditLogEntryType applicationCommandPermissionUpdate = AuditLogEntryType._create(121); + static const AuditLogEntryType autoModerationRuleCreate = AuditLogEntryType._create(140); + static const AuditLogEntryType autoModerationRuleUpdate = AuditLogEntryType._create(141); + static const AuditLogEntryType autoModerationRuleDelete = AuditLogEntryType._create(142); + static const AuditLogEntryType autoModerationBlockMessage = AuditLogEntryType._create(143); const AuditLogEntryType._create(int value) : super(value); diff --git a/lib/src/core/audit_logs/audit_log_options.dart b/lib/src/core/audit_logs/audit_log_options.dart index 699ef5629..334a27494 100644 --- a/lib/src/core/audit_logs/audit_log_options.dart +++ b/lib/src/core/audit_logs/audit_log_options.dart @@ -1,10 +1,10 @@ import 'package:nyxx/nyxx.dart'; -/// Additionnal info for certain action types +/// Additional info for certain action types /// /// [Look here for more](https://discord.com/developers/docs/resources/audit-log#audit-log-entry-object-audit-log-events) abstract class IAuditLogOptions { - /// The channel in which the entites were targeted. + /// The channel in which the entities were targeted. Snowflake? get channelId; /// The number of entities targeted. @@ -33,7 +33,7 @@ abstract class IAuditLogOptions { } class AuditLogOptions implements IAuditLogOptions { - /// The channel in which the entites were targeted. + /// The channel in which the entities were targeted. @override late final Snowflake? channelId; diff --git a/lib/src/core/guild/auto_moderation.dart b/lib/src/core/guild/auto_moderation.dart new file mode 100644 index 000000000..b1703c9c9 --- /dev/null +++ b/lib/src/core/guild/auto_moderation.dart @@ -0,0 +1,263 @@ +import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; +import 'package:nyxx/src/core/guild/guild.dart'; +import 'package:nyxx/src/core/guild/role.dart'; +import 'package:nyxx/src/core/snowflake.dart'; +import 'package:nyxx/src/core/snowflake_entity.dart'; +import 'package:nyxx/src/core/user/member.dart'; +import 'package:nyxx/src/internal/cache/cacheable.dart'; +import 'package:nyxx/src/internal/exceptions/unknown_enum_value.dart'; +import 'package:nyxx/src/nyxx.dart'; +import 'package:nyxx/src/typedefs.dart'; + +abstract class IAutoModerationRule implements SnowflakeEntity { + /// The guild's id this rule is applied to. + Cacheable get guild; + + /// The name of this rule. + String get name; + + /// The user which first created this rule. + Cacheable get creator; + + /// The rule event type. + EventTypes get eventType; + + /// The rule trigger type. + TriggerTypes get triggerType; + + /// The trigger metadata. + ITriggerMetadata get triggerMetadata; + + /// The actions which will execute when the rule is triggered. + List? get actions; + + /// Whether this rule is enabled. + bool get enabled; + + /// The role that should not be affected by the rule (Maximum of 20). + Iterable> get ignoredRoles; + + /// The channel that should not be affected by the rule (Maximum of 50). + Iterable> get ignoredChannels; +} + +enum EventTypes { + /// When a member sends or edits a message in a guild. + messageSend(1); + + final int value; + const EventTypes(this.value); + + static EventTypes _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); + + @override + String toString() => 'EventTypes[$value]'; +} + +enum TriggerTypes { + /// Check if content contains words from a user defined list of keywords. + keyword(1), + + /// Check if content contains any harmful links. + harmfulLink(2), + + /// Check if content represents generic spam. + spam(3), + + /// Check if content contains words from internal pre-defined wordsets. + keywordPreset(4), + + /// Check if content contains more mentions than allowed. + mentionSpam(5); + + final int value; + const TriggerTypes(this.value); + + // Not private because used in guild events + static TriggerTypes fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); + + @override + String toString() => 'TriggerTypes[$value]'; +} + +enum KeywordPresets { + /// Words that may be considered forms of swearing or cursing. + profanity(1), + + /// Words that refer to sexually explicit behavior or activity. + sexualContent(2), + + /// Personal insults or words that may be considered hate speech. + slurs(3); + + final int value; + const KeywordPresets(this.value); + + static KeywordPresets _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); + + @override + String toString() => 'KeywordPresets[$value]'; +} + +enum ActionTypes { + /// Blocks the content of a message according to the rule. + blockMessage(1), + + /// Logs user's sended message to a specified channel. + sendAlertMessage(2), + + /// Timeout user for a specified duration. + timeout(3); + + final int value; + const ActionTypes(this.value); + + static ActionTypes _fromValue(int value) => values.firstWhere((v) => v.value == value, orElse: () => throw UnknownEnumValueError(value)); + + @override + String toString() => 'ActionTypes[$value]'; +} + +abstract class ITriggerMetadata { + /// Substrings which will be searched for in the content. + /// The associated trigger type is [TriggerTypes.keyword]. + List? get keywordsFilter; + + /// The internally pre-defined wordsets which will be searched for in content. + /// The associated trigger type is [TriggerTypes.keywordPreset]. + Iterable? get keywordPresets; + + /// Substrings which will be exempt from triggering the preset trigger type. + /// The associated trigger type is [TriggerTypes.keywordPreset]. + List? get allowList; + + /// 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; +} + +abstract class IActionStructure { + /// The type of the action. + ActionTypes get actionType; + + /// Additional metadata needed during execution for this specific action type. + IActionMetadata? get actionMetadata; +} + +abstract class IActionMetadata { + /// The channel if to which user content should be logged. + /// The associated action type is [ActionTypes.sendAlertMessage]. + Cacheable? get channelId; + + /// The timeout duration - maximum duration is 4 weeks (2,419,200 seconds). + /// It's associated action type is [ActionTypes.timeout]. + Duration? get duration; +} + +class AutoModerationRule extends SnowflakeEntity implements IAutoModerationRule { + @override + late final Cacheable guild; + + @override + late final String name; + + @override + late final Cacheable creator; + + @override + late final EventTypes eventType; + + @override + late final TriggerTypes triggerType; + + @override + late final ITriggerMetadata triggerMetadata; + + @override + late final List? actions; + + @override + late final bool enabled; + + @override + late final Iterable> ignoredRoles; + + @override + late final Iterable> ignoredChannels; + + AutoModerationRule(RawApiMap rawData, INyxx client) : super(Snowflake(rawData['id'])) { + guild = GuildCacheable(client, Snowflake(rawData['guild_id'])); + name = rawData['name'] as String; + creator = MemberCacheable(client, Snowflake(rawData['creator_id']), guild); + eventType = EventTypes._fromValue(rawData['event_type'] as int); + triggerType = TriggerTypes.fromValue(rawData['trigger_type'] as int); + triggerMetadata = TriggerMetadata(rawData['trigger_metadata'] as RawApiMap); + actions = (rawData['actions'] as RawApiList?)?.map((a) => ActionStructure(a as RawApiMap, client)).toList(); + enabled = rawData['enabled'] as bool; + ignoredRoles = (rawData['exempt_roles'] as RawApiList).map((r) => RoleCacheable(client, Snowflake(r), guild)); + ignoredChannels = (rawData['exempt_channels'] as RawApiList).map((r) => ChannelCacheable(client, Snowflake(r))); + } + + @override + String toString() => 'IAutoModerationRule(id: $id, guildId: ${guild.id}, name: $name, triggerMetadata: $triggerMetadata)'; +} + +class TriggerMetadata implements ITriggerMetadata { + // Maybe return null instead of empty list + @override + late final Iterable? keywordPresets; + + @override + late final List? keywordsFilter; + + @override + late final List? allowList; + + @override + late final int? mentionLimit; + + /// 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?; + } + + @override + String toString() => 'ITriggerMetadata(keywordPresets: $keywordPresets, keywordFilter: $keywordsFilter, allowList: $allowList, mentionLimit: $mentionLimit)'; +} + +class ActionStructure implements IActionStructure { + @override + late final IActionMetadata? actionMetadata; + + @override + late final ActionTypes actionType; + + /// Creates an instance of [ActionStructure]. + ActionStructure(RawApiMap data, INyxx client) { + actionType = ActionTypes._fromValue(data['type'] as int); + if (data['metadata'] != null && (data['metadata'] as RawApiMap).isNotEmpty) { + actionMetadata = ActionMetadata(data['metadata'] as RawApiMap, client); + } else { + actionMetadata = null; + } + } +} + +class ActionMetadata implements IActionMetadata { + @override + late final Cacheable? channelId; + + @override + late final Duration? duration; + + /// Creates an instance of [ActionMetadata]. + ActionMetadata(RawApiMap data, INyxx client) { + channelId = data['channel_id'] != null ? ChannelCacheable(client, Snowflake(data['channel_id'])) : null; + duration = data['duration_seconds'] != null ? Duration(seconds: data['duration_seconds'] as int) : null; + } +} diff --git a/lib/src/core/guild/guild.dart b/lib/src/core/guild/guild.dart index b2b6e5dac..8591f7c31 100644 --- a/lib/src/core/guild/guild.dart +++ b/lib/src/core/guild/guild.dart @@ -3,6 +3,7 @@ import 'package:nyxx/src/core/audit_logs/audit_log_entry.dart'; import 'package:nyxx/src/core/channel/guild/guild_channel.dart'; import 'package:nyxx/src/core/channel/invite.dart'; import 'package:nyxx/src/core/channel/text_channel.dart'; +import 'package:nyxx/src/core/guild/auto_moderation.dart'; import 'package:nyxx/src/core/guild/guild_feature.dart'; import 'package:nyxx/src/core/guild/guild_nsfw_level.dart'; import 'package:nyxx/src/core/guild/guild_preview.dart'; @@ -32,6 +33,7 @@ import 'package:nyxx/src/core/voice/voice_state.dart'; import 'package:nyxx/src/internal/cache/cacheable.dart'; import 'package:nyxx/src/typedefs.dart'; import 'package:nyxx/src/utils/builders/attachment_builder.dart'; +import 'package:nyxx/src/utils/builders/auto_moderation_builder.dart'; import 'package:nyxx/src/utils/builders/channel_builder.dart'; import 'package:nyxx/src/utils/builders/guild_builder.dart'; import 'package:nyxx/src/utils/builders/guild_event_builder.dart'; @@ -186,6 +188,10 @@ abstract class IGuild implements SnowflakeEntity { /// The approximate amount of presences in the guild. int? get approxPresenceCount; + /// The cached auto moderation rules in the guild. + /// An empty map is returned if none where fetched or added by events. + ICache get autoModerationRules; + /// The guild's icon, represented as URL. /// If guild doesn't have icon it returns null. String? iconURL({String format = "webp", int size = 128}); @@ -334,6 +340,21 @@ abstract class IGuild implements SnowflakeEntity { /// Fetches the welcome screen of this guild if it's a community guild. Future fetchWelcomeScreen(); + + /// Fetches the auto moderation rules. + Stream fetchAutoModerationRules(); + + /// Fetches a sole moderation rule. + Future fetchAutoModerationRule(Snowflake ruleId); + + /// Creates an auto moderation rule. + Future createAutoModerationRule(AutoModerationRuleBuilder builder, {String? reason}); + + /// Edits an auto moderation rule. + Future editAutoModerationRule(AutoModerationRuleBuilder builder, Snowflake ruleId, {String? reason}); + + /// Deletes an auto moderation rule. + Future deleteAutoModerationRule(Snowflake ruleId, {String? reason}); } class Guild extends SnowflakeEntity implements IGuild { @@ -562,6 +583,9 @@ class Guild extends SnowflakeEntity implements IGuild { ); } + @override + late final ICache autoModerationRules; + /// Creates an instance of [Guild] Guild(this.client, RawApiMap raw, [bool guildCreate = false]) : super(Snowflake(raw["id"])) { name = raw["name"] as String; @@ -686,6 +710,8 @@ class Guild extends SnowflakeEntity implements IGuild { if (raw["stage_instances"] != null) for (final rawInstance in raw["stage_instances"]) StageChannelInstance(client, rawInstance as RawApiMap) ]; + + autoModerationRules = SnowflakeCache(); } /// The guild's icon, represented as URL. @@ -898,4 +924,21 @@ class Guild extends SnowflakeEntity implements IGuild { @override Future fetchWelcomeScreen() => client.httpEndpoints.fetchGuildWelcomeScreen(id); + + @override + Stream fetchAutoModerationRules() => client.httpEndpoints.fetchAutoModerationRules(id); + + @override + Future fetchAutoModerationRule(Snowflake ruleId) => client.httpEndpoints.fetchAutoModerationRule(id, ruleId); + + @override + Future createAutoModerationRule(AutoModerationRuleBuilder builder, {String? reason}) => + client.httpEndpoints.createAutoModerationRule(id, builder, auditReason: reason); + + @override + Future editAutoModerationRule(AutoModerationRuleBuilder builder, Snowflake ruleId, {String? reason}) => + client.httpEndpoints.editAutoModerationRule(id, ruleId, builder, auditReason: reason); + + @override + Future deleteAutoModerationRule(Snowflake ruleId, {String? reason}) => client.httpEndpoints.deleteAutoModerationRule(id, ruleId, auditReason: reason); } diff --git a/lib/src/core/guild/guild_feature.dart b/lib/src/core/guild/guild_feature.dart index 5162e3768..62e5bfd34 100644 --- a/lib/src/core/guild/guild_feature.dart +++ b/lib/src/core/guild/guild_feature.dart @@ -53,6 +53,9 @@ class GuildFeature extends IEnum { /// Guild is a Student Hub static const GuildFeature studentHub = GuildFeature._create("HUB"); + /// Guild has Auto Moderation + static const GuildFeature autoModeration = GuildFeature._create("AUTO_MODERATION"); + /// Creates instance of [GuildFeature] from [value]. GuildFeature.from(String? value) : super(value ?? ""); const GuildFeature._create(String? value) : super(value ?? ""); diff --git a/lib/src/events/guild_events.dart b/lib/src/events/guild_events.dart index 35703104a..15ac0a2ba 100644 --- a/lib/src/events/guild_events.dart +++ b/lib/src/events/guild_events.dart @@ -1,13 +1,18 @@ -import 'package:nyxx/src/core/guild/scheduled_event.dart'; -import 'package:nyxx/src/nyxx.dart'; -import 'package:nyxx/src/core/snowflake.dart'; +import 'package:nyxx/src/core/channel/guild/text_guild_channel.dart'; +import 'package:nyxx/src/core/channel/text_channel.dart'; +import 'package:nyxx/src/core/guild/auto_moderation.dart'; import 'package:nyxx/src/core/guild/guild.dart'; import 'package:nyxx/src/core/guild/role.dart'; +import 'package:nyxx/src/core/guild/scheduled_event.dart'; import 'package:nyxx/src/core/message/guild_emoji.dart'; +import 'package:nyxx/src/core/message/message.dart'; import 'package:nyxx/src/core/message/sticker.dart'; +import 'package:nyxx/src/core/snowflake.dart'; +import 'package:nyxx/src/core/snowflake_entity.dart'; import 'package:nyxx/src/core/user/member.dart'; import 'package:nyxx/src/core/user/user.dart'; import 'package:nyxx/src/internal/cache/cacheable.dart'; +import 'package:nyxx/src/nyxx.dart'; import 'package:nyxx/src/typedefs.dart'; abstract class IGuildCreateEvent { @@ -474,3 +479,167 @@ class GuildEventDeleteEvent implements IGuildEventDeleteEvent { event = GuildEvent(raw['d'] as RawApiMap, client); } } + +abstract class IAutoModerationRuleCreateEvent { + /// The created rule. + IAutoModerationRule get rule; +} + +class AutoModerationRuleCreateEvent implements IAutoModerationRuleCreateEvent { + @override + late final IAutoModerationRule rule; + + AutoModerationRuleCreateEvent(RawApiMap raw, INyxx client) { + rule = AutoModerationRule(raw['d'] as RawApiMap, client); + client.guilds[rule.guild.id]?.autoModerationRules[rule.id] = rule; + } +} + +abstract class IAutoModerationRuleUpdateEvent { + /// The updated rule. + IAutoModerationRule get rule; + + /// The old rule before it's update. + IAutoModerationRule? get oldRule; +} + +class AutoModerationRuleUpdateEvent implements IAutoModerationRuleUpdateEvent { + @override + late final IAutoModerationRule rule; + + @override + late final IAutoModerationRule? oldRule; + + AutoModerationRuleUpdateEvent(RawApiMap raw, INyxx client) { + rule = AutoModerationRule(raw['d'] as RawApiMap, client); + final guild = client.guilds[rule.guild.id]; + oldRule = guild?.autoModerationRules[rule.id]; + if (guild == null) { + return; + } + guild.autoModerationRules.update(rule.id, (_) => rule, ifAbsent: () => rule); + } +} + +abstract class IAutoModerationRuleDeleteEvent { + /// The deleted rule. + IAutoModerationRule get rule; +} + +class AutoModerationRuleDeleteEvent implements IAutoModerationRuleDeleteEvent { + @override + late final IAutoModerationRule rule; + + AutoModerationRuleDeleteEvent(RawApiMap raw, INyxx client) { + rule = AutoModerationRule(raw['d'] as RawApiMap, client); + client.guilds[rule.guild.id]?.autoModerationRules.remove(rule.id); + } +} + +/// When a webhook is created, updated or deleted. +abstract class IWebhookUpdateEvent { + /// The channel that points this webhook to. + Cacheable get channel; + + /// The guild this webhook was created/updated/deleted. + Cacheable get guild; +} + +class WebhookUpdateEvent implements IWebhookUpdateEvent { + @override + late final Cacheable channel; + + @override + late final Cacheable guild; + + WebhookUpdateEvent(RawApiMap raw, INyxx client) { + channel = ChannelCacheable(client, Snowflake(raw['d']['channel_id'] as String)); + guild = GuildCacheable(client, Snowflake(raw['d']['guild_id'] as String)); + } +} + +abstract class IAutoModerationActionExecutionEvent implements SnowflakeEntity { + /// The guild where this action was executed. + Cacheable get guild; + + /// The action which was executed. + ActionStructure get action; + + /// The trigger type of rule which was triggered. + TriggerTypes get triggerType; + + /// The member which generated the content which triggered the rule. + Cacheable get member; + + /// The channel in which user content was posted. + Cacheable? get channel; + + /// The message of any user message which content belongs to. + /// + /// This will not be present if the message was blocked by automod or the content was not part of the message. + Cacheable? get message; + + /// The message id of any system auto moderation messages posted as a result of this action. + /// + /// `null` if the [action.actionType] is not [ActionTypes.sendAlertMessage]. + Snowflake? get alertSystemMessage; + + /// The member generated text content. + /// + /// An empty string if you have not the message content privilegied intent. + String get content; + + /// The word or phrase configured in the rule that triggered the rule + String? get matchedKeyword; + + /// The substring in content that triggered the rule. + /// + /// An empty string if you have not the message content privilegied intent. + String get matchedContent; +} + +class AutoModeratioActionExecutionEvent extends SnowflakeEntity implements IAutoModerationActionExecutionEvent { + @override + late final Cacheable guild; + + @override + late final ActionStructure action; + + @override + late final TriggerTypes triggerType; + + @override + late final Cacheable member; + + @override + late final Cacheable? channel; + + @override + late final Cacheable? message; + + @override + late final Snowflake? alertSystemMessage; + + @override + late final String content; + + @override + late final String? matchedKeyword; + + @override + late final String matchedContent; + + AutoModeratioActionExecutionEvent(RawApiMap rawPayload, INyxx client) : super(Snowflake(rawPayload['d']['rule_id'])) { + final raw = rawPayload['d']; + guild = GuildCacheable(client, Snowflake(raw['guild_id'] as String)); + action = ActionStructure(raw['action'] as RawApiMap, client); + triggerType = TriggerTypes.fromValue(raw['rule_trigger_type'] as int); + member = MemberCacheable(client, Snowflake(raw['user_id'] as String), guild); + channel = raw['channel_id'] != null ? ChannelCacheable(client, Snowflake(raw['channel_id'])) : null; + message = raw['message_id'] != null && channel != null ? MessageCacheable(client, Snowflake(raw['message_id']), channel!) : null; + alertSystemMessage = raw['alert_system_message_id'] != null ? Snowflake(raw['alert_system_message_id']) : null; + content = raw['content'] as String; + matchedKeyword = raw['matched_keyword'] != null ? raw['matched_keyword'] as String : null; + matchedContent = raw['matched_content'] as String; + } +} diff --git a/lib/src/internal/event_controller.dart b/lib/src/internal/event_controller.dart index 638ee02ac..6470d68f6 100644 --- a/lib/src/internal/event_controller.dart +++ b/lib/src/internal/event_controller.dart @@ -211,6 +211,21 @@ abstract class IWebsocketEventController implements IRestEventController { /// Emitted when stage channel instance is deleted Stream get onGuildEventDelete; + + /// Emitted when an auto moderation rule is created + Stream get onAutoModerationRuleCreate; + + /// Emitted when an auto moderation rule is updated + Stream get onAutoModerationRuleUpdate; + + /// Emitted when an auto moderation rule is deleted + Stream get onAutoModerationRuleDelete; + + /// Emitted when a webhook is created, updated or deleted. + Stream get onWebhookUpdate; + + /// Emitted when an auto moderation rule was triggered and an action was executed (e.g. a message was blocked). + Stream get onAutoModerationActionExecution; } /// A controller for all events. @@ -344,6 +359,16 @@ class WebsocketEventController extends RestEventController implements IWebsocket /// Guild scheduled event was updated late final StreamController onGuildEventUpdateController; + late final StreamController onAutoModerationRuleCreateController; + + late final StreamController onAutoModerationRuleUpdateController; + + late final StreamController onAutoModerationRuleDeleteController; + + late final StreamController onWebhookUpdateController; + + late final StreamController onAutoModerationActionExecutionController; + /// Emitted when a shard is disconnected from the websocket. @override late final Stream onDisconnect; @@ -526,6 +551,21 @@ class WebsocketEventController extends RestEventController implements IWebsocket @override late final Stream onGuildEventUpdate; + @override + late final Stream onAutoModerationRuleCreate; + + @override + late final Stream onAutoModerationRuleUpdate; + + @override + late final Stream onAutoModerationRuleDelete; + + @override + late final Stream onWebhookUpdate; + + @override + late final Stream onAutoModerationActionExecution; + final INyxxWebsocket _client; /// Makes a new `EventController`. @@ -658,6 +698,21 @@ class WebsocketEventController extends RestEventController implements IWebsocket onGuildEventDeleteController = StreamController.broadcast(); onGuildEventDelete = onGuildEventDeleteController.stream; + + onAutoModerationRuleCreateController = StreamController.broadcast(); + onAutoModerationRuleCreate = onAutoModerationRuleCreateController.stream; + + onAutoModerationRuleUpdateController = StreamController.broadcast(); + onAutoModerationRuleUpdate = onAutoModerationRuleUpdateController.stream; + + onAutoModerationRuleDeleteController = StreamController.broadcast(); + onAutoModerationRuleDelete = onAutoModerationRuleDeleteController.stream; + + onWebhookUpdateController = StreamController.broadcast(); + onWebhookUpdate = onWebhookUpdateController.stream; + + onAutoModerationActionExecutionController = StreamController.broadcast(); + onAutoModerationActionExecution = onAutoModerationActionExecutionController.stream; } @override @@ -712,5 +767,13 @@ class WebsocketEventController extends RestEventController implements IWebsocket await onGuildEventCreateController.close(); await onGuildEventUpdateController.close(); await onGuildEventDeleteController.close(); + + await onAutoModerationRuleCreateController.close(); + await onAutoModerationRuleDeleteController.close(); + await onAutoModerationRuleUpdateController.close(); + + await onWebhookUpdateController.close(); + + await onAutoModerationActionExecutionController.close(); } } diff --git a/lib/src/internal/exceptions/unknown_enum_value.dart b/lib/src/internal/exceptions/unknown_enum_value.dart new file mode 100644 index 000000000..5f1a9ede4 --- /dev/null +++ b/lib/src/internal/exceptions/unknown_enum_value.dart @@ -0,0 +1,10 @@ +/// Thrown when a parsing method of an enum failed. +class UnknownEnumValueError extends Error { + final Object value; + + /// Creates a new instance of [UnknownEnumValueError]. + UnknownEnumValueError(this.value); + + @override + String toString() => 'Unknown enum value: ${Error.safeToString(value)}'; +} diff --git a/lib/src/internal/http/http_route.dart b/lib/src/internal/http/http_route.dart index 131f8a1ed..1a0d98c83 100644 --- a/lib/src/internal/http/http_route.dart +++ b/lib/src/internal/http/http_route.dart @@ -76,6 +76,9 @@ abstract class IHttpRoute { /// Adds the [`scheduled-events`](https://discord.com/developers/docs/resources/guild-scheduled-event#get-guild-scheduled-event) part to this [IHttpRoute]. void scheduledEvents({String? id}); + /// Adds the [`rules`](https://discord.com/developers/docs/resources/auto-moderation#get-auto-moderation-rule) part to this [IHttpRoute]. + void rules({String? id}); + /// Adds the [`prune`](https://discord.com/developers/docs/resources/guild#get-guild-prune-count) part to this [IHttpRoute]. void prune(); @@ -132,6 +135,9 @@ abstract class IHttpRoute { /// Adds the [`welcome-screen`](https://discord.com/developers/docs/resources/guild#get-guild-welcome-screen) part to this [IHttpRoute]. void welcomeScreen(); + + /// Adds the [`auto-moderation`](https://discord.com/developers/docs/resources/auto-moderation#list-auto-moderation-rules-for-guild) part to this [IHttpRoute]. + void autoModeration(); } class HttpRoute implements IHttpRoute { @@ -222,6 +228,9 @@ class HttpRoute implements IHttpRoute { @override void scheduledEvents({String? id}) => add(HttpRoutePart("scheduled-events", [if (id != null) HttpRouteParam(id)])); + @override + void rules({String? id}) => add(HttpRoutePart('rules', [if (id != null) HttpRouteParam(id)])); + @override void prune() => add(HttpRoutePart("prune")); @@ -278,4 +287,7 @@ class HttpRoute implements IHttpRoute { @override void welcomeScreen() => add(HttpRoutePart('welcome-screen')); + + @override + void autoModeration() => add(HttpRoutePart('auto-moderation')); } diff --git a/lib/src/internal/http_endpoints.dart b/lib/src/internal/http_endpoints.dart index b1ae88761..85a253e69 100644 --- a/lib/src/internal/http_endpoints.dart +++ b/lib/src/internal/http_endpoints.dart @@ -1,4 +1,5 @@ import 'package:nyxx/src/core/audit_logs/audit_log_entry.dart'; +import 'package:nyxx/src/core/guild/auto_moderation.dart'; import 'package:nyxx/src/core/guild/scheduled_event.dart'; import 'package:nyxx/src/internal/http/http_route.dart'; import 'package:nyxx/src/nyxx.dart'; @@ -33,6 +34,7 @@ import 'package:nyxx/src/internal/http/http_response.dart'; import 'package:nyxx/src/internal/response_wrapper/thread_list_result_wrapper.dart'; import 'package:nyxx/src/typedefs.dart'; import 'package:nyxx/src/utils/builders/attachment_builder.dart'; +import 'package:nyxx/src/utils/builders/auto_moderation_builder.dart'; import 'package:nyxx/src/utils/builders/channel_builder.dart'; import 'package:nyxx/src/utils/builders/forum_thread_builder.dart'; import 'package:nyxx/src/utils/builders/guild_builder.dart'; @@ -426,6 +428,16 @@ abstract class IHttpEndpoints { {int limit = 100, bool withMember = false, Snowflake? before, Snowflake? after}); Future startForumThread(Snowflake channelId, ForumThreadBuilder builder); + + Stream fetchAutoModerationRules(Snowflake guildId); + + Future fetchAutoModerationRule(Snowflake guildId, Snowflake ruleId); + + Future createAutoModerationRule(Snowflake guildId, AutoModerationRuleBuilder builder, {String? auditReason}); + + Future editAutoModerationRule(Snowflake guildId, Snowflake ruleId, AutoModerationRuleBuilder builder, {String? auditReason}); + + Future deleteAutoModerationRule(Snowflake guildId, Snowflake ruleId, {String? auditReason}); } class HttpEndpoints implements IHttpEndpoints { @@ -2137,4 +2149,118 @@ class HttpEndpoints implements IHttpEndpoints { yield GuildEvent(rawGuildEvent as RawApiMap, client); } } + + @override + Stream fetchAutoModerationRules(Snowflake guildId) async* { + final response = await httpHandler.execute( + BasicRequest( + HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(), + ), + ); + + if (response is IHttpResponseError) { + yield* Stream.error(response); + } + + for (final rawRule in (response as IHttpResponseSuccess).jsonBody as RawApiList) { + final rule = AutoModerationRule(rawRule as RawApiMap, client); + client.guilds[guildId]?.autoModerationRules[rule.id] = rule; + yield rule; + } + } + + @override + Future fetchAutoModerationRule(Snowflake guildId, Snowflake ruleId) async { + final response = await httpHandler.execute( + BasicRequest( + HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(id: ruleId.toString()), + ), + ); + + if (response is IHttpResponseError) { + return Future.error(response); + } + + final rule = AutoModerationRule((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + + client.guilds[guildId]?.autoModerationRules[ruleId] = rule; + + return rule; + } + + @override + Future createAutoModerationRule(Snowflake guildId, AutoModerationRuleBuilder builder, {String? auditReason}) async { + final response = await httpHandler.execute( + BasicRequest( + HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(), + method: 'POST', + auditLog: auditReason, + body: builder.build(), + ), + ); + + if (response is IHttpResponseError) { + return Future.error(response); + } + + final rule = AutoModerationRule((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + + client.guilds[guildId]?.autoModerationRules[rule.id] = rule; + + return rule; + } + + @override + Future editAutoModerationRule(Snowflake guildId, Snowflake ruleId, AutoModerationRuleBuilder builder, {String? auditReason}) async { + final response = await httpHandler.execute( + BasicRequest( + HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(id: ruleId.toString()), + body: builder.build(), + auditLog: auditReason, + method: 'PATCH', + ), + ); + + if (response is IHttpResponseError) { + return Future.error(response); + } + + final rule = AutoModerationRule((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + + client.guilds[guildId]?.autoModerationRules[ruleId] = rule; + + return rule; + } + + @override + Future deleteAutoModerationRule(Snowflake guildId, Snowflake ruleId, {String? auditReason}) async { + final response = await httpHandler.execute( + BasicRequest( + HttpRoute() + ..guilds(id: guildId.toString()) + ..autoModeration() + ..rules(id: ruleId.toString()), + auditLog: auditReason, + method: 'DELETE', + ), + ); + + if (response is IHttpResponseError) { + return Future.error(response); + } + + client.guilds[guildId]?.autoModerationRules.remove(ruleId); + } } diff --git a/lib/src/internal/shard/shard.dart b/lib/src/internal/shard/shard.dart index 1eb90bc34..df7d887e6 100644 --- a/lib/src/internal/shard/shard.dart +++ b/lib/src/internal/shard/shard.dart @@ -532,6 +532,26 @@ class Shard implements IShard { eventController.onGuildEventDeleteController.add(GuildEventDeleteEvent(rawPayload, manager.connectionManager.client)); break; + case 'WEBHOOKS_UPDATE': + eventController.onWebhookUpdateController.add(WebhookUpdateEvent(rawPayload, manager.connectionManager.client)); + break; + + case 'AUTO_MODERATION_RULE_CREATE': + eventController.onAutoModerationRuleCreateController.add(AutoModerationRuleCreateEvent(rawPayload, manager.connectionManager.client)); + break; + + case 'AUTO_MODERATION_RULE_UPDATE': + eventController.onAutoModerationRuleUpdateController.add(AutoModerationRuleUpdateEvent(rawPayload, manager.connectionManager.client)); + break; + + case 'AUTO_MODERATION_RULE_DELETE': + eventController.onAutoModerationRuleDeleteController.add(AutoModerationRuleDeleteEvent(rawPayload, manager.connectionManager.client)); + break; + + case 'AUTO_MODERATION_ACTION_EXECUTION': + eventController.onAutoModerationActionExecutionController.add(AutoModeratioActionExecutionEvent(rawPayload, manager.connectionManager.client)); + break; + default: if (manager.connectionManager.client.options.dispatchRawShardEvent) { manager.onRawEventController.add(RawEvent(this, rawPayload)); diff --git a/lib/src/utils/builders/auto_moderation_builder.dart b/lib/src/utils/builders/auto_moderation_builder.dart new file mode 100644 index 000000000..55ccd6423 --- /dev/null +++ b/lib/src/utils/builders/auto_moderation_builder.dart @@ -0,0 +1,107 @@ +import 'package:nyxx/src/core/guild/auto_moderation.dart'; +import 'package:nyxx/src/core/snowflake.dart'; +import 'package:nyxx/src/typedefs.dart'; +import 'package:nyxx/src/utils/builders/builder.dart'; + +class AutoModerationRuleBuilder implements Builder { + /// The name of the rule. + String name; + + /// The event type of the rule. + EventTypes eventType; + + /// The trigger type. + TriggerTypes triggerType; + + /// The actions which will execute when the rule is triggered + List actions; + + /// The trigger metadata. + /// + /// Can be omitted as it is based on [triggerType]. + TriggerMetadataBuilder? triggerMetadata; + + /// Whether this rule is enabled. `false` by default. + bool? enabled; + + /// The role IDs that should not be affected by the rule. (Maximum of 20). + List? ignoredRoles; + + /// The channel ids that should not be affected by the rule. (Maximum of 50). + List? ignoredChannels; + + AutoModerationRuleBuilder(this.name, {required this.eventType, required this.triggerType, required this.actions}); + + @override + RawApiMap build() => { + 'name': name, + 'event_type': eventType.value, + 'trigger_type': triggerType.value, + 'actions': actions.map((a) => a.build()).toList(), + if (triggerMetadata != null) 'trigger_metadata': triggerMetadata!.build(), + if (enabled != null) 'enabled': enabled, + if (ignoredRoles != null) 'exempt_roles': ignoredRoles!.map((s) => s.toString()).toList(), + if (ignoredChannels != null) 'exempt_channels': ignoredChannels!.map((s) => s.toString()).toList(), + }; +} + +class ActionStructureBuilder implements Builder { + /// The type for this action. + ActionTypes actionType; + + /// Additional metadata needed during execution for this specific action type + ActionMetadataBuilder metadata; + + ActionStructureBuilder(this.actionType, this.metadata); + + @override + RawApiMap build() => { + 'type': actionType.value, + 'metadata': metadata.build(), + }; +} + +class ActionMetadataBuilder implements Builder { + /// Channel to which messages content should be logged. + /// + /// (Works only when it's action type is [ActionTypes.sendAlertMessage]). + Snowflake? channelId; + + /// The duration of the timeout. + /// This cannot exceed 4 weeks! + /// + /// (Works only when it's action type is [ActionTypes.timeout]). + Duration? duration; + + ActionMetadataBuilder({this.channelId, this.duration}); + + @override + RawApiMap build() => { + if (channelId != null) 'channel_id': channelId.toString(), + if (duration != null) 'duration_seconds': duration!.inSeconds, + }; +} + +class TriggerMetadataBuilder implements Builder { + /// Substrings which will be searched for in content. + List? keywordFilter; + + /// The internally pre-defined wordsets which will be searched for in content. + List? presets; + + /// Substrings which will be exempt from triggering the preset trigger type. + List? allowList; + + /// The total number of mentions (either role and user) allowed per message. + /// (Maximum of 50) + // Pr still not merged + int? mentionLimit; + + @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, + }; +} diff --git a/lib/src/utils/builders/forum_thread_builder.dart b/lib/src/utils/builders/forum_thread_builder.dart index 922b2509d..3faa046de 100644 --- a/lib/src/utils/builders/forum_thread_builder.dart +++ b/lib/src/utils/builders/forum_thread_builder.dart @@ -4,7 +4,7 @@ import 'package:nyxx/src/utils/builders/message_builder.dart'; import 'package:nyxx/src/utils/builders/thread_builder.dart'; class ForumThreadBuilder implements Builder { - /// The name fo the thread + /// The name of the thread String name; /// First message in thread diff --git a/pubspec.yaml b/pubspec.yaml index 33132aa9a..47e20fcd7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ documentation: https://nyxx.l7ssha.xyz issue_tracker: https://github.com/nyxx-discord/nyxx/issues environment: - sdk: '>=2.13.0 <3.0.0' + sdk: '>=2.17.0 <3.0.0' dependencies: http: ^0.13.3 diff --git a/test/unit/builders_test.dart b/test/unit/builders_test.dart index 81dffc911..de3a5268b 100644 --- a/test/unit/builders_test.dart +++ b/test/unit/builders_test.dart @@ -1,9 +1,11 @@ import 'package:nyxx/src/core/channel/text_channel.dart'; +import 'package:nyxx/src/core/guild/auto_moderation.dart'; import 'package:nyxx/src/core/guild/status.dart'; import 'package:nyxx/src/core/permissions/permissions.dart'; import 'package:nyxx/src/core/snowflake.dart'; import 'package:nyxx/src/core/user/presence.dart'; import 'package:nyxx/src/internal/cache/cacheable.dart'; +import 'package:nyxx/src/utils/builders/auto_moderation_builder.dart'; import 'package:nyxx/src/utils/builders/channel_builder.dart'; import 'package:nyxx/src/utils/builders/embed_builder.dart'; import 'package:nyxx/src/utils/builders/forum_thread_builder.dart'; @@ -233,4 +235,51 @@ main() { })); }); }); + + test('AutoModerationRuleBuilder', () { + final rb = AutoModerationRuleBuilder( + 'Super cool rule', + eventType: EventTypes.messageSend, + triggerType: TriggerTypes.keyword, + actions: [ + ActionStructureBuilder( + ActionTypes.timeout, + ActionMetadataBuilder( + duration: Duration( + days: 1, + ), + ), + ), + ], + ) + ..triggerMetadata = (TriggerMetadataBuilder() + ..keywordFilter = ['hey', '*looks', 'wildcards!!*'] + ..allowList = ['wow*', 'im', 'allowed!'] + ..presets = [KeywordPresets.slurs]) + ..ignoredChannels = [Snowflake.zero()] + ..ignoredRoles = [Snowflake.zero()] + ..enabled = true; + + expect(rb.build(), { + 'name': 'Super cool rule', + 'event_type': 1, + 'trigger_type': 1, + 'actions': [ + { + 'type': 3, + 'metadata': { + 'duration_seconds': 86400, + } + } + ], + 'trigger_metadata': { + 'keyword_filter': ['hey', '*looks', 'wildcards!!*'], + 'presets': [3], + 'allow_list': ['wow*', 'im', 'allowed!'] + }, + 'enabled': true, + 'exempt_channels': ['0'], + 'exempt_roles': ['0'] + }); + }); } From f1d3ce6af29b56e31918aa312acfb052926ed08e Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sat, 20 Aug 2022 14:18:15 +0200 Subject: [PATCH 02/13] Release 4.1.0-dev.0 --- CHANGELOG.md | 7 ++++++- lib/src/internal/constants.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1637ecc4d..4a8da4d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ +## 4.1.0-dev.0 +__20.08.2022__ + +- feature: feature: auto moderation (#353) + ## 4.0.0 -___29.07.2022__ +__29.07.2022__ - breaking: Fix typo in `IHttpResponseSucess` - breaking: Remove `threeDayThreadArchive` and `sevenDayThreadArchive` guild features diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index 48efff17e..77a80f79b 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.0.0"; + static const String version = "4.1.0-dev.0"; /// Url to Nyxx repo static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; diff --git a/pubspec.yaml b/pubspec.yaml index 47e20fcd7..b107abcb6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx -version: 4.0.0 +version: 4.1.0-dev.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 From e3a35ffebb7fbe9df08234bb3616aad57289bcf6 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sun, 28 Aug 2022 10:57:56 +0200 Subject: [PATCH 03/13] feature: Event to notify change of connection status; Fixes #363 (#364) * feature: Event to notify change of connection status; Fixes #363 * Remove unneeded imports. Fixup http_bucket.dart unused fields --- lib/src/internal/http/http_bucket.dart | 5 ++--- lib/src/internal/shard/shard.dart | 11 ++++++++++- lib/src/plugin/plugin.dart | 2 ++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/src/internal/http/http_bucket.dart b/lib/src/internal/http/http_bucket.dart index 17ac7fe79..a13267185 100644 --- a/lib/src/internal/http/http_bucket.dart +++ b/lib/src/internal/http/http_bucket.dart @@ -9,7 +9,6 @@ class HttpBucket { static const String xRateLimitReset = "x-ratelimit-reset"; static const String xRateLimitResetAfter = "x-ratelimit-reset-after"; - int _limit; int _remaining; DateTime _reset; Duration _resetAfter; @@ -25,7 +24,7 @@ class HttpBucket { String get id => _bucketId; - HttpBucket(this._limit, this._remaining, this._reset, this._resetAfter, this._bucketId); + HttpBucket(this._remaining, this._reset, this._resetAfter, this._bucketId); static HttpBucket? fromResponseSafe(http.StreamedResponse response) { final limit = getLimitFromHeaders(response.headers); @@ -38,7 +37,7 @@ class HttpBucket { return null; } - return HttpBucket(limit, remaining, reset, resetAfter, bucketId); + return HttpBucket(remaining, reset, resetAfter, bucketId); } static String? getBucketIdFromHeaders(Map headers) => headers[xRateLimitBucket]; diff --git a/lib/src/internal/shard/shard.dart b/lib/src/internal/shard/shard.dart index df7d887e6..3032dec68 100644 --- a/lib/src/internal/shard/shard.dart +++ b/lib/src/internal/shard/shard.dart @@ -113,6 +113,7 @@ class Shard implements IShard { @override bool get connected => _connected; + // ignore: unused_field late final Isolate _shardIsolate; // Reference to isolate late final Stream _receiveStream; // Broadcast stream on which data from isolate is received late final ReceivePort _receivePort; // Port on which data from isolate is received @@ -233,11 +234,19 @@ class Shard implements IShard { return; } + final closeReason = data['errorReason'] as String?; + final socketError = data['error'] as String?; + + for (final plugin in manager.connectionManager.client.plugins) { + plugin.onConnectionChange(manager.connectionManager.client, manager.logger, closeCode, closeReason, socketError); + } + _connected = false; _heartbeatTimer.cancel(); - manager.logger.severe("Shard $id disconnected. Error: [${data['error']}] Error code: [${data['errorCode']}] | Error message: [${data['errorReason']}]"); manager.onDisconnectController.add(this); + manager.logger.severe("Shard $id disconnected. Error: [$socketError] Error code: [$closeCode] | Error message: [$closeReason]"); + switch (closeCode) { case 4004: case 4010: diff --git a/lib/src/plugin/plugin.dart b/lib/src/plugin/plugin.dart index e298d3082..4fa622554 100644 --- a/lib/src/plugin/plugin.dart +++ b/lib/src/plugin/plugin.dart @@ -8,4 +8,6 @@ abstract class BasePlugin { FutureOr onBotStart(INyxx nyxx, Logger logger) async {} FutureOr onBotStop(INyxx nyxx, Logger logger) async {} + + FutureOr onConnectionChange(INyxx nyxx, Logger logger, int? closeCode, String? closeReason, String? socketError) async {} } From 6ea3bcf242baf97ae54c4016987b04261c24218a Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Sun, 28 Aug 2022 10:59:12 +0200 Subject: [PATCH 04/13] Refactor internal shard system (#368) * Refactor internal shard implementation * Add documentation * Deprecate guildSubscriptions, now handled by intents. Co-authored-by: Szymon Uglis --- lib/src/client_options.dart | 1 + lib/src/internal/shard/message.dart | 79 ++ lib/src/internal/shard/shard.dart | 920 ++++++++++++---------- lib/src/internal/shard/shard_handler.dart | 214 +++-- 4 files changed, 728 insertions(+), 486 deletions(-) create mode 100644 lib/src/internal/shard/message.dart diff --git a/lib/src/client_options.dart b/lib/src/client_options.dart index 7dbc2ae26..15732e2aa 100644 --- a/lib/src/client_options.dart +++ b/lib/src/client_options.dart @@ -49,6 +49,7 @@ class ClientOptions { bool compressedGatewayPayloads; /// Enables dispatching of guild subscription events (presence and typing events) + @Deprecated('No longer has any effect, use intents instead.') bool guildSubscriptions; /// Initial bot presence diff --git a/lib/src/internal/shard/message.dart b/lib/src/internal/shard/message.dart new file mode 100644 index 000000000..6f4ada577 --- /dev/null +++ b/lib/src/internal/shard/message.dart @@ -0,0 +1,79 @@ +class ShardMessage { + final T type; + final dynamic data; + + const ShardMessage(this.type, {this.data}); +} + +enum ShardToManager { + /// Sent when the shard receives a payload from Discord. + /// + /// Data payload includes: + /// - `data`: dynamic + /// The uncompressed and JSON-decoded data received + received, + + /// Sent when the shard encounters an error. + /// + /// Errors reported include state errors and errors that occurred during the initial connection. + /// Any error causing the connection to close will send [disconnected] with a non-normal close code. + /// + /// Data payload includes: + /// - `message`: String + /// The message associated with the error + /// - `shouldReconnect`: bool? + /// Whether the shard should attempt to reconnect following this error + error, + + /// Sent when the shard is connected + connected, + + /// Send when the shard is disconnected + /// + /// Data payload includes: + /// - `closeCode`: int + /// The code associated with the websocket disconnection + /// - `closeReason`: String? + /// The message associated with the disconnection + disconnected, + + /// Send when the shard is disposed + disposed, +} + +enum ManagerToShard { + /// Sent when the shard should send a payload to Discord + /// + /// Data payload includes: + /// - `opCode`: int + /// The opcode of the payload to send + /// - `d`: dynamic + /// The data to send in the payload + send, + + /// Sent to request the shard to connect and start dispatching events back to the manager + /// + /// Data payload includes: + /// - `gatewayHost`: String + /// The URL on which to connect to the gateway + /// - `useCompression`: bool + /// Whether to use compression on this gateway connection + connect, + + /// Sent to request the shard to reconnect, closing the current connection if any. + /// + /// This will disconnect with a non-normal disconnect code. + /// + /// Data payload includes: + /// - `gatewayHost`: String + /// The URL on which to connect to the gateway + /// - `useCompression`: bool + /// Whether to use compression on this gateway connection + reconnect, + + /// Send to request the shard to disconnect, with a normal disconnection code. + disconnect, + + /// Sent to dispose the shard + dispose, +} diff --git a/lib/src/internal/shard/shard.dart b/lib/src/internal/shard/shard.dart index 3032dec68..e57a39a65 100644 --- a/lib/src/internal/shard/shard.dart +++ b/lib/src/internal/shard/shard.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'dart:io'; import 'dart:isolate'; +import 'dart:math'; -import 'package:nyxx/src/core/snowflake.dart'; import 'package:nyxx/src/core/guild/client_user.dart'; +import 'package:nyxx/src/core/snowflake.dart'; import 'package:nyxx/src/events/channel_events.dart'; -import 'package:nyxx/src/events/disconnect_event.dart'; import 'package:nyxx/src/events/guild_events.dart'; import 'package:nyxx/src/events/invite_events.dart'; import 'package:nyxx/src/events/member_chunk_event.dart'; @@ -24,13 +24,18 @@ import 'package:nyxx/src/internal/event_controller.dart'; import 'package:nyxx/src/internal/exceptions/invalid_shard_exception.dart'; import 'package:nyxx/src/internal/exceptions/unrecoverable_nyxx_error.dart'; import 'package:nyxx/src/internal/interfaces/disposable.dart'; -import 'package:nyxx/src/internal/shard/shard_manager.dart'; +import 'package:nyxx/src/internal/shard/message.dart'; import 'package:nyxx/src/internal/shard/shard_handler.dart'; +import 'package:nyxx/src/internal/shard/shard_manager.dart'; import 'package:nyxx/src/typedefs.dart'; import 'package:nyxx/src/utils/builders/presence_builder.dart'; +/// A connection to the [Discord Gateway](https://discord.com/developers/docs/topics/gateway). +/// +/// One client can have multiple shards. Each shard moves the decompressing and decoding steps of the Gateway connection to their own thread which can lessen +/// the load on the main thread for large bots which might receive thousands of events per minute. abstract class IShard implements Disposable { - /// Id of shard + /// The ID of this shard. int get id; /// Reference to [ShardManager] @@ -75,515 +80,624 @@ abstract class IShard implements Disposable { {String? query, Iterable? userIds, int limit = 0, bool presences = false, String? nonce}); } -/// Shard is single connection to discord gateway. Since bots can grow big, handling thousand of guild on same websocket connections would be very hand. -/// Traffic can be split into different connections which can be run on different processes or even different machines. class Shard implements IShard { - /// Id of shard @override final int id; - /// Reference to [ShardManager] @override final ShardManager manager; - /// Emitted when the shard encounters a connection error @override - late final Stream onDisconnect = manager.onDisconnect.where((event) => event.id == id); + final List guilds = []; - /// Emitted when shard receives member chunk. @override - late final Stream onMemberChunk = manager.onMemberChunk.where((event) => event.shardId == id); + Duration gatewayLatency = Duration.zero; - /// Emitted when the shard resumed its connection @override - late final Stream onResume = manager.onResume.where((event) => event.id == id); + bool connected = false; - /// List of handled guild ids - @override - final List guilds = []; + /// The receive port on which events from the isolate will be received. + final ReceivePort receivePort = ReceivePort(); - /// Gets the latest gateway latency. + /// A stream on which events from the shard will be received. /// - /// To calculate the gateway latency, nyxx measures the time it takes for Discord to answer the gateway - /// heartbeat packet with a heartbeat ack packet. Note this value is updated each time gateway responses to ack. - @override - Duration get gatewayLatency => _gatewayLatency; + /// Should only be accessed after [readyFuture] has completed. + late final Stream> shardMessages; - /// Returns true if shard is connected to websocket - @override - bool get connected => _connected; - - // ignore: unused_field - late final Isolate _shardIsolate; // Reference to isolate - late final Stream _receiveStream; // Broadcast stream on which data from isolate is received - late final ReceivePort _receivePort; // Port on which data from isolate is received - late final SendPort _isolateSendPort; // Port on which data can be sent to isolate - late SendPort _sendPort; // Send Port for isolate - String? _sessionId; // Id of gateway session - int _sequence = 0; // Event sequence - late Timer _heartbeatTimer; // Heartbeat time - bool _connected = false; // Connection status - bool _resume = false; // Resume status - Duration _gatewayLatency = const Duration(); // latency of discord - late DateTime _lastHeartbeatSent; // Datetime when last heartbeat was sent - bool _heartbeatAckReceived = true; // True if last heartbeat was acked - - WebsocketEventController get eventController => manager.connectionManager.client.eventsWs as WebsocketEventController; - - /// Creates an instance of [Shard] - Shard(this.id, this.manager, String gatewayUrl) { - manager.logger.finer("Starting shard with id: $id; url: $gatewayUrl"); - - _receivePort = ReceivePort(); - _receiveStream = _receivePort.asBroadcastStream(); - _isolateSendPort = _receivePort.sendPort; - - Isolate.spawn(shardHandler, _isolateSendPort).then((isolate) async { - _shardIsolate = isolate; - _sendPort = await _receiveStream.first as SendPort; - - _sendPort.send({"cmd": "INIT", "gatewayUrl": gatewayUrl, "compression": manager.connectionManager.client.options.compressedGatewayPayloads}); - _receiveStream.listen(_handle); - }); + /// The send port on which messages to the isolate should be added. + /// + /// Should only be accessed after [readyFuture] has completed. + late final SendPort sendPort; + + /// A future that completes once the handler isolate is running. + late final Future readyFuture; + + /// The URL to which this shard should make the initial connection. + final String gatewayHost; + + 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, + }), + )); + + // Start handling messages from the shard. + readyFuture.then((_) => shardMessages.listen(handle)); } - /// Sends WS data. - @override - void send(int opCode, dynamic d) { - final rawData = { - "cmd": "SEND", - "data": {"op": opCode, "d": d} - }; - manager.logger.finest("Sending to shard isolate on shard [$id]: [$rawData]"); - _sendPort.send(rawData); + /// Spawns the handler isolate and initializes [sendPort] and [shardMessages]; + Future spawn() async { + manager.logger.fine("Starting shard $id..."); + + await Isolate.spawn(shardHandler, receivePort.sendPort, debugName: "Shard Runner #$id"); + + final rawShardMessages = receivePort.asBroadcastStream(); + + sendPort = await rawShardMessages.first as SendPort; + shardMessages = rawShardMessages.cast>(); + + manager.logger.fine("Shard $id ready"); } - /// Updates clients voice state for [Guild] with given [guildId] - @override - void changeVoiceState(Snowflake? guildId, Snowflake? channelId, {bool selfMute = false, bool selfDeafen = false}) { - send(OPCodes.voiceStateUpdate, - {"guild_id": guildId.toString(), "channel_id": channelId?.toString(), "self_mute": selfMute, "self_deaf": selfDeafen}); + /// Sends a message to the shard isolate. + void execute(ShardMessage message) async { + await readyFuture; + + manager.logger.finest("Sending message ${message.type}${message.data == null ? '' : ' with data ${message.data}'} to shard $id"); + sendPort.send(message); } - /// Allows to set presence for current shard. - @override - void setPresence(PresenceBuilder presenceBuilder) { - send(OPCodes.statusUpdate, presenceBuilder.build()); + /// 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() { + 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, + }, + )); } - /// Syncs all guilds - @override - void guildSync() => send(OPCodes.guildSync, guilds.map((e) => e.toString())); + void resetConnectionProperties() { + connected = false; + heartbeatTimer?.cancel(); + lastHeartbeatSent = null; + } - /// Allows to request members objects from gateway - /// [guild] can be either Snowflake or Iterable - @override - void requestMembers(/* Snowflake|Iterable */ dynamic guild, - {String? query, Iterable? userIds, int limit = 0, bool presences = false, String? nonce}) { - if (query != null && userIds != null) { - throw ArgumentError("Both `query` and userIds cannot be specified."); + /// Handler for incoming messages from the isolate. + /// + /// These messages are not raw messages from the websocket! Those are handled in [handlePayload]. + Future handle(ShardMessage message) async { + manager.logger.finest('Got message ${message.type}${message.data == null ? '' : ' with data ${message.data}'} on shard $id'); + + switch (message.type) { + case ShardToManager.received: + return handlePayload(message.data); + case ShardToManager.connected: + return handleConnected(); + case ShardToManager.disconnected: + return handleDisconnect(message.data['closeCode'] as int, message.data['closeReason'] as String?); + case ShardToManager.error: + return handleError(message.data['message'] as String, message.data['shouldReconnect'] as bool?); + case ShardToManager.disposed: + manager.logger.info("Shard $id disposed."); + break; } + } - dynamic guildPayload; + /// A handler for when the shard connection disconnects. + Future handleDisconnect(int closeCode, String? closeReason) async { + resetConnectionProperties(); - if (guild is Snowflake) { - if (!guilds.contains(guild)) { - throw InvalidShardException("Cannot request member for guild on wrong shard"); - } + manager.onDisconnectController.add(this); - guildPayload = [guild.toString()]; - } else if (guild is Iterable) { - if (!guilds.any((element) => guild.contains(element))) { - throw InvalidShardException("Cannot request member for guild on wrong shard"); - } + // https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes + const warnings = { + 4000: 'Unknown error', + 4001: 'Unknown opcode', + 4002: 'Decode error (invalid payload)', + 4003: 'Payload sent before authentication', + 4005: 'Already authenticated', + 4007: 'Invalid seq', + 4008: 'Rate limited', + 4009: 'Session timed out', + }; + + const errors = { + 4004: 'Invalid authentication', + 4010: 'Invalid shard', + 4011: 'Sharding required', + 4012: 'Invalid API version', + 4013: 'Invalid intents', + 4014: 'Disallowed intent', + }; - guildPayload = guild.map((e) => e.toString()).toList(); + if (errors.containsKey(closeCode)) { + throw UnrecoverableNyxxError('Shard $id disconnected: ${errors[closeCode]!}'); + } else if (warnings.containsKey(closeCode)) { + manager.logger.warning('Shard $id disconnected: ${warnings[closeCode]!}'); + + // Try to resume on all warnings apart from invalid sequence, which prevents us from resuming + shouldResume = closeCode != 4007; } else { - throw ArgumentError("Guild has to be either Snowflake or Iterable"); + // If we get an unknown error, try to resume. + shouldResume = true; } - final payload = { - "guild_id": guildPayload, - "limit": limit, - "presences": presences, - if (query != null) "query": query, - if (userIds != null) "user_ids": userIds.map((e) => e.toString()).toList(), - if (nonce != null) "nonce": nonce - }; + // Reconnect by default + reconnect(); + } - send(OPCodes.requestGuildMember, payload); + /// A handler for when the shard establishes a connection to the Gateway. + Future handleConnected() async { + manager.logger.info('Shard $id connected to gateway'); + connected = true; + + // There was no previous heartbeat on a new connection. + // Setting this to true prevents us from reconnecting upon receiving the first heartbeat due to the previous heartbeat "not being acked". + lastHeartbeatAcked = true; } - void _heartbeat() { - send(OPCodes.heartbeat, _sequence == 0 ? null : _sequence); - _lastHeartbeatSent = DateTime.now(); + /// 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 { + manager.logger.shout('Shard $id reported error: $message'); - if (!_heartbeatAckReceived) { - manager.logger.warning("Not received previous heartbeat ack on shard: [$id] on sequence: [{$_sequence}]"); - return; + if (shouldReconnect ?? false) { + Future.delayed(const Duration(seconds: 10), reconnect); } - - _heartbeatAckReceived = false; } - void _handleError(dynamic data) { - final closeCode = data["errorCode"] as int?; + /// A handler for when a payload from the gateway is received. + Future handlePayload(dynamic data) async { + final opcode = data['op'] as int; + final d = data['d']; - if (closeCode == null) { - manager.logger.warning("Received null close = client is probably closing. Payload: `$data`"); - return; - } + switch (opcode) { + case OPCodes.dispatch: + dispatch(data['s'] as int, data['t'] as String, data as RawApiMap); + break; - final closeReason = data['errorReason'] as String?; - final socketError = data['error'] as String?; + case OPCodes.heartbeat: + heartbeat(); + break; - for (final plugin in manager.connectionManager.client.plugins) { - plugin.onConnectionChange(manager.connectionManager.client, manager.logger, closeCode, closeReason, socketError); - } + case OPCodes.hello: + hello(d['heartbeat_interval'] as int); + break; - _connected = false; - _heartbeatTimer.cancel(); - manager.onDisconnectController.add(this); + case OPCodes.heartbeatAck: + heartbeatAck(); + break; + + case OPCodes.invalidSession: + // https://discord.com/developers/docs/topics/gateway#invalid-session + shouldResume = d as bool; + + if (shouldResume) { + reconnect(); + } else { + // https://discord.com/developers/docs/topics/gateway#resuming + Future.delayed( + Duration(seconds: 1) + Duration(seconds: 4) * Random().nextDouble(), + identify, + ); + } + break; - manager.logger.severe("Shard $id disconnected. Error: [$socketError] Error code: [$closeCode] | Error message: [$closeReason]"); - - switch (closeCode) { - case 4004: - case 4010: - throw UnrecoverableNyxxError("Gateway error: 4010"); - case 4013: - throw UnrecoverableNyxxError("Gateway error: 4013: Cannot connect to gateway due intent value is invalid. " - "Check https://discordapp.com/developers/docs/topics/gateway#gateway-intents for more info."); - case 4014: - throw UnrecoverableNyxxError("Gateway error: 4014: You sent a disallowed intent for a Gateway Intent. " - "You may have tried to specify an intent that you have not enabled or are not whitelisted for. " - "Check https://discordapp.com/developers/docs/topics/gateway#gateway-intents for more info."); - case 4007: - case 4009: - case 1005: - case 1001: - _reconnect(); - break; - case -1: - _connect(delay: 10); + case OPCodes.reconnect: + shouldResume = true; + reconnect(); break; + default: - _connect(); + manager.logger.severe('Unhandled opcode $opcode'); break; } } - // Connects to gateway - void _connect({int delay = 2}) { - manager.logger.info("Connecting to gateway on shard $id!"); - _resume = false; - Future.delayed(Duration(seconds: delay), () => _sendPort.send({"cmd": "CONNECT"})); - } + /// The timer than handles sending regular heartbeats to the gateway. + Timer? heartbeatTimer; - // Reconnects to gateway - void _reconnect() { - manager.logger.info("Resuming connection to gateway on shard $id!"); - _resume = true; - Future.delayed(const Duration(seconds: 1), () => _sendPort.send({"cmd": "CONNECT"})); - } + /// Whether this shard should attempt to resume upon connecting. + /// + /// Note that a result will only be sent if this shard [shouldResume] and [canResume]. + bool shouldResume = false; - Future _handle(dynamic rawData) async { - manager.logger.finest("Received gateway payload on shard [$id]: [$rawData]"); + /// Whether this shard can resume upon connecting. + bool get canResume => seqNum != null && sessionId != null && resumeGatewayUrl != null; - if (rawData["cmd"] == "CONNECT_ACK") { - manager.logger.info("Shard $id connected to gateway!"); + /// A handler for [OPCodes.hello]. + void hello(int heartbeatInterval) { + // https://discord.com/developers/docs/topics/gateway#heartbeating + final heartbeatDuration = Duration(milliseconds: heartbeatInterval); - return; - } + final jitter = Random().nextDouble(); - if (rawData["cmd"] == "ERROR" || rawData["cmd"] == "DISCONNECTED") { - _handleError(rawData); - return; - } + heartbeatTimer = Timer(heartbeatDuration * jitter, () { + heartbeat(); - if (rawData["jsonData"] == null) { - return; + heartbeatTimer = Timer.periodic(heartbeatDuration, (timer) => heartbeat()); + }); + + if (shouldResume && canResume) { + resume(); + } else { + identify(); } + } - final discordPayload = rawData["jsonData"] as RawApiMap; + /// Sends the identify payload to the gateway. + // https://discord.com/developers/docs/topics/gateway#identifying + void identify() => send(OPCodes.identify, { + "token": manager.connectionManager.client.token, + "properties": { + "os": Platform.operatingSystem, + "browser": "nyxx", + "device": "nyxx", + }, + "large_threshold": manager.connectionManager.client.options.largeThreshold, + "intents": manager.connectionManager.client.intents, + if (manager.connectionManager.client.options.initialPresence != null) "presence": manager.connectionManager.client.options.initialPresence!.build(), + "shard": [id, manager.totalNumShards] + }); + + /// Sends the resume payload to the gateway. + /// + /// Will throw if [canResume] is false. + // https://discord.com/developers/docs/topics/gateway#resuming + void resume() => send(OPCodes.resume, { + "token": manager.connectionManager.client.token, + "session_id": sessionId!, + "seq": seqNum!, + }); + + /// The time at which the last heartbeat was sent. + /// + /// Used for calculating gateway latency. + DateTime? lastHeartbeatSent; - if (discordPayload["s"] != null) { - _sequence = discordPayload["s"] as int; + /// Whether the last heartbeat sent has been acknowledged. + bool lastHeartbeatAcked = true; + + /// A handler for [OPCodes.heartbeat]. + /// + /// Also called regularly in the callback of [heartbeatTimer]. + /// + /// Triggers a reconnect if it is invoked before the last heartbeat was acked. See + /// https://discord.com/developers/docs/topics/gateway#heartbeating-example-gateway-heartbeat-ack. + void heartbeat() { + send(OPCodes.heartbeat, seqNum); + + if (!lastHeartbeatAcked) { + shouldResume = true; + reconnect(); + return; } - await _dispatch(discordPayload); + lastHeartbeatSent = DateTime.now(); + lastHeartbeatAcked = false; } - Future _dispatch(RawApiMap rawPayload) async { - switch (rawPayload["op"] as int) { - case OPCodes.heartbeatAck: - _heartbeatAckReceived = true; - _gatewayLatency = DateTime.now().difference(_lastHeartbeatSent); + /// A handler for [OPCodes.heartbeatAck]. + /// + /// Updates the gateway latency. + void heartbeatAck() { + gatewayLatency = DateTime.now().difference(lastHeartbeatSent!); + lastHeartbeatAcked = true; + } - break; - case OPCodes.hello: - if (_sessionId == null || !_resume) { - final identifyMsg = { - "token": manager.connectionManager.client.token, - "properties": { - "os": Platform.operatingSystem, - "browser": "nyxx", - "device": "nyxx", - }, - "large_threshold": manager.connectionManager.client.options.largeThreshold, - "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.totalNumShards] - }; - - send(OPCodes.identify, identifyMsg); - - manager.onConnectController.add(this); - } else if (_resume) { - send(OPCodes.resume, {"token": manager.connectionManager.client.token, "session_id": _sessionId, "seq": _sequence}); + /// The session ID found in the READY event. + String? sessionId; + + /// The URL to use for resuming gateway connections, found in the READY event. + String? resumeGatewayUrl; + + /// The last known sequence number. + int? seqNum; + + /// A handler for [OPCodes.dispatch]. + void dispatch(int seqNum, String type, RawApiMap data) async { + final eventController = manager.connectionManager.client.eventsWs as WebsocketEventController; + + this.seqNum = seqNum; + + switch (type) { + case "READY": + sessionId = data["d"]["session_id"] as String; + resumeGatewayUrl = data["d"]["resume_gateway_url"] as String; + + manager.connectionManager.client.self = ClientUser(manager.connectionManager.client, data["d"]["user"] as RawApiMap); + + manager.logger.info("Shard $id ready!"); + + if (!shouldResume) { + await manager.connectionManager.propagateReady(); } - Future.delayed(const Duration(milliseconds: 100), () { - _heartbeatTimer = Timer.periodic(Duration(milliseconds: rawPayload["d"]["heartbeat_interval"] as int), (Timer t) => _heartbeat()); - }); break; - case OPCodes.invalidSession: - manager.logger.severe("Invalid session on shard $id. ${(rawPayload["d"] as bool) ? "Resuming..." : "Reconnecting..."}"); - _heartbeatTimer.cancel(); - (manager.connectionManager.client.eventsWs as WebsocketEventController) - .onDisconnectController - .add(DisconnectEvent(this, DisconnectEventReason.invalidSession)); - - if (rawPayload["d"] as bool) { - _reconnect(); - } else { - _connect(); - } + case "RESUMED": + shouldResume = false; + manager.onResumeController.add(this); + break; + case "GUILD_MEMBERS_CHUNK": + manager.onMemberChunkController.add(MemberChunkEvent(data, manager.connectionManager.client, id)); break; - case OPCodes.dispatch: - final dispatchType = rawPayload["t"] as String; + case "MESSAGE_REACTION_REMOVE_ALL": + eventController.onMessageReactionsRemovedController.add(MessageReactionsRemovedEvent(data, manager.connectionManager.client)); + break; - switch (dispatchType) { - case "READY": - _sessionId = rawPayload["d"]["session_id"] as String; - manager.connectionManager.client.self = ClientUser(manager.connectionManager.client, rawPayload["d"]["user"] as RawApiMap); + case "MESSAGE_REACTION_ADD": + eventController.onMessageReactionAddedController.add(MessageReactionAddedEvent(data, manager.connectionManager.client)); + break; - _connected = true; - manager.logger.info("Shard $id ready!"); + case "MESSAGE_REACTION_REMOVE": + eventController.onMessageReactionRemoveController.add(MessageReactionRemovedEvent(data, manager.connectionManager.client)); + break; - if (!_resume) { - await manager.connectionManager.propagateReady(); - } + case "MESSAGE_DELETE_BULK": + eventController.onMessageDeleteBulkController.add(MessageDeleteBulkEvent(data, manager.connectionManager.client)); + break; - break; - case "RESUME": - manager.onResumeController.add(this); - break; + case "CHANNEL_PINS_UPDATE": + eventController.onChannelPinsUpdateController.add(ChannelPinsUpdateEvent(data, manager.connectionManager.client)); + break; - case "GUILD_MEMBERS_CHUNK": - manager.onMemberChunkController.add(MemberChunkEvent(rawPayload, manager.connectionManager.client, id)); - break; + case "VOICE_STATE_UPDATE": + eventController.onVoiceStateUpdateController.add(VoiceStateUpdateEvent(data, manager.connectionManager.client)); + break; + + case "VOICE_SERVER_UPDATE": + eventController.onVoiceServerUpdateController.add(VoiceServerUpdateEvent(data, manager.connectionManager.client)); + break; + + case "GUILD_EMOJIS_UPDATE": + eventController.onGuildEmojisUpdateController.add(GuildEmojisUpdateEvent(data, manager.connectionManager.client)); + break; + + case "MESSAGE_CREATE": + eventController.onMessageReceivedController.add(MessageReceivedEvent(data, manager.connectionManager.client)); + break; + + case "MESSAGE_DELETE": + eventController.onMessageDeleteController.add(MessageDeleteEvent(data, manager.connectionManager.client)); + break; - case "MESSAGE_REACTION_REMOVE_ALL": - eventController.onMessageReactionsRemovedController.add(MessageReactionsRemovedEvent(rawPayload, manager.connectionManager.client)); - break; + case "MESSAGE_UPDATE": + eventController.onMessageUpdateController.add(MessageUpdateEvent(data, manager.connectionManager.client)); + break; - case "MESSAGE_REACTION_ADD": - eventController.onMessageReactionAddedController.add(MessageReactionAddedEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_CREATE": + final event = GuildCreateEvent(data, manager.connectionManager.client); + guilds.add(event.guild.id); + eventController.onGuildCreateController.add(event); + break; - case "MESSAGE_REACTION_REMOVE": - eventController.onMessageReactionRemoveController.add(MessageReactionRemovedEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_UPDATE": + eventController.onGuildUpdateController.add(GuildUpdateEvent(data, manager.connectionManager.client)); + break; - case "MESSAGE_DELETE_BULK": - eventController.onMessageDeleteBulkController.add(MessageDeleteBulkEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_DELETE": + eventController.onGuildDeleteController.add(GuildDeleteEvent(data, manager.connectionManager.client)); + break; - case "CHANNEL_PINS_UPDATE": - eventController.onChannelPinsUpdateController.add(ChannelPinsUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_BAN_ADD": + eventController.onGuildBanAddController.add(GuildBanAddEvent(data, manager.connectionManager.client)); + break; - case "VOICE_STATE_UPDATE": - eventController.onVoiceStateUpdateController.add(VoiceStateUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_BAN_REMOVE": + eventController.onGuildBanRemoveController.add(GuildBanRemoveEvent(data, manager.connectionManager.client)); + break; - case "VOICE_SERVER_UPDATE": - eventController.onVoiceServerUpdateController.add(VoiceServerUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_MEMBER_ADD": + eventController.onGuildMemberAddController.add(GuildMemberAddEvent(data, manager.connectionManager.client)); + break; - case "GUILD_EMOJIS_UPDATE": - eventController.onGuildEmojisUpdateController.add(GuildEmojisUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_MEMBER_REMOVE": + eventController.onGuildMemberRemoveController.add(GuildMemberRemoveEvent(data, manager.connectionManager.client)); + break; - case "MESSAGE_CREATE": - eventController.onMessageReceivedController.add(MessageReceivedEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_MEMBER_UPDATE": + eventController.onGuildMemberUpdateController.add(GuildMemberUpdateEvent(data, manager.connectionManager.client)); + break; - case "MESSAGE_DELETE": - eventController.onMessageDeleteController.add(MessageDeleteEvent(rawPayload, manager.connectionManager.client)); - break; + case "CHANNEL_CREATE": + eventController.onChannelCreateController.add(ChannelCreateEvent(data, manager.connectionManager.client)); + break; - case "MESSAGE_UPDATE": - eventController.onMessageUpdateController.add(MessageUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "CHANNEL_UPDATE": + eventController.onChannelUpdateController.add(ChannelUpdateEvent(data, manager.connectionManager.client)); + break; - case "GUILD_CREATE": - final event = GuildCreateEvent(rawPayload, manager.connectionManager.client); - guilds.add(event.guild.id); - eventController.onGuildCreateController.add(event); - break; + case "CHANNEL_DELETE": + eventController.onChannelDeleteController.add(ChannelDeleteEvent(data, manager.connectionManager.client)); + break; - case "GUILD_UPDATE": - eventController.onGuildUpdateController.add(GuildUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "TYPING_START": + eventController.onTypingController.add(TypingEvent(data, manager.connectionManager.client)); + break; - case "GUILD_DELETE": - eventController.onGuildDeleteController.add(GuildDeleteEvent(rawPayload, manager.connectionManager.client)); - break; + case "PRESENCE_UPDATE": + eventController.onPresenceUpdateController.add(PresenceUpdateEvent(data, manager.connectionManager.client)); + break; - case "GUILD_BAN_ADD": - eventController.onGuildBanAddController.add(GuildBanAddEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_ROLE_CREATE": + eventController.onRoleCreateController.add(RoleCreateEvent(data, manager.connectionManager.client)); + break; - case "GUILD_BAN_REMOVE": - eventController.onGuildBanRemoveController.add(GuildBanRemoveEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_ROLE_UPDATE": + eventController.onRoleUpdateController.add(RoleUpdateEvent(data, manager.connectionManager.client)); + break; - case "GUILD_MEMBER_ADD": - eventController.onGuildMemberAddController.add(GuildMemberAddEvent(rawPayload, manager.connectionManager.client)); - break; + case "GUILD_ROLE_DELETE": + eventController.onRoleDeleteController.add(RoleDeleteEvent(data, manager.connectionManager.client)); + break; - case "GUILD_MEMBER_REMOVE": - eventController.onGuildMemberRemoveController.add(GuildMemberRemoveEvent(rawPayload, manager.connectionManager.client)); - break; + case "USER_UPDATE": + eventController.onUserUpdateController.add(UserUpdateEvent(data, manager.connectionManager.client)); + break; - case "GUILD_MEMBER_UPDATE": - eventController.onGuildMemberUpdateController.add(GuildMemberUpdateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "CHANNEL_CREATE": - eventController.onChannelCreateController.add(ChannelCreateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "CHANNEL_UPDATE": - eventController.onChannelUpdateController.add(ChannelUpdateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "CHANNEL_DELETE": - eventController.onChannelDeleteController.add(ChannelDeleteEvent(rawPayload, manager.connectionManager.client)); - break; - - case "TYPING_START": - eventController.onTypingController.add(TypingEvent(rawPayload, manager.connectionManager.client)); - break; - - case "PRESENCE_UPDATE": - eventController.onPresenceUpdateController.add(PresenceUpdateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "GUILD_ROLE_CREATE": - eventController.onRoleCreateController.add(RoleCreateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "GUILD_ROLE_UPDATE": - eventController.onRoleUpdateController.add(RoleUpdateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "GUILD_ROLE_DELETE": - eventController.onRoleDeleteController.add(RoleDeleteEvent(rawPayload, manager.connectionManager.client)); - break; - - case "USER_UPDATE": - eventController.onUserUpdateController.add(UserUpdateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "INVITE_CREATE": - eventController.onInviteCreatedController.add(InviteCreatedEvent(rawPayload, manager.connectionManager.client)); - break; - - case "INVITE_DELETE": - eventController.onInviteDeleteController.add(InviteDeletedEvent(rawPayload, manager.connectionManager.client)); - break; - - case "MESSAGE_REACTION_REMOVE_EMOJI": - eventController.onMessageReactionRemoveEmojiController.add(MessageReactionRemoveEmojiEvent(rawPayload, manager.connectionManager.client)); - break; - - case "THREAD_CREATE": - eventController.onThreadCreatedController.add(ThreadCreateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "THREAD_MEMBERS_UPDATE": - eventController.onThreadMembersUpdateController.add(ThreadMembersUpdateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "THREAD_DELETE": - eventController.onThreadDeleteController.add(ThreadDeletedEvent(rawPayload, manager.connectionManager.client)); - break; - - case "THREAD_MEMBER_UPDATE": - // Catch unnecessary OP, could be needed in future but unsure. - break; - - case "GUILD_SCHEDULED_EVENT_CREATE": - eventController.onGuildEventCreateController.add(GuildEventCreateEvent(rawPayload, manager.connectionManager.client)); - break; - - case "GUILD_SCHEDULED_EVENT_UPDATE": - eventController.onGuildEventUpdateController.add(GuildEventUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "INVITE_CREATE": + eventController.onInviteCreatedController.add(InviteCreatedEvent(data, manager.connectionManager.client)); + break; - case "GUILD_SCHEDULED_EVENT_DELETE": - eventController.onGuildEventDeleteController.add(GuildEventDeleteEvent(rawPayload, manager.connectionManager.client)); - break; + case "INVITE_DELETE": + eventController.onInviteDeleteController.add(InviteDeletedEvent(data, manager.connectionManager.client)); + break; - case 'WEBHOOKS_UPDATE': - eventController.onWebhookUpdateController.add(WebhookUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "MESSAGE_REACTION_REMOVE_EMOJI": + eventController.onMessageReactionRemoveEmojiController.add(MessageReactionRemoveEmojiEvent(data, manager.connectionManager.client)); + break; - case 'AUTO_MODERATION_RULE_CREATE': - eventController.onAutoModerationRuleCreateController.add(AutoModerationRuleCreateEvent(rawPayload, manager.connectionManager.client)); - break; + case "THREAD_CREATE": + eventController.onThreadCreatedController.add(ThreadCreateEvent(data, manager.connectionManager.client)); + break; - case 'AUTO_MODERATION_RULE_UPDATE': - eventController.onAutoModerationRuleUpdateController.add(AutoModerationRuleUpdateEvent(rawPayload, manager.connectionManager.client)); - break; + case "THREAD_MEMBERS_UPDATE": + eventController.onThreadMembersUpdateController.add(ThreadMembersUpdateEvent(data, manager.connectionManager.client)); + break; - case 'AUTO_MODERATION_RULE_DELETE': - eventController.onAutoModerationRuleDeleteController.add(AutoModerationRuleDeleteEvent(rawPayload, manager.connectionManager.client)); - break; + case "THREAD_DELETE": + eventController.onThreadDeleteController.add(ThreadDeletedEvent(data, manager.connectionManager.client)); + break; - case 'AUTO_MODERATION_ACTION_EXECUTION': - eventController.onAutoModerationActionExecutionController.add(AutoModeratioActionExecutionEvent(rawPayload, manager.connectionManager.client)); - break; + case "THREAD_MEMBER_UPDATE": + // Catch unnecessary OP, could be needed in future but unsure. + break; - default: - if (manager.connectionManager.client.options.dispatchRawShardEvent) { - manager.onRawEventController.add(RawEvent(this, rawPayload)); - } else { - manager.logger.info("UNKNOWN OPCODE: $rawPayload"); - } - } + case "GUILD_SCHEDULED_EVENT_CREATE": + eventController.onGuildEventCreateController.add(GuildEventCreateEvent(data, manager.connectionManager.client)); + break; + + case "GUILD_SCHEDULED_EVENT_UPDATE": + eventController.onGuildEventUpdateController.add(GuildEventUpdateEvent(data, manager.connectionManager.client)); + break; + + case "GUILD_SCHEDULED_EVENT_DELETE": + eventController.onGuildEventDeleteController.add(GuildEventDeleteEvent(data, manager.connectionManager.client)); + break; + + case 'WEBHOOKS_UPDATE': + eventController.onWebhookUpdateController.add(WebhookUpdateEvent(data, manager.connectionManager.client)); + break; + + case 'AUTO_MODERATION_RULE_CREATE': + eventController.onAutoModerationRuleCreateController.add(AutoModerationRuleCreateEvent(data, manager.connectionManager.client)); break; + + case 'AUTO_MODERATION_RULE_UPDATE': + eventController.onAutoModerationRuleUpdateController.add(AutoModerationRuleUpdateEvent(data, manager.connectionManager.client)); + break; + + case 'AUTO_MODERATION_RULE_DELETE': + eventController.onAutoModerationRuleDeleteController.add(AutoModerationRuleDeleteEvent(data, manager.connectionManager.client)); + break; + + case 'AUTO_MODERATION_ACTION_EXECUTION': + eventController.onAutoModerationActionExecutionController.add(AutoModeratioActionExecutionEvent(data, manager.connectionManager.client)); + break; + + default: + if (manager.connectionManager.client.options.dispatchRawShardEvent) { + manager.onRawEventController.add(RawEvent(this, data)); + } else { + manager.logger.info("UNKNOWN OPCODE: $data"); + } } } @override - Future dispose() async { - manager.logger.info("Started disposing shard $id..."); + void send(int opCode, dynamic d) => execute(ShardMessage( + ManagerToShard.send, + data: { + "op": opCode, + "d": d, + }, + )); + + @override + Stream get onDisconnect => manager.onDisconnect.where((event) => event.id == id); + + @override + Stream get onMemberChunk => manager.onMemberChunk.where((event) => event.shardId == id); + + @override + Stream get onResume => manager.onResume.where((event) => event.id == id); + + @override + void guildSync() => send(OPCodes.guildSync, guilds.map((e) => e.toString())); - _sendPort.send({"cmd": "KILL"}); + @override + void setPresence(PresenceBuilder presenceBuilder) => send(OPCodes.statusUpdate, presenceBuilder.build()); + + @override + void changeVoiceState(Snowflake? guildId, Snowflake? channelId, {bool selfMute = false, bool selfDeafen = false}) => send( + OPCodes.voiceStateUpdate, + { + "guild_id": guildId?.toString(), + "channel_id": channelId?.toString(), + "self_mute": selfMute, + "self_deaf": selfDeafen, + }, + ); + + @override + void requestMembers( + /* Snowflake|Iterable */ dynamic guild, { + String? query, + Iterable? userIds, + int limit = 0, + bool presences = false, + String? nonce, + }) { + if (query != null && userIds != null) { + throw ArgumentError("At most one of `query` and `userIds` may be set"); + } + + if (guild is! Iterable) { + if (guild is! Snowflake) { + throw ArgumentError("`guild` must be a Snowflake or an Iterable"); + } + + guild = [guild]; + } + + for (final id in guild) { + if (!guilds.contains(id)) { + throw InvalidShardException("Cannot request guild $id on shard ${this.id} because it does not exist on this shard"); + } + } - final killFuture = _receiveStream.firstWhere((element) => (element as RawApiMap)["cmd"] == "TERMINATE_OK"); - await killFuture; + final payload = { + "guild_id": guild.map((id) => id.toString()).toList(), + "limit": limit, + "presences": presences, + if (query != null) "query": query, + if (userIds != null) "user_ids": userIds.map((e) => e.toString()).toList(), + if (nonce != null) "nonce": nonce + }; + + send(OPCodes.requestGuildMember, payload); + } + + @override + Future dispose() async { + execute(ShardMessage(ManagerToShard.dispose)); - _receivePort.close(); - _heartbeatTimer.cancel(); + // Wait for shard to dispose correctly + await shardMessages.firstWhere((message) => message.type == ShardToManager.disposed); - manager.logger.info("Shard $id disposed."); + receivePort.close(); } } diff --git a/lib/src/internal/shard/shard_handler.dart b/lib/src/internal/shard/shard_handler.dart index a49eedf74..0513e589f 100644 --- a/lib/src/internal/shard/shard_handler.dart +++ b/lib/src/internal/shard/shard_handler.dart @@ -4,119 +4,167 @@ import 'dart:io'; import 'dart:isolate'; import 'package:nyxx/src/internal/constants.dart'; -import 'package:nyxx/src/typedefs.dart'; +import 'package:nyxx/src/internal/interfaces/disposable.dart'; +import 'package:nyxx/src/internal/shard/message.dart'; -// Decodes zlib compresses string into string json -RawApiMap _decodeBytes(dynamic rawPayload, RawZLibFilter decoder) { - if (rawPayload is String) { - return jsonDecode(rawPayload) as RawApiMap; - } - - decoder.process(rawPayload as List, 0, rawPayload.length); - - final buffer = []; - for (List? decoded = []; decoded != null; decoded = decoder.processed()) { - buffer.addAll(decoded); - } +void shardHandler(SendPort sendPort) { + final runner = ShardRunner(sendPort); - // that shouldn't really happen - if (buffer.isEmpty) { - return {}; - } - - final rawStr = utf8.decode(buffer); - return jsonDecode(rawStr) as RawApiMap; + sendPort.send(runner.receivePort.sendPort); } -/* -Protocol used to communicate with shard isolate. - First message delivered to shardHandler will be init message with gateway uri +class ShardRunner implements Disposable { + /// The receive port on which messages from the manager will be received. + final ReceivePort receivePort = ReceivePort(); - * DATA - sent along with data received from websocket - * DISCONNECTED - sent when shard disconnects - * ERROR - sent when error occurs + /// A stream on which messages from the manager will be received. + Stream> get managerMessages => receivePort.cast>(); - * INIT - inits ws connection - * CONNECT - sent when ws connection is established. additional data can contain if reconnected. - * SEND - sent along with data to send via websocket -*/ -Future shardHandler(SendPort shardPort) async { - /// Port init - final receivePort = ReceivePort(); - final receiveStream = receivePort.asBroadcastStream(); + /// The send port on which messages to the manager should be added; + final SendPort sendPort; - final sendPort = receivePort.sendPort; - shardPort.send(sendPort); + /// The current active connection. + WebSocket? connection; - /// Initial data init - final initData = await receiveStream.first; - final gatewayUri = Constants.gatewayUri(initData["gatewayUrl"] as String, initData["compression"] as bool); + /// The subscription to the current active connection. + StreamSubscription? connectionSubscription; - WebSocket? _socket; - StreamSubscription? _socketSubscription; + /// Whether this shard is currently connected to Discord. + bool get connected => connection?.readyState == WebSocket.open; - Future terminate() async { - await _socketSubscription?.cancel(); - await _socket?.close(1000); - receivePort.close(); - shardPort.send({"cmd": "TERMINATE_OK"}); + /// Whether this shard is currently reconnecting. + /// + /// [ShardToManager.disconnected] will not be dispatched if this is true. + bool reconnecting = false; + + ShardRunner(this.sendPort) { + managerMessages.listen(handle); } - // Attempts to connect to ws - Future _connect() async { - try { - _socket = await WebSocket.connect(gatewayUri.toString()); - _socket!.pingInterval = const Duration(seconds: 20); - final zlibDecoder = RawZLibFilter.inflateFilter(); // Create zlib decoder specific to this connection. New connection should get new zlib context + /// Sends a message back to the manager. + void execute(ShardMessage message) => sendPort.send(message); + + /// Handler for uncompressed messages received from Discord. + /// + /// Calls jsonDecode and sends the data back to the manager. + void receive(String payload) => execute(ShardMessage( + ShardToManager.received, + data: jsonDecode(payload), + )); + + /// Handler for incoming messages from the manager. + Future handle(ShardMessage message) async { + switch (message.type) { + case ManagerToShard.send: + return send(message.data); + case ManagerToShard.connect: + return connect(message.data['gatewayHost'] as String, message.data['useCompression'] as bool); + case ManagerToShard.reconnect: + return reconnect(message.data['gatewayHost'] as String, message.data['useCompression'] as bool); + case ManagerToShard.disconnect: + return disconnect(); + case ManagerToShard.dispose: + return dispose(); + } + } - _socket!.done.then((value) { - shardPort.send({"cmd": "DISCONNECTED", "errorCode": _socket!.closeCode, "errorReason": _socket!.closeReason}); - }); + /// Initiate the connection on this shard. + /// + /// Sends [ShardToManager.connected] upon completion. + Future connect(String gatewayHost, bool useCompression) async { + if (connected) { + execute(ShardMessage(ShardToManager.error, data: {'message': 'Shard is already connected'})); + return; + } - _socket!.handleError((err) { - shardPort.send({"cmd": "ERROR", "error": err.toString(), "errorCode": _socket!.closeCode, "errorReason": _socket!.closeReason}); + try { + final gatewayUri = Constants.gatewayUri(gatewayHost, useCompression); + + connection = await WebSocket.connect(gatewayUri.toString()); + connection!.pingInterval = const Duration(seconds: 20); + + connection!.done.then((_) { + if (reconnecting) { + return; + } + + execute(ShardMessage( + ShardToManager.disconnected, + data: { + 'closeCode': connection!.closeCode!, + 'closeReason': connection!.closeReason, + }, + )); }); - _socketSubscription = _socket!.listen((data) { - shardPort.send({"cmd": "DATA", "jsonData": _decodeBytes(data, zlibDecoder)}); - }); + if (useCompression) { + connectionSubscription = connection!.cast>().transform(ZLibDecoder()).transform(utf8.decoder).listen(receive); + } else { + connectionSubscription = connection!.cast().listen(receive); + } - shardPort.send({"cmd": "CONNECT_ACK"}); + execute(ShardMessage(ShardToManager.connected)); } on WebSocketException catch (err) { - shardPort.send({"cmd": "ERROR", "error": err.toString(), "errorCode": _socket!.closeCode, "errorReason": _socket!.closeReason}); + execute(ShardMessage(ShardToManager.error, data: {'message': err.message, 'shouldReconnect': true})); } on SocketException catch (err) { - shardPort.send({"cmd": "ERROR", "error": err.toString(), "errorCode": -1, "errorReason": "SocketException"}); - } on Exception catch (err) { - print(err); - } on Error catch (err) { - print(err); + execute(ShardMessage(ShardToManager.error, data: {'message': err.message, 'shouldReconnect': true})); + } catch (err) { + execute(ShardMessage(ShardToManager.error, data: {'message': 'Unhanded exception $err'})); } } - // Connects - await _connect(); + /// Reconnect to the server, closing the connection if necessary. + Future reconnect(String gatewayHost, bool useCompression) async { + if (reconnecting) { + execute(ShardMessage(ShardToManager.error, data: {'message': 'Shard is already reconnecting'})); + } - await for (final message in receiveStream) { - final cmd = message["cmd"]; + 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); + } - if (cmd == "SEND") { - if (_socket?.closeCode == null) { - _socket?.add(jsonEncode(message["data"])); - } + await connect(gatewayHost, useCompression); + reconnecting = false; + } - continue; + /// Terminate the connection on this shard. + /// + /// Sends [ShardToManager.disconnected]. + Future disconnect([int closeCode = 1000]) async { + if (!connected) { + execute(ShardMessage(ShardToManager.error, data: {'message': 'Cannot disconnect shard if no connection is active'})); } - if (cmd == "CONNECT") { - await _socketSubscription?.cancel(); - await _socket?.close(1000); - await _connect(); + // Closing the connection will trigger the `connection.done` future we listened to when connecting, which will execute the [ShardToManager.disconnected] + // message. + await connection!.close(closeCode); + await connectionSubscription!.cancel(); - continue; + connection = null; + connectionSubscription = null; + } + + /// Sends data on this shard. + Future send(dynamic data) async { + if (!connected) { + execute(ShardMessage(ShardToManager.error, data: {'message': 'Cannot send data when connection is closed'})); } - if (cmd == "KILL") { - await terminate(); + connection!.add(jsonEncode(data)); + } + + /// Disposes of this shard. + /// + /// Sends [ShardToManager.disposed] upon completion. + @override + Future dispose() async { + if (connected) { + await disconnect(); } + + receivePort.close(); + execute(ShardMessage(ShardToManager.disposed)); } } From cdda169a4dcd88d78f600b98347511b2596bc115 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sun, 28 Aug 2022 11:01:48 +0200 Subject: [PATCH 05/13] Release 4.1.0-dev.1 --- CHANGELOG.md | 6 ++++++ lib/src/internal/constants.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8da4d29..ebf6ec796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 4.1.0-dev.1 +__28.08.2022__ + +- feature: Refactor internal shard system (#368) +- feature: Event to notify change of connection status (#364) + ## 4.1.0-dev.0 __20.08.2022__ diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index 77a80f79b..4b67f930e 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.1.0-dev.0"; + static const String version = "4.1.0-dev.1"; /// Url to Nyxx repo static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; diff --git a/pubspec.yaml b/pubspec.yaml index b107abcb6..0c5e41ea5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx -version: 4.1.0-dev.0 +version: 4.1.0-dev.1 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 From defcb9e5d1bb2bfeeadc186b8936ffc059875612 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sun, 28 Aug 2022 11:10:42 +0200 Subject: [PATCH 06/13] bug: Fixup shard disconnect event --- CHANGELOG.md | 5 +++++ lib/src/internal/constants.dart | 2 +- lib/src/internal/shard/shard.dart | 8 ++++++++ lib/src/plugin/plugin.dart | 3 ++- pubspec.yaml | 2 +- 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf6ec796..e5db73b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 4.1.0-dev.2 +__28.08.2022__ + +- bug: Fixup shard disconnect event + ## 4.1.0-dev.1 __28.08.2022__ diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index 4b67f930e..c3fad41c9 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.1.0-dev.1"; + static const String version = "4.1.0-dev.2"; /// Url to Nyxx repo static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; diff --git a/lib/src/internal/shard/shard.dart b/lib/src/internal/shard/shard.dart index e57a39a65..1c2db7bad 100644 --- a/lib/src/internal/shard/shard.dart +++ b/lib/src/internal/shard/shard.dart @@ -201,6 +201,10 @@ class Shard implements IShard { manager.onDisconnectController.add(this); + for (final element in manager.connectionManager.client.plugins) { + element.onConnectionClose(manager.connectionManager.client, manager.logger, closeCode, closeReason); + } + // https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes const warnings = { 4000: 'Unknown error', @@ -252,6 +256,10 @@ class Shard implements IShard { Future handleError(String message, bool? shouldReconnect) 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); } diff --git a/lib/src/plugin/plugin.dart b/lib/src/plugin/plugin.dart index 4fa622554..03ad72663 100644 --- a/lib/src/plugin/plugin.dart +++ b/lib/src/plugin/plugin.dart @@ -9,5 +9,6 @@ abstract class BasePlugin { FutureOr onBotStart(INyxx nyxx, Logger logger) async {} FutureOr onBotStop(INyxx nyxx, Logger logger) async {} - FutureOr onConnectionChange(INyxx nyxx, Logger logger, int? closeCode, String? closeReason, String? socketError) async {} + FutureOr onConnectionClose(INyxx nyxx, Logger logger, int closeCode, String? closeReason) async {} + FutureOr onConnectionError(INyxx nyxx, Logger logger, String errorMessage) async {} } diff --git a/pubspec.yaml b/pubspec.yaml index 0c5e41ea5..fb5851a4a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx -version: 4.1.0-dev.1 +version: 4.1.0-dev.2 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 From b5cdb982d8a7b65208c52e788635983a2271b1f0 Mon Sep 17 00:00:00 2001 From: Rapougnac <74512338+Rapougnac@users.noreply.github.com> Date: Sat, 3 Sep 2022 21:40:26 +0200 Subject: [PATCH 07/13] Cache guild events (#369) --- lib/src/core/guild/guild.dart | 10 +++++- lib/src/events/guild_events.dart | 11 +++++++ lib/src/internal/http_endpoints.dart | 46 ++++++++++++++++------------ 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/lib/src/core/guild/guild.dart b/lib/src/core/guild/guild.dart index 8591f7c31..a40ee8522 100644 --- a/lib/src/core/guild/guild.dart +++ b/lib/src/core/guild/guild.dart @@ -40,7 +40,7 @@ import 'package:nyxx/src/utils/builders/guild_event_builder.dart'; import 'package:nyxx/src/utils/builders/sticker_builder.dart'; abstract class IGuild implements SnowflakeEntity { - /// Reference to [NyxxWebsocket] instance + /// Reference to [INyxxWebsocket] instance INyxx get client; /// The guild's name. @@ -192,6 +192,10 @@ abstract class IGuild implements SnowflakeEntity { /// An empty map is returned if none where fetched or added by events. ICache get autoModerationRules; + /// The cached guild events in the guild. + /// An empty map is returned if none where fetched or added by events. + ICache get scheduledEvents; + /// The guild's icon, represented as URL. /// If guild doesn't have icon it returns null. String? iconURL({String format = "webp", int size = 128}); @@ -586,6 +590,9 @@ class Guild extends SnowflakeEntity implements IGuild { @override late final ICache autoModerationRules; + @override + late final ICache scheduledEvents; + /// Creates an instance of [Guild] Guild(this.client, RawApiMap raw, [bool guildCreate = false]) : super(Snowflake(raw["id"])) { name = raw["name"] as String; @@ -712,6 +719,7 @@ class Guild extends SnowflakeEntity implements IGuild { ]; autoModerationRules = SnowflakeCache(); + scheduledEvents = SnowflakeCache(); } /// The guild's icon, represented as URL. diff --git a/lib/src/events/guild_events.dart b/lib/src/events/guild_events.dart index 15ac0a2ba..68943330c 100644 --- a/lib/src/events/guild_events.dart +++ b/lib/src/events/guild_events.dart @@ -451,19 +451,29 @@ class GuildEventCreateEvent implements IGuildEventCreateEvent { GuildEventCreateEvent(RawApiMap raw, INyxx client) { event = GuildEvent(raw['d'] as RawApiMap, client); + event.guild.getFromCache()?.scheduledEvents[event.id] = event; } } abstract class IGuildEventUpdateEvent { + /// The newly edited event. IGuildEvent get event; + + /// The old event before it's update. + IGuildEvent? get oldEvent; } class GuildEventUpdateEvent implements IGuildEventUpdateEvent { @override late final IGuildEvent event; + @override + late final IGuildEvent? oldEvent; + GuildEventUpdateEvent(RawApiMap raw, INyxx client) { event = GuildEvent(raw['d'] as RawApiMap, client); + oldEvent = event.guild.getFromCache()?.scheduledEvents[event.id]; + event.guild.getFromCache()?.scheduledEvents.update(event.id, (_) => event, ifAbsent: () => event); } } @@ -477,6 +487,7 @@ class GuildEventDeleteEvent implements IGuildEventDeleteEvent { GuildEventDeleteEvent(RawApiMap raw, INyxx client) { event = GuildEvent(raw['d'] as RawApiMap, client); + event.guild.getFromCache()?.scheduledEvents.remove(event.id); } } diff --git a/lib/src/internal/http_endpoints.dart b/lib/src/internal/http_endpoints.dart index 85a253e69..89d149bee 100644 --- a/lib/src/internal/http_endpoints.dart +++ b/lib/src/internal/http_endpoints.dart @@ -2066,7 +2066,9 @@ class HttpEndpoints implements IHttpEndpoints { return Future.error(response); } - return GuildEvent((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + final event = GuildEvent((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + client.guilds[guildId]?.scheduledEvents[event.id] = event; + return event; } @override @@ -2089,39 +2091,43 @@ class HttpEndpoints implements IHttpEndpoints { return Future.error(response); } - return GuildEvent((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + final event = GuildEvent((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + client.guilds[guildId]?.scheduledEvents[guildEventId] = event; + return event; } @override Future fetchGuildEvent(Snowflake guildId, Snowflake guildEventId) async { final response = await httpHandler.execute(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..scheduledEvents(id: guildEventId.toString()), - method: 'GET')); + HttpRoute() + ..guilds(id: guildId.toString()) + ..scheduledEvents(id: guildEventId.toString()), + )); if (response is IHttpResponseError) { return Future.error(response); } - return GuildEvent((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + final event = GuildEvent((response as IHttpResponseSuccess).jsonBody as RawApiMap, client); + client.guilds[guildId]?.scheduledEvents[event.id] = event; + return event; } @override Stream fetchGuildEventUsers(Snowflake guildId, Snowflake guildEventId, {int limit = 100, bool withMember = false, Snowflake? before, Snowflake? after}) async* { final response = await httpHandler.execute(BasicRequest( - HttpRoute() - ..guilds(id: guildId.toString()) - ..scheduledEvents(id: guildEventId.toString()) - ..users(), - method: 'GET', - queryParams: { - 'limit': limit, - 'with_member': withMember, - if (before != null) 'before': before.toString(), - if (after != null) 'after': after.toString(), - })); + HttpRoute() + ..guilds(id: guildId.toString()) + ..scheduledEvents(id: guildEventId.toString()) + ..users(), + queryParams: { + 'limit': limit, + 'with_member': withMember, + if (before != null) 'before': before.toString(), + if (after != null) 'after': after.toString(), + }, + )); if (response is IHttpResponseError) { yield* Stream.error(response); @@ -2146,7 +2152,9 @@ class HttpEndpoints implements IHttpEndpoints { } for (final rawGuildEvent in (response as IHttpResponseSuccess).jsonBody as RawApiList) { - yield GuildEvent(rawGuildEvent as RawApiMap, client); + final event = GuildEvent(rawGuildEvent as RawApiMap, client); + client.guilds[guildId]?.scheduledEvents[event.id] = event; + yield event; } } From 0a7147e10eb1711b73bbcbffd6bab3ecd1ae8577 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sat, 3 Sep 2022 21:41:55 +0200 Subject: [PATCH 08/13] Release 4.1.0-dev.3 --- CHANGELOG.md | 5 +++++ lib/src/internal/constants.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5db73b1b..89695a9d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 4.1.0-dev.3 +__03.09.2022__ + +- feature: Cache guild events (#369) + ## 4.1.0-dev.2 __28.08.2022__ diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index c3fad41c9..ef49e559f 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.1.0-dev.2"; + static const String version = "4.1.0-dev.3"; /// Url to Nyxx repo static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; diff --git a/pubspec.yaml b/pubspec.yaml index fb5851a4a..e6be210e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx -version: 4.1.0-dev.2 +version: 4.1.0-dev.3 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 From 93140c0ed13630d71c927ebd937561935fdf31b9 Mon Sep 17 00:00:00 2001 From: Rapougnac <74512338+Rapougnac@users.noreply.github.com> Date: Sat, 10 Sep 2022 19:40:39 +0200 Subject: [PATCH 09/13] Add `invitesDisabled` feature (#370) * Add `invitesDisabled` feature * Update guild_feature.dart * Fix formatting * Dart format --- lib/src/core/guild/guild_feature.dart | 86 ++++++++++++++++++--------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/lib/src/core/guild/guild_feature.dart b/lib/src/core/guild/guild_feature.dart index 62e5bfd34..b4141ed55 100644 --- a/lib/src/core/guild/guild_feature.dart +++ b/lib/src/core/guild/guild_feature.dart @@ -2,60 +2,88 @@ import 'package:nyxx/src/utils/enum.dart'; /// Guild features class GuildFeature extends IEnum { + /// Guild has Auto Moderation + static const GuildFeature autoModeration = GuildFeature._create("AUTO_MODERATION"); + + /// Guild has access to set an animated guild icon + static const GuildFeature animatedIcon = GuildFeature._create("ANIMATED_ICON"); + + /// Guild has access to set an animated guild banner image + static const GuildFeature animatedBanner = GuildFeature._create('ANIMATED_BANNER'); + + /// Guild has access to set a guild banner image + static const GuildFeature banner = GuildFeature._create("BANNER"); + + /// Guild can enable welcome screen, Membership Screening, stage channels and discovery, and receives community updates + static const GuildFeature community = GuildFeature._create('COMMUNITY'); + + /// Guild is able to be discovered in the directory + static const GuildFeature discoverable = GuildFeature._create("DISCOVERABLE"); + + /// Guild is able to be featured in the directory + static const GuildFeature featurable = GuildFeature._create('FEATURABLE'); + + /// Guild has paused invites, preventing new users from joining + static const GuildFeature invitesDisabled = GuildFeature._create('INVITES_DISABLED'); + /// Guild has access to set an invite splash background static const GuildFeature inviteSplash = GuildFeature._create("INVITE_SPLASH"); - /// Guild has access to set 384kbps bitrate in voice (previously VIP voice servers) - static const GuildFeature vipRegions = GuildFeature._create("VIP_REGIONS"); + /// Guild has enabled [Membership Screening](https://discord.com/developers/docs/resources/guild#membership-screening-object) + static const GuildFeature memberVerificationGateEnabled = GuildFeature._create('MEMBER_VERIFICATION_GATE_ENABLED'); - /// Guild has access to set a vanity URL - static const GuildFeature vanityUrl = GuildFeature._create("VANITY_URL"); + /// Guild has enabled monetization + static const GuildFeature monetizationEnabled = GuildFeature._create("MONETIZATION_ENABLED"); - /// Guild is verified - static const GuildFeature verified = GuildFeature._create("VERIFIED"); + /// Guild has increased custom sticker slots + static const GuildFeature moreStickers = GuildFeature._create("MORE_STICKERS"); + + /// Guild has access to create news channels + static const GuildFeature news = GuildFeature._create("NEWS"); /// Guild is partnered static const GuildFeature partnered = GuildFeature._create("PARTNERED"); - /// Guild has access to use commerce features (i.e. create store channels) - static const GuildFeature commerce = GuildFeature._create("COMMERCE"); + /// Guild can be previewed before joining via Membership Screening or the directory + static const GuildFeature previewEnabled = GuildFeature._create('PREVIEW_ENABLED'); - /// Guild has access to create news channels - static const GuildFeature news = GuildFeature._create("NEWS"); + /// Guild has access to create private threads + static const GuildFeature privateThreadsEnabled = GuildFeature._create("PRIVATE_THREADS"); - /// Guild is able to be discovered in the directory - static const GuildFeature discoverable = GuildFeature._create("DISCOVERABLE"); + /// Guild is able to set role icons + static const GuildFeature roleIcons = GuildFeature._create('ROLE_ICONS'); - /// Guild has access to set an animated guild icon - static const GuildFeature animatedIcon = GuildFeature._create("ANIMATED_ICON"); + /// Guild has enabled ticketed events + static const GuildFeature ticketsEventEnabled = GuildFeature._create("TICKETED_EVENTS_ENABLED"); - /// Guild has access to set a guild banner image - static const GuildFeature banner = GuildFeature._create("BANNER"); + /// Guild has access to set a vanity URL + static const GuildFeature vanityUrl = GuildFeature._create("VANITY_URL"); - /// Guild cannot be public - static const GuildFeature publicDisabled = GuildFeature._create("PUBLIC_DISABLED"); + /// Guild is verified + static const GuildFeature verified = GuildFeature._create("VERIFIED"); + + /// Guild has access to set 384kbps bitrate in voice (previously VIP voice servers) + static const GuildFeature vipRegions = GuildFeature._create("VIP_REGIONS"); /// Guild has enabled the welcome screen static const GuildFeature welcomeScreenEnabled = GuildFeature._create("WELCOME_SCREEN_ENABLED"); - /// Guild has enabled ticketed events - static const GuildFeature ticketsEventEnabled = GuildFeature._create("TICKETED_EVENTS_ENABLED"); + /// Guild has access to use commerce features (i.e. create store channels) + @Deprecated(''' - /// Guild has enabled monetization - static const GuildFeature monetizationEnabled = GuildFeature._create("MONETIZATION_ENABLED"); +Discord no longer offers the ability to purchase a license to sell PC games. - /// Guild has increased custom sticker slots - static const GuildFeature moreStickers = GuildFeature._create("MORE_STICKERS"); +See https://support-dev.discord.com/hc/en-us/articles/6309018858647-Self-serve-Game-Selling-Deprecation for more information''') + static const GuildFeature commerce = GuildFeature._create("COMMERCE"); - /// Guild has access to create private threads - static const GuildFeature privateThreadsEnabled = GuildFeature._create("PRIVATE_THREADS"); + /// Guild cannot be public + @Deprecated('No longer has meaning') + static const GuildFeature publicDisabled = GuildFeature._create("PUBLIC_DISABLED"); /// Guild is a Student Hub + @Deprecated("Was not documented but exists, this can be removed at any time") static const GuildFeature studentHub = GuildFeature._create("HUB"); - /// Guild has Auto Moderation - static const GuildFeature autoModeration = GuildFeature._create("AUTO_MODERATION"); - /// Creates instance of [GuildFeature] from [value]. GuildFeature.from(String? value) : super(value ?? ""); const GuildFeature._create(String? value) : super(value ?? ""); From ef9adef7c14e39164eb8a16b3774c837aac9fa3d Mon Sep 17 00:00:00 2001 From: Rapougnac <74512338+Rapougnac@users.noreply.github.com> Date: Sun, 11 Sep 2022 09:19:42 +0200 Subject: [PATCH 10/13] Add pending for member screening (#371) * Add pending for member screening * Fu auto-import --- lib/src/core/user/member.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/src/core/user/member.dart b/lib/src/core/user/member.dart index 43538c0fd..d57740d91 100644 --- a/lib/src/core/user/member.dart +++ b/lib/src/core/user/member.dart @@ -62,6 +62,10 @@ abstract class IMember implements SnowflakeEntity, Mentionable { /// True if user is timed out bool get isTimedOut; + /// True if member is currently pending by [Membership Screening](https://discord.com/developers/docs/resources/guild#membership-screening-object). + /// When completed, an [IGuildMemberUpdateEvent] will be fired with [isPending] set to `false`. + bool get isPending; + /// Returns url to member avatar String? avatarURL({String format = "webp"}); @@ -164,6 +168,9 @@ class Member extends SnowflakeEntity implements IMember { return Permissions(total); } + @override + late final bool isPending; + /// Creates an instance of [Member] Member(this.client, RawApiMap raw, Snowflake guildId) : super(Snowflake(raw["user"]["id"])) { nickname = raw["nick"] as String?; @@ -186,6 +193,8 @@ class Member extends SnowflakeEntity implements IMember { client.users[id] = User(client, userRaw); } } + + isPending = (raw['pending'] as bool?) ?? false; } /// Returns url to member avatar From 5a5af0e34d57b0a17a9f7bc923b28c3b0b19453c Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Mon, 12 Sep 2022 08:56:10 +0200 Subject: [PATCH 11/13] feature: member screening events (#372) * feature: member screening events * code review --- lib/src/internal/event_controller.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/src/internal/event_controller.dart b/lib/src/internal/event_controller.dart index 6470d68f6..4a1645519 100644 --- a/lib/src/internal/event_controller.dart +++ b/lib/src/internal/event_controller.dart @@ -127,6 +127,14 @@ abstract class IWebsocketEventController implements IRestEventController { /// Emitted when a member joins a guild. Stream get onGuildMemberAdd; + /// Emitted when a member joins a guild but is not yet screened by: + /// https://support.discord.com/hc/en-us/articles/1500000466882 + Stream get onGuildMemberAddScreening; + + /// Emitted when a member joins a guild but passed member screening + /// https://support.discord.com/hc/en-us/articles/1500000466882 + Stream get onGuildMemberAddPassedScreening; + /// Emitted when a member is updated. Stream get onGuildMemberUpdate; @@ -566,6 +574,12 @@ class WebsocketEventController extends RestEventController implements IWebsocket @override late final Stream onAutoModerationActionExecution; + @override + late final Stream onGuildMemberAddScreening; + + @override + late final Stream onGuildMemberAddPassedScreening; + final INyxxWebsocket _client; /// Makes a new `EventController`. @@ -713,6 +727,9 @@ class WebsocketEventController extends RestEventController implements IWebsocket onAutoModerationActionExecutionController = StreamController.broadcast(); onAutoModerationActionExecution = onAutoModerationActionExecutionController.stream; + + onGuildMemberAddScreening = onGuildMemberAdd.where((event) => event.member.isPending); + onGuildMemberAddPassedScreening = onGuildMemberUpdate.where((event) => !(event.member.getFromCache()?.isPending ?? true)); } @override From ae49161f384a72ffdb1565f0e60c93826a11806a Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Thu, 15 Sep 2022 20:51:59 +0200 Subject: [PATCH 12/13] Release 4.1.0-dev.4 --- CHANGELOG.md | 7 +++++++ lib/src/internal/constants.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89695a9d8..6103df690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 4.1.0-dev.4 +__15.09.2022__ + +- feature: Add `invitesDisabled` feature (#370) +- feature: Add pending for member screening (#371) +- feature: member screening events (#372) + ## 4.1.0-dev.3 __03.09.2022__ diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index ef49e559f..93e892009 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.1.0-dev.3"; + static const String version = "4.1.0-dev.4"; /// Url to Nyxx repo static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; diff --git a/pubspec.yaml b/pubspec.yaml index e6be210e9..711850e52 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx -version: 4.1.0-dev.3 +version: 4.1.0-dev.4 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 From d1753cad3205a03316d43aeb48be16674b34e03e Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sun, 25 Sep 2022 10:09:45 +0200 Subject: [PATCH 13/13] Release 4.1.0 --- CHANGELOG.md | 12 ++++++++++++ lib/src/internal/constants.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6103df690..fdad96662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 4.1.0 +__25.09.2022__ + +- feature: Add `invitesDisabled` feature (#370) +- feature: Add pending for member screening (#371) +- feature: member screening events (#372) +- feature: Cache guild events (#369) +- feature: Refactor internal shard system (#368) +- feature: Event to notify change of connection status (#364) +- feature: feature: auto moderation (#353) +- bug: Fixup shard disconnect event + ## 4.1.0-dev.4 __15.09.2022__ diff --git a/lib/src/internal/constants.dart b/lib/src/internal/constants.dart index 93e892009..f3d58ca45 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.1.0-dev.4"; + static const String version = "4.1.0"; /// Url to Nyxx repo static const String repoUrl = "https://github.com/nyxx-discord/nyxx"; diff --git a/pubspec.yaml b/pubspec.yaml index 711850e52..1be147df9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx -version: 4.1.0-dev.4 +version: 4.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