From e410b03f81814f313d80be8fa230885b2b0640fe Mon Sep 17 00:00:00 2001 From: James Upjohn Date: Sun, 17 May 2026 09:50:36 +1200 Subject: [PATCH 1/3] feat(helix): implement pinned message endpoints --- MiniTwitch.Helix/Enums/MessageFragmentType.cs | 9 ++ MiniTwitch.Helix/HelixWrapper.cs | 72 +++++++++++++++- MiniTwitch.Helix/Internal/Models/Endpoints.cs | 26 +++++- .../Internal/Models/QueryParams.cs | 3 +- .../Responses/PinnedChatMessages.cs | 86 +++++++++++++++++++ 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 MiniTwitch.Helix/Enums/MessageFragmentType.cs create mode 100644 MiniTwitch.Helix/Responses/PinnedChatMessages.cs diff --git a/MiniTwitch.Helix/Enums/MessageFragmentType.cs b/MiniTwitch.Helix/Enums/MessageFragmentType.cs new file mode 100644 index 0000000..4d4d5b6 --- /dev/null +++ b/MiniTwitch.Helix/Enums/MessageFragmentType.cs @@ -0,0 +1,9 @@ +namespace MiniTwitch.Helix.Enums; + +public enum MessageFragmentType +{ + Text, + Emote, + Cheermote, + Mention +} \ No newline at end of file diff --git a/MiniTwitch.Helix/HelixWrapper.cs b/MiniTwitch.Helix/HelixWrapper.cs index 442877f..d865ff7 100644 --- a/MiniTwitch.Helix/HelixWrapper.cs +++ b/MiniTwitch.Helix/HelixWrapper.cs @@ -2902,4 +2902,74 @@ public Task> RemoveSuspiciousStatusFromChatUser( return HelixResultFactory.Create(Client, request, endpoint, cancellationToken); } -} + + /// + /// API Reference + /// + public Task> GetPinnedChatMessage( + long broadcasterId, + CancellationToken cancellationToken = default) + { + HelixEndpoint endpoint = Endpoints.GetPinnedChatMessage; + RequestData request = new RequestData(_baseUrl, endpoint) + .AddParam(QueryParams.BroadcasterId, broadcasterId) + .AddParam(QueryParams.ModeratorId, this.UserId); + + return HelixResultFactory.Create(Client, request, endpoint, cancellationToken); + } + + /// + /// API Reference + /// + public Task PinChatMessage( + long broadcasterId, + string messageId, + int? durationSeconds = null, + CancellationToken cancellationToken = default) + { + HelixEndpoint endpoint = Endpoints.PinChatMessage; + RequestData request = new RequestData(_baseUrl, endpoint) + .AddParam(QueryParams.BroadcasterId, broadcasterId) + .AddParam(QueryParams.ModeratorId, this.UserId) + .AddParam(QueryParams.MessageId, messageId) + .AddParam(QueryParams.DurationSeconds, durationSeconds); + + return HelixResultFactory.Create(Client, request, endpoint, cancellationToken); + } + + /// + /// API Reference + /// + public Task UpdatePinChatMessage( + long broadcasterId, + string messageId, + int? durationSeconds = null, + CancellationToken cancellationToken = default) + { + HelixEndpoint endpoint = Endpoints.UpdatePinnedChatMessage; + RequestData request = new RequestData(_baseUrl, endpoint) + .AddParam(QueryParams.BroadcasterId, broadcasterId) + .AddParam(QueryParams.ModeratorId, this.UserId) + .AddParam(QueryParams.MessageId, messageId) + .AddParam(QueryParams.DurationSeconds, durationSeconds); + + return HelixResultFactory.Create(Client, request, endpoint, cancellationToken); + } + + /// + /// API Reference + /// + public Task UnpinChatMessage( + long broadcasterId, + string messageId, + CancellationToken cancellationToken = default) + { + HelixEndpoint endpoint = Endpoints.UnpinChatMessage; + RequestData request = new RequestData(_baseUrl, endpoint) + .AddParam(QueryParams.BroadcasterId, broadcasterId) + .AddParam(QueryParams.ModeratorId, this.UserId) + .AddParam(QueryParams.MessageId, messageId); + + return HelixResultFactory.Create(Client, request, endpoint, cancellationToken); + } +} \ No newline at end of file diff --git a/MiniTwitch.Helix/Internal/Models/Endpoints.cs b/MiniTwitch.Helix/Internal/Models/Endpoints.cs index 8be7155..b3a6241 100644 --- a/MiniTwitch.Helix/Internal/Models/Endpoints.cs +++ b/MiniTwitch.Helix/Internal/Models/Endpoints.cs @@ -897,4 +897,28 @@ internal static class Endpoints Method = HttpMethod.Delete, Route = "/moderation/suspicious_users" }; -} + + public static readonly HelixEndpoint GetPinnedChatMessage = new() + { + Method = HttpMethod.Get, + Route = "/chat/pins" + }; + + public static readonly HelixEndpoint PinChatMessage = new() + { + Method = HttpMethod.Put, + Route = "/chat/pins" + }; + + public static readonly HelixEndpoint UpdatePinnedChatMessage = new() + { + Method = HttpMethod.Patch, + Route = "/chat/pins" + }; + + public static readonly HelixEndpoint UnpinChatMessage = new() + { + Method = HttpMethod.Delete, + Route = "/chat/pins" + }; +} \ No newline at end of file diff --git a/MiniTwitch.Helix/Internal/Models/QueryParams.cs b/MiniTwitch.Helix/Internal/Models/QueryParams.cs index d2bdbf2..f73b4f6 100644 --- a/MiniTwitch.Helix/Internal/Models/QueryParams.cs +++ b/MiniTwitch.Helix/Internal/Models/QueryParams.cs @@ -71,6 +71,7 @@ internal static class QueryParams public const string ClipId = "clip_id"; public const string Title = "title"; public const string Duration = "duration"; + public const string DurationSeconds = "duration_seconds"; public const string VodId = "vod_id"; public const string VodOffset = "vod_offset"; -} +} \ No newline at end of file diff --git a/MiniTwitch.Helix/Responses/PinnedChatMessages.cs b/MiniTwitch.Helix/Responses/PinnedChatMessages.cs new file mode 100644 index 0000000..12031d1 --- /dev/null +++ b/MiniTwitch.Helix/Responses/PinnedChatMessages.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; +using MiniTwitch.Helix.Enums; +using MiniTwitch.Helix.Internal.Json; +using MiniTwitch.Helix.Models; + +namespace MiniTwitch.Helix.Responses; + +public class PinnedChatMessages : BaseResponse +{ + public record Pin( + [property: JsonPropertyName("message_id")] + string MessageId, + [property: JsonPropertyName("broadcaster_id")] + string BroadcasterId, + [property: JsonPropertyName("sender_user_id")] + string SenderUserId, + [property: JsonPropertyName("sender_user_login")] + string SenderUserLogin, + [property: JsonPropertyName("sender_user_name")] + string SenderUserDisplayName, + [property: JsonPropertyName("pinned_by_user_id")] + string PinnedByUserId, + [property: JsonPropertyName("pinned_by_user_login")] + string PinnedByUserLogin, + [property: JsonPropertyName("pinned_by_user_name")] + string PinnedByUserDisplayName, + [property: JsonPropertyName("message")] + Message Message, + [property: JsonPropertyName("starts_at")] + DateTimeOffset StartsAt, + [property: JsonPropertyName("ends_at")] + DateTimeOffset? EndsAt, + [property: JsonPropertyName("updated_at")] + DateTimeOffset UpdatedAt + ); + + public record Message( + [property: JsonPropertyName("text")] + string Text, + [property: JsonPropertyName("fragments")] + IReadOnlyList Fragments + ); + + public record MessageFragment( + [property: JsonPropertyName("type")] + [property: JsonConverter(typeof(EnumConverter))] + MessageFragmentType Type, + [property: JsonPropertyName("text")] + string Text, + [property: JsonPropertyName("cheermote")] + CheermoteFragment? Cheermote, + [property: JsonPropertyName("emote")] + EmoteFragment? Emote, + [property: JsonPropertyName("mention")] + MentionFragment? Mention + ); + + public record CheermoteFragment( + [property: JsonPropertyName("prefix")] + string Prefix, + [property: JsonPropertyName("bits")] + int Bits, + [property: JsonPropertyName("tier")] + int Tier + ); + + public record EmoteFragment( + [property: JsonPropertyName("id")] + string Id, + [property: JsonPropertyName("emote_set_id")] + string EmoteSetId, + [property: JsonPropertyName("owner_id")] + string OwnerId, + [property: JsonPropertyName("format")] + IReadOnlyList Format + ); + + public record MentionFragment( + [property: JsonPropertyName("user_id")] + string UserId, + [property: JsonPropertyName("user_login")] + string UserLogin, + [property: JsonPropertyName("user_name")] + string UserDisplayName + ); +} \ No newline at end of file From 530f861ca645e74963d27cdd6ec263f4d0fe7905 Mon Sep 17 00:00:00 2001 From: James Upjohn Date: Sun, 17 May 2026 11:59:03 +1200 Subject: [PATCH 2/3] feat(test): cover pinned message deserialization --- .../DataSources/PinnedMessageDataSource.cs | 136 ++++++++++++++++++ MiniTwitch.Helix.Test/DeserializeTest.cs | 11 +- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 MiniTwitch.Helix.Test/DataSources/PinnedMessageDataSource.cs diff --git a/MiniTwitch.Helix.Test/DataSources/PinnedMessageDataSource.cs b/MiniTwitch.Helix.Test/DataSources/PinnedMessageDataSource.cs new file mode 100644 index 0000000..74aaaf9 --- /dev/null +++ b/MiniTwitch.Helix.Test/DataSources/PinnedMessageDataSource.cs @@ -0,0 +1,136 @@ +using System.Collections; +using MiniTwitch.Helix.Enums; +using MiniTwitch.Helix.Internal.Json; +using MiniTwitch.Helix.Responses; + +namespace MiniTwitch.Helix.Test.DataSources; + +public class PinnedMessageDataSource : IEnumerable +{ + private static readonly PinnedChatMessages.Pin Base = new( + MessageId: "a42a84b2-7ad7-4ac1-95bb-0843d70e005a", + BroadcasterId: "46673989", + SenderUserId: "77481472", + SenderUserLogin: "jpsauce", + SenderUserDisplayName: "JPSauce", + PinnedByUserId: "82674227", + PinnedByUserLogin: "jammehge", + PinnedByUserDisplayName: "JAMMEHGE", + Message: new PinnedChatMessages.Message( + Text: "fix it! now!!!", + Fragments: [ + new PinnedChatMessages.MessageFragment( + Type: MessageFragmentType.Text, + Text: "fix it! now!!!", + Cheermote: null, + Emote: null, + Mention: null + ) + ] + ), + StartsAt: new DateTimeOffset(2026, 05, 16, 23, 10, 16, TimeSpan.Zero), + EndsAt: new DateTimeOffset(2026, 01, 02, 03, 04, 55, TimeSpan.Zero), + UpdatedAt: new DateTimeOffset(2026, 01, 02, 03, 04, 55, TimeSpan.Zero) + ); + + private static string JsonFromModel(PinnedChatMessages.Pin pin) => + $$""" + { + "data": [ + { + "broadcaster_id": "{{pin.BroadcasterId}}", + "ends_at": {{(pin.EndsAt is {} ea ? $"\"{ea:yyyy-MM-ddTHH:mm:sszzz}\"" : "null")}}, + "message": { + "fragments": [ + {{string.Join(", ", pin.Message.Fragments.Select(BuildFragment))}} + ], + "text": "{{pin.Message.Text}}" + }, + "message_id": "{{pin.MessageId}}", + "pinned_by_user_id": "{{pin.PinnedByUserId}}", + "pinned_by_user_login": "{{pin.PinnedByUserLogin}}", + "pinned_by_user_name": "{{pin.PinnedByUserDisplayName}}", + "sender_user_id": "{{pin.SenderUserId}}", + "sender_user_login": "{{pin.SenderUserLogin}}", + "sender_user_name": "{{pin.SenderUserDisplayName}}", + "starts_at": "{{pin.StartsAt:yyyy-MM-ddTHH:mm:sszzz}}", + "updated_at": "{{pin.UpdatedAt:yyyy-MM-ddTHH:mm:sszzz}}" + } + ] + } + """; + + private static string BuildFragment(PinnedChatMessages.MessageFragment fragment) => + $$""" + { + "cheermote": {{(fragment.Cheermote is { } c ? $"{{\"prefix\": \"{c.Prefix}\", \"bits\": {c.Bits}, \"tier\": {c.Tier}}}" : "null")}}, + "emote": {{(fragment.Emote is { } e ? $"{{\"id\": \"{e.Id}\", \"emote_set_id\": \"{e.EmoteSetId}\", \"owner_id\": \"{e.OwnerId}\", \"format\": [{string.Join(", ", e.Format.Select(f => $"\"{f}\""))}]}}" : "null")}}, + "mention": {{(fragment.Mention is { } m ? $"{{\"user_id\": \"{m.UserId}\", \"user_login\": \"{m.UserLogin}\", \"user_name\": \"{m.UserDisplayName}\"}}" : "null")}}, + "text": "{{fragment.Text}}", + "type": "{{SnakeCase.Instance.ConvertToCase(fragment.Type.ToString())}}" + } + """; + + private static IEnumerable Cases => + [ + Base with { EndsAt = null }, + Base with + { + Message = Base.Message with + { + Fragments = + [ + Base.Message.Fragments[0] with + { + Type = MessageFragmentType.Cheermote, + Cheermote = new PinnedChatMessages.CheermoteFragment("prefix_1", 1, 2), + Emote = null, + Mention = null + } + ] + } + }, + Base with + { + Message = Base.Message with + { + Fragments = + [ + Base.Message.Fragments[0] with + { + Type = MessageFragmentType.Emote, + Cheermote = null, + Emote = new PinnedChatMessages.EmoteFragment("emoteId_1", "emoteSetId_1", "ownerId_1", ["format_1"]), + Mention = null + } + ] + } + }, + Base with + { + Message = Base.Message with + { + Fragments = + [ + Base.Message.Fragments[0] with + { + Type = MessageFragmentType.Mention, + Cheermote = null, + Emote = null, + Mention = new PinnedChatMessages.MentionFragment("userId_1", "userLogin_1", "userDisplayName_1") + } + ] + } + } + ]; + + public IEnumerator GetEnumerator() => Cases + .Select(@case => new object[] + { + JsonFromModel(@case), + new PinnedChatMessages { Data = [@case] } + }) + .GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} \ No newline at end of file diff --git a/MiniTwitch.Helix.Test/DeserializeTest.cs b/MiniTwitch.Helix.Test/DeserializeTest.cs index 7f50e2f..74e2ad0 100644 --- a/MiniTwitch.Helix.Test/DeserializeTest.cs +++ b/MiniTwitch.Helix.Test/DeserializeTest.cs @@ -3,6 +3,7 @@ using MiniTwitch.Helix.Internal; using MiniTwitch.Helix.Requests; using MiniTwitch.Helix.Responses; +using MiniTwitch.Helix.Test.DataSources; namespace MiniTwitch.Helix.Test; @@ -180,5 +181,13 @@ public void AddSuspiciousStatusToChatUser() var asJson = JsonSerializer.Serialize(requestObject, options); Assert.Equal(requestJson, asJson); } -} + [Theory] + [ClassData(typeof(PinnedMessageDataSource))] + public void GetPinnedMessage(string jsonResponse, PinnedChatMessages expected) + { + var result = JsonSerializer.Deserialize(jsonResponse, options); + + Assert.Equivalent(expected, result); + } +} \ No newline at end of file From 5f33887695651c0479dc6e2380d683a93d316bf2 Mon Sep 17 00:00:00 2001 From: James Upjohn Date: Sun, 17 May 2026 12:19:17 +1200 Subject: [PATCH 3/3] feat(helix): support `pin` property in send chat request --- MiniTwitch.Helix/Requests/ChatMessage.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/MiniTwitch.Helix/Requests/ChatMessage.cs b/MiniTwitch.Helix/Requests/ChatMessage.cs index 95fa142..4958daa 100644 --- a/MiniTwitch.Helix/Requests/ChatMessage.cs +++ b/MiniTwitch.Helix/Requests/ChatMessage.cs @@ -3,7 +3,7 @@ namespace MiniTwitch.Helix.Requests; -public class ChatMessage(long broadcasterId, string message, string? replyParentMessageId = null, bool? forSourceOnly = null) +public class ChatMessage(long broadcasterId, string message, string? replyParentMessageId = null, bool? forSourceOnly = null, bool? shouldPin = null) { [JsonConverter(typeof(LongConverter))] public long BroadcasterId { get; } = broadcasterId; @@ -14,4 +14,7 @@ public class ChatMessage(long broadcasterId, string message, string? replyParent /// This value is assigned automatically /// public long SenderId { get; internal set; } + + [JsonPropertyName("pin")] + public bool? Pin { get; } = shouldPin; } \ No newline at end of file