From 36e0fa4784a7d0bd4369e9f6c658bd5f358788f8 Mon Sep 17 00:00:00 2001 From: John Korsnes Date: Mon, 21 Sep 2026 17:42:05 +0200 Subject: [PATCH] feat: add conversations.info with include_num_members, fix conversations.members deserialization conversations.info gives per-channel member counts (Slack docs: Tier 3, 50+/min), needed to compute a Slack reach metric analogous to Discord's approximate_member_count. conversations.members was deserializing into ConversationsListResponse (a Channels list), silently dropping the actual members array - fixed with a proper response type. --- .../ISlackClient.cs | 16 +++- .../ConversationsInfoResponse.cs | 17 +++++ .../ConversationsMembersResponse.cs | 10 +++ .../SlackClient.cs | 17 ++++- ...tionsInfoAndMembersDeserializationTests.cs | 73 +++++++++++++++++++ 5 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 source/src/Slackbot.Net.SlackClients.Http/Models/Responses/ConversationsInfo/ConversationsInfoResponse.cs create mode 100644 source/src/Slackbot.Net.SlackClients.Http/Models/Responses/ConversationsMembers/ConversationsMembersResponse.cs create mode 100644 source/test/Slackbot.Net.SlackClients.Http.Tests/ConversationsInfoAndMembersDeserializationTests.cs diff --git a/source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs b/source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs index 5f20a44..452c62d 100644 --- a/source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs +++ b/source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs @@ -10,7 +10,9 @@ using Slackbot.Net.SlackClients.Http.Models.Responses.ChatGetPermalink; using Slackbot.Net.SlackClients.Http.Models.Responses.ChatPostMessage; using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsHistoryResponse; +using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsInfo; using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsList; +using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsMembers; using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsRepliesResponse; using Slackbot.Net.SlackClients.Http.Models.Responses.FileUpload; using Slackbot.Net.SlackClients.Http.Models.Responses.UserProfile; @@ -75,12 +77,18 @@ public interface ISlackClient /// /// Scopes required: channels:read | groups:read | im:read - /// Only requests `public_channel` types of conversations + /// Returns the member user ids of a conversation. /// /// https://api.slack.com/methods/conversations.members - Task ConversationsMembers(string channel); - - + Task ConversationsMembers(string channel); + + /// + /// Scopes required: channels:read | groups:read | im:read | mpim:read + /// + /// https://api.slack.com/methods/conversations.info + Task ConversationsInfo(string channel, bool includeNumMembers = false); + + /// /// Scopes required: channels:history/groups:history/im:history or mpim:history /// diff --git a/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/ConversationsInfo/ConversationsInfoResponse.cs b/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/ConversationsInfo/ConversationsInfoResponse.cs new file mode 100644 index 0000000..20024ec --- /dev/null +++ b/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/ConversationsInfo/ConversationsInfoResponse.cs @@ -0,0 +1,17 @@ +namespace Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsInfo; + +public class ConversationsInfoResponse : Response +{ + public ConversationInfo Channel { get; set; } +} + +public class ConversationInfo +{ + public string Id { get; set; } + public string Name { get; set; } + public bool Is_Channel { get; set; } + public bool Is_Archived { get; set; } + + // Only populated when include_num_members=true is passed. + public int? Num_Members { get; set; } +} diff --git a/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/ConversationsMembers/ConversationsMembersResponse.cs b/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/ConversationsMembers/ConversationsMembersResponse.cs new file mode 100644 index 0000000..b23a88f --- /dev/null +++ b/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/ConversationsMembers/ConversationsMembersResponse.cs @@ -0,0 +1,10 @@ +using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsList; + +namespace Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsMembers; + +public class ConversationsMembersResponse : Response +{ + public IEnumerable Members { get; set; } + + public ResponseMetadata Response_Metadata { get; set; } +} diff --git a/source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs b/source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs index 2f9771e..0ef41a5 100644 --- a/source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs +++ b/source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs @@ -12,7 +12,9 @@ using Slackbot.Net.SlackClients.Http.Models.Responses.ChatGetPermalink; using Slackbot.Net.SlackClients.Http.Models.Responses.ChatPostMessage; using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsHistoryResponse; +using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsInfo; using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsList; +using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsMembers; using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsRepliesResponse; using Slackbot.Net.SlackClients.Http.Models.Responses.FileUpload; using Slackbot.Net.SlackClients.Http.Models.Responses.UserProfile; @@ -113,13 +115,24 @@ public async Task ConversationsListPublicChannels(int } /// - public async Task ConversationsMembers(string channel) + public async Task ConversationsMembers(string channel) { var parameters = new List> { new KeyValuePair("channel", channel) }; - return await _client.PostParametersAsForm(parameters, "conversations.members", s => _logger.LogTrace(s)); + return await _client.PostParametersAsForm(parameters, "conversations.members", s => _logger.LogTrace(s)); + } + + /// + public async Task ConversationsInfo(string channel, bool includeNumMembers = false) + { + var parameters = new List> + { + new KeyValuePair("channel", channel), + new KeyValuePair("include_num_members", includeNumMembers ? "true" : "false") + }; + return await _client.PostParametersAsForm(parameters, "conversations.info", s => _logger.LogTrace(s)); } /// diff --git a/source/test/Slackbot.Net.SlackClients.Http.Tests/ConversationsInfoAndMembersDeserializationTests.cs b/source/test/Slackbot.Net.SlackClients.Http.Tests/ConversationsInfoAndMembersDeserializationTests.cs new file mode 100644 index 0000000..ae78e9a --- /dev/null +++ b/source/test/Slackbot.Net.SlackClients.Http.Tests/ConversationsInfoAndMembersDeserializationTests.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; +using Slackbot.Net.SlackClients.Http; +using Slackbot.Net.Tests.Helpers; + +namespace Slackbot.Net.Tests; + +// Self-contained: deserializes realistic Slack payloads through the real SlackClient +// path (no network, no credentials). +public class ConversationsInfoAndMembersDeserializationTests(ITestOutputHelper helper) +{ + private ISlackClient ClientReturning(string json) => + new SlackClient( + new HttpClient(new StubHttpMessageHandler(json)) { BaseAddress = new Uri("https://slack.com/api/") }, + new XUnitLogger(helper)); + + // Based on https://docs.slack.dev/reference/methods/conversations.info + [Fact] + public async Task ConversationsInfo_PopulatesNumMembers_WhenRequested() + { + var json = """ + { + "ok": true, + "channel": { + "id": "C012AB3CD", + "name": "general", + "is_channel": true, + "is_archived": false, + "num_members": 6 + } + } + """; + var response = await ClientReturning(json).ConversationsInfo("C012AB3CD", includeNumMembers: true); + + Assert.True(response.Ok); + Assert.Equal("C012AB3CD", response.Channel.Id); + Assert.Equal("general", response.Channel.Name); + Assert.True(response.Channel.Is_Channel); + Assert.False(response.Channel.Is_Archived); + Assert.Equal(6, response.Channel.Num_Members); + } + + // num_members is only present when include_num_members=true was passed on the request. + [Fact] + public async Task ConversationsInfo_NumMembersIsNull_WhenNotRequested() + { + var json = """ + { "ok": true, "channel": { "id": "C012AB3CD", "name": "general", "is_channel": true, "is_archived": false } } + """; + var response = await ClientReturning(json).ConversationsInfo("C012AB3CD"); + + Assert.True(response.Ok); + Assert.Null(response.Channel.Num_Members); + } + + // Based on https://docs.slack.dev/reference/methods/conversations.members - a flat list of + // member user ids, not a list of Conversation objects. + [Fact] + public async Task ConversationsMembers_PopulatesMemberIds_AndPagination() + { + var json = """ + { + "ok": true, + "members": ["U023BECGF", "U061F7AUR", "W012A3CDE"], + "response_metadata": { "next_cursor": "dXNlcjpVMDYxTkZUVDI=" } + } + """; + var response = await ClientReturning(json).ConversationsMembers("C012AB3CD"); + + Assert.True(response.Ok); + Assert.Equal(["U023BECGF", "U061F7AUR", "W012A3CDE"], response.Members); + Assert.Equal("dXNlcjpVMDYxTkZUVDI=", response.Response_Metadata.Next_Cursor); + } +}