Repository: nyxx-discord/nyxx (dart)

Issue:
Implement forum channels
Ref: https://github.com/discord/discord-api-docs/pull/4761

Pull request:
# Description
 
Implements forum channels

## Connected issues & other potential problems

Closes #320 

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)

# Checklist:

- [x] Ran `dart analyze` or `make analyze` and fixed all issues
- [x] Ran `dart format --set-exit-if-changed -l 160 ./lib` or `make format` and fixed all issues
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] I have checked my changes haven't lowered code coverage

Patch:
diff --git a/lib/nyxx.dart b/lib/nyxx.dart
index eb6b2437f..dde8bf1e3 100644
--- a/lib/nyxx.dart
+++ b/lib/nyxx.dart
@@ -23,6 +23,8 @@ export 'src/core/channel/cacheable_text_channel.dart' show ICacheableTextChannel
 export 'src/core/channel/channel.dart' show IChannel, ChannelType;
 export 'src/core/channel/dm_channel.dart' show IDMChannel;
 export 'src/core/channel/text_channel.dart' show ITextChannel;
+export 'src/core/channel/guild/forum/forum_channel.dart' show IForumChannel;
+export 'src/core/channel/guild/forum/forum_tag.dart' show IForumTag;
 export 'src/core/channel/thread_channel.dart' show IThreadMember, IThreadChannel;
 export 'src/core/channel/thread_preview_channel.dart' show IThreadPreviewChannel;
 export 'src/core/channel/guild/activity_types.dart' show VoiceActivityType;
@@ -167,6 +169,7 @@ export 'src/utils/builders/reply_builder.dart' show ReplyBuilder;
 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/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/core/channel/channel.dart b/lib/src/core/channel/channel.dart
index f1bd591fd..f681eceab 100644
--- a/lib/src/core/channel/channel.dart
+++ b/lib/src/core/channel/channel.dart
@@ -1,3 +1,4 @@
+import 'package:nyxx/src/core/channel/guild/forum/forum_channel.dart';
 import 'package:nyxx/src/nyxx.dart';
 import 'package:nyxx/src/core/snowflake.dart';
 import 'package:nyxx/src/core/snowflake_entity.dart';
@@ -63,6 +64,8 @@ abstract class Channel extends SnowflakeEntity implements IChannel {
         return ThreadChannel(client, raw);
       case 13:
         return StageVoiceGuildChannel(client, raw, guildId);
+      case 15:
+        return ForumChannel(client, raw, guildId);
       default:
         return _InternalChannel._new(client, raw, guildId);
     }
@@ -100,6 +103,8 @@ class ChannelType extends IEnum<int> {
   /// Channel in a Student Hub containing the listed servers
   static const ChannelType guildDirectory = ChannelType._create(14);
 
+  static const ChannelType forumChannel = ChannelType._create(15);
+
   /// Type of channel is unknown
   static const ChannelType unknown = ChannelType._create(1337);
 
diff --git a/lib/src/core/channel/guild/forum/forum_channel.dart b/lib/src/core/channel/guild/forum/forum_channel.dart
new file mode 100644
index 000000000..3c41ded4e
--- /dev/null
+++ b/lib/src/core/channel/guild/forum/forum_channel.dart
@@ -0,0 +1,60 @@
+import 'package:nyxx/src/core/channel/guild/forum/forum_tag.dart';
+import 'package:nyxx/src/core/channel/guild/guild_channel.dart';
+import 'package:nyxx/src/core/channel/thread_channel.dart';
+import 'package:nyxx/src/core/channel/thread_preview_channel.dart';
+import 'package:nyxx/src/core/snowflake.dart';
+import 'package:nyxx/src/internal/interfaces/mentionable.dart';
+import 'package:nyxx/src/internal/response_wrapper/thread_list_result_wrapper.dart';
+import 'package:nyxx/src/nyxx.dart';
+import 'package:nyxx/src/typedefs.dart';
+import 'package:nyxx/src/utils/builders/forum_thread_builder.dart';
+
+abstract class IForumChannel implements IGuildChannel, Mentionable {
+  /// Tags available to assign to forum posts
+  List<IForumTag> get availableTags;
+
+  /// Creates a thread in a channel, that only retrieves a [ThreadPreviewChannel]
+  Future<IThreadChannel> createThread(ForumThreadBuilder builder);
+
+  /// Fetches joined private and archived thread channels
+  Future<IThreadListResultWrapper> fetchJoinedPrivateArchivedThreads({DateTime? before, int? limit});
+
+  /// Fetches private, archived thread channels
+  Future<IThreadListResultWrapper> fetchPrivateArchivedThreads({DateTime? before, int? limit});
+
+  /// Fetches public, archives thread channels
+  Future<IThreadListResultWrapper> fetchPublicArchivedThreads({DateTime? before, int? limit});
+}
+
+class ForumChannel extends GuildChannel implements IForumChannel {
+  @override
+  late final List<IForumTag> availableTags;
+
+  /// Creates an instance of [TextGuildChannel]
+  ForumChannel(INyxx client, RawApiMap raw, [Snowflake? guildId]) : super(client, raw, guildId) {
+    availableTags = (raw['available_tags'] as List<dynamic>).cast<RawApiMap>().map((e) => ForumTag(e)).toList();
+  }
+
+  /// The channel's mention string.
+  @override
+  String get mention => "<#$id>";
+
+  /// Creates a thread in a channel, that only retrieves a [ThreadPreviewChannel]
+  @override
+  Future<IThreadChannel> createThread(ForumThreadBuilder builder) => client.httpEndpoints.startForumThread(id, builder);
+
+  /// Fetches joined private and archived thread channels
+  @override
+  Future<IThreadListResultWrapper> fetchJoinedPrivateArchivedThreads({DateTime? before, int? limit}) =>
+      client.httpEndpoints.fetchJoinedPrivateArchivedThreads(id, before: before, limit: limit);
+
+  /// Fetches private, archived thread channels
+  @override
+  Future<IThreadListResultWrapper> fetchPrivateArchivedThreads({DateTime? before, int? limit}) =>
+      client.httpEndpoints.fetchPrivateArchivedThreads(id, before: before, limit: limit);
+
+  /// Fetches public, archives thread channels
+  @override
+  Future<IThreadListResultWrapper> fetchPublicArchivedThreads({DateTime? before, int? limit}) =>
+      client.httpEndpoints.fetchPublicArchivedThreads(id, before: before, limit: limit);
+}
diff --git a/lib/src/core/channel/guild/forum/forum_tag.dart b/lib/src/core/channel/guild/forum/forum_tag.dart
new file mode 100644
index 000000000..a22d08db2
--- /dev/null
+++ b/lib/src/core/channel/guild/forum/forum_tag.dart
@@ -0,0 +1,37 @@
+import 'package:nyxx/src/core/snowflake.dart';
+import 'package:nyxx/src/typedefs.dart';
+
+abstract class IForumTag {
+  /// Id of forum tag
+  Snowflake get id;
+
+  /// Name of forum tag
+  String? get name;
+
+  /// Id of corresponding emoji if guild emoji
+  Snowflake? get emojiId;
+
+  /// Unicode emoji of emoji if non guild emoji
+  String? get emojiName;
+}
+
+class ForumTag implements IForumTag {
+  @override
+  late final Snowflake id;
+
+  @override
+  late final String? name;
+
+  @override
+  late final Snowflake? emojiId;
+
+  @override
+  late final String? emojiName;
+
+  ForumTag(RawApiMap raw) {
+    id = Snowflake(raw['id']);
+    name = raw['string'] as String?;
+    emojiId = raw['emoji_id'] != 0 ? Snowflake(raw['emoji_id']) : null;
+    emojiName = raw['emoji_name'] as String?;
+  }
+}
diff --git a/lib/src/core/channel/guild/guild_channel.dart b/lib/src/core/channel/guild/guild_channel.dart
index b18813ca8..dff2c78be 100644
--- a/lib/src/core/channel/guild/guild_channel.dart
+++ b/lib/src/core/channel/guild/guild_channel.dart
@@ -208,7 +208,7 @@ abstract class MinimalGuildChannel extends Channel implements IMinimalGuildChann
       guild = GuildCacheable(client, guildId);
     } else {
       throw Exception(
-          "Cannot initialize instance of GuildChannelNex due missing `guild_id` in json payload and/or missing optional guildId parameter. Report this issue to developer");
+          "Cannot initialize instance of GuildChannel due missing `guild_id` in json payload and/or missing optional guildId parameter. Report this issue to developer");
     }
 
     if (raw["parent_id"] != null) {
diff --git a/lib/src/core/channel/guild/text_guild_channel.dart b/lib/src/core/channel/guild/text_guild_channel.dart
index a0ce85c22..75e5dcfbb 100644
--- a/lib/src/core/channel/guild/text_guild_channel.dart
+++ b/lib/src/core/channel/guild/text_guild_channel.dart
@@ -34,7 +34,7 @@ abstract class ITextGuildChannel implements IGuildChannel, ITextChannel, Mention
   /// Valid file types for [avatarFile] are jpeg, gif and png.
   ///
   /// ```
-  /// final webhook = await channnel.createWebhook("!a Send nudes kek6407");
+  /// final webhook = await channel.createWebhook("!a Send nudes kek6407");
   /// ```
   Future<IWebhook> createWebhook(String name, {AttachmentBuilder? avatarAttachment, String? auditReason});
 
diff --git a/lib/src/internal/http_endpoints.dart b/lib/src/internal/http_endpoints.dart
index 5694602ec..079a7133c 100644
--- a/lib/src/internal/http_endpoints.dart
+++ b/lib/src/internal/http_endpoints.dart
@@ -34,6 +34,7 @@ import 'package:nyxx/src/internal/response_wrapper/thread_list_result_wrapper.da
 import 'package:nyxx/src/typedefs.dart';
 import 'package:nyxx/src/utils/builders/attachment_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';
 import 'package:nyxx/src/utils/builders/guild_event_builder.dart';
 import 'package:nyxx/src/utils/builders/member_builder.dart';
@@ -411,6 +412,8 @@ abstract class IHttpEndpoints {
 
   Stream<GuildEventUser> fetchGuildEventUsers(Snowflake guildId, Snowflake guildEventId,
       {int limit = 100, bool withMember = false, Snowflake? before, Snowflake? after});
+
+  Future<IThreadChannel> startForumThread(Snowflake channelId, ForumThreadBuilder builder);
 }
 
 class HttpEndpoints implements IHttpEndpoints {
@@ -542,6 +545,25 @@ class HttpEndpoints implements IHttpEndpoints {
     return Future.error(response);
   }
 
+  @override
+  Future<IThreadChannel> startForumThread(Snowflake channelId, ForumThreadBuilder builder) async {
+    final response = await httpHandler.execute(
+      BasicRequest(
+        HttpRoute()
+          ..channels(id: channelId.toString())
+          ..threads(),
+        method: "POST",
+        body: builder.build(),
+      ),
+    );
+
+    if (response is HttpResponseSuccess) {
+      return ThreadChannel(client, response.jsonBody as RawApiMap);
+    }
+
+    return Future.error(response);
+  }
+
   @override
   Future<void> deleteRole(Snowflake guildId, Snowflake roleId, {String? auditReason}) async => executeSafe(BasicRequest(
       HttpRoute()
diff --git a/lib/src/utils/builders/forum_thread_builder.dart b/lib/src/utils/builders/forum_thread_builder.dart
new file mode 100644
index 000000000..922b2509d
--- /dev/null
+++ b/lib/src/utils/builders/forum_thread_builder.dart
@@ -0,0 +1,31 @@
+import 'package:nyxx/src/typedefs.dart';
+import 'package:nyxx/src/utils/builders/builder.dart';
+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
+  String name;
+
+  /// First message in thread
+  MessageBuilder message;
+
+  /// Amount of seconds a user has to wait before sending another message (0-21600);
+  /// bots, as well as users with the permission manage_messages, manage_thread, or manage_channel, are unaffected
+  int? rateLimitPerUser;
+
+  /// The time after which the thread is automatically archived.
+  ThreadArchiveTime? archiveAfter;
+
+  ForumThreadBuilder(this.name, this.message);
+
+  @override
+  RawApiMap build() {
+    return {
+      "name": name,
+      "message": message.build(),
+      if (archiveAfter != null) "auto_archive_duration": archiveAfter!.value,
+      if (rateLimitPerUser != null) 'rate_limit_per_user': rateLimitPerUser!
+    };
+  }
+}
diff --git a/lib/src/utils/builders/thread_builder.dart b/lib/src/utils/builders/thread_builder.dart
index b3a0ccb3d..23c1c0ef0 100644
--- a/lib/src/utils/builders/thread_builder.dart
+++ b/lib/src/utils/builders/thread_builder.dart
@@ -3,7 +3,7 @@ import 'package:nyxx/src/utils/enum.dart';
 import 'package:nyxx/src/utils/builders/builder.dart';
 
 class ThreadBuilder extends Builder {
-  /// The name fo the thread
+  /// The name for the thread
   String? name;
 
   /// Whether or not the thread is private


Test patch:
diff --git a/test/unit/builders_test.dart b/test/unit/builders_test.dart
index 7f5a620e2..32c90fce3 100644
--- a/test/unit/builders_test.dart
+++ b/test/unit/builders_test.dart
@@ -6,6 +6,7 @@ import 'package:nyxx/src/core/user/presence.dart';
 import 'package:nyxx/src/internal/cache/cacheable.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';
 import 'package:nyxx/src/utils/builders/member_builder.dart';
 import 'package:nyxx/src/utils/builders/message_builder.dart';
 import 'package:nyxx/src/utils/builders/permissions_builder.dart';
@@ -210,5 +211,19 @@ main() {
 
       expect(MessageDecoration.bold.format('test'), equals('**test**'));
     });
+
+    test("ForumThreadBuilder", () {
+      final builder = ForumThreadBuilder("test", MessageBuilder.content("test"));
+
+      expect(
+          builder.build(),
+          equals({
+            'name': 'test',
+            'message': {
+              'content': 'test'
+            }
+          })
+      );
+    });
   });
 }