Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Slackbot.Net.Endpoints.Models.Events;

namespace Slackbot.Net.Endpoints.Abstractions;

public interface IHandleReactionAdded
{
Task<EventHandledResponse> Handle(EventMetaData eventMetadata, ReactionAddedEvent slackEvent);

bool ShouldHandle(ReactionAddedEvent slackEvent)
{
return true;
}
}
1 change: 1 addition & 0 deletions source/src/Slackbot.Net.Endpoints/EventTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ public static class EventTypes
public const string TeamJoin = "team_join";
public const string EmojiChanged = "emoji_changed";
public const string Message = "message";
public const string ReactionAdded = "reaction_added";
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public static IApplicationBuilder UseSlackbot(
app.MapWhen(TeamJoinEvents.ShouldRun, b => b.UseMiddleware<TeamJoinEvents>());
app.MapWhen(EmojiChangedEvents.ShouldRun, b => b.UseMiddleware<EmojiChangedEvents>());
app.MapWhen(MessageEvents.ShouldRun, b => b.UseMiddleware<MessageEvents>());
app.MapWhen(ReactionAddedEvents.ShouldRun, b => b.UseMiddleware<ReactionAddedEvents>());

return app;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ public ISlackbotHandlersBuilder AddInteractiveBlockActionsHandler<T>()
public ISlackbotHandlersBuilder AddTeamJoinHandler<T>() where T : class, IHandleTeamJoin;
public ISlackbotHandlersBuilder AddEmojiChangedHandler<T>() where T : class, IHandleEmojiChanged;
public ISlackbotHandlersBuilder AddMessageHandler<T>() where T : class, IHandleMessage;
public ISlackbotHandlersBuilder AddReactionAddedHandler<T>() where T : class, IHandleReactionAdded;
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,10 @@ public ISlackbotHandlersBuilder AddMessageHandler<T>() where T : class, IHandleM
services.AddSingleton<IHandleMessage, T>();
return this;
}

public ISlackbotHandlersBuilder AddReactionAddedHandler<T>() where T : class, IHandleReactionAdded
{
services.AddSingleton<IHandleReactionAdded, T>();
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ private static SlackEvent ToEventType(JsonElement eventJson, string raw)
return JsonSerializer.Deserialize<EmojiChangedEvent>(json, WebOptions);
case EventTypes.Message:
return JsonSerializer.Deserialize<MessageEvent>(json, WebOptions);
case EventTypes.ReactionAdded:
return JsonSerializer.Deserialize<ReactionAddedEvent>(json, WebOptions);
default:
var unknownSlackEvent = JsonSerializer.Deserialize<UnknownSlackEvent>(json, WebOptions);
unknownSlackEvent.RawJson = raw;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Slackbot.Net.Endpoints.Abstractions;
using Slackbot.Net.Endpoints.Models.Events;

namespace Slackbot.Net.Endpoints.Middlewares;

public class ReactionAddedEvents(
RequestDelegate next,
ILogger<ReactionAddedEvents> logger,
IEnumerable<IHandleReactionAdded> responseHandlers
)
{
public async Task Invoke(HttpContext context)
{
var reactionAdded = (ReactionAddedEvent)context.Items[HttpItemKeys.SlackEventKey];
var metadata = (EventMetaData)context.Items[HttpItemKeys.EventMetadataKey];
var handlers = responseHandlers.Where(h => h.ShouldHandle(reactionAdded));

logger.BeginScope(new Dictionary<string, object>
{
["Slack_TeamId"] = metadata?.Team_Id,
["Slack_User"] = reactionAdded?.User
});

foreach (var handler in handlers)
{
try
{
logger.LogInformation("Handling using {HandlerType}", handler.GetType());
var response = await handler.Handle(metadata, reactionAdded);
logger.LogInformation("Handler response: {Response}", response.Response);
}
catch (Exception e)
{
logger.LogError(e, e.Message);
}
}

context.Response.StatusCode = 200;
}

public static bool ShouldRun(HttpContext ctx)
{
return ctx.Items.ContainsKey(HttpItemKeys.EventTypeKey)
&& ctx.Items[HttpItemKeys.EventTypeKey].ToString() == EventTypes.ReactionAdded;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Slackbot.Net.Endpoints.Models.Events;

// https://docs.slack.dev/reference/events/reaction_added
public class ReactionAddedEvent : SlackEvent
{
public string User { get; set; }
public string Reaction { get; set; }
public string Item_User { get; set; }
public string Event_Ts { get; set; }
public ReactionItem Item { get; set; }
}

public class ReactionItem
{
public string Type { get; set; } // message | file | file_comment
public string Channel { get; set; } // message
public string Ts { get; set; } // message
public string File { get; set; } // file, file_comment
public string File_Comment { get; set; } // file_comment
}
8 changes: 8 additions & 0 deletions source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Slackbot.Net.SlackClients.Http.Models.Responses;
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.ConversationsList;
using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsRepliesResponse;
using Slackbot.Net.SlackClients.Http.Models.Responses.FileUpload;
Expand Down Expand Up @@ -83,6 +84,13 @@ public interface ISlackClient
/// <remarks>https://api.slack.com/methods/conversations.list</remarks>
Task<ConversationsRepliesResponse> ConversationsReplies(string channel, string ts, int? limit = null, string cursor = null);

/// <summary>
/// Scopes required: channels:history/groups:history/im:history or mpim:history
/// Fetches a conversation's message history. Bot/app-authored messages carry Bot_Id and SubType.
/// </summary>
/// <remarks>https://api.slack.com/methods/conversations.history</remarks>
Task<ConversationsHistoryResponse> ConversationsHistory(string channel, int? limit = null, string cursor = null);

/// <summary>
/// Scopes required: channels:manage | groups:write | im:write | mpim:write
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsList;
using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsRepliesResponse;

namespace Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsHistoryResponse;

public class ConversationsHistoryResponse : Response
{
public Message[] Messages { get; set; }
public bool Has_More { get; set; }
public ResponseMetadata Response_Metadata { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,17 @@ public class Message
public string Thread_Ts { get; set; }
public string Parent_User_id { get; set; }
public string Ts { get; set; }

// Populated on bot/app-authored messages. Reliable bot detection: Bot_Id != null.
// See https://docs.slack.dev/reference/events/message/bot_message
public string Bot_Id { get; set; }
public string App_Id { get; set; }
public BotProfile Bot_Profile { get; set; }
}

public class BotProfile
{
public string Id { get; set; }
public string App_Id { get; set; }
public string Name { get; set; }
}
21 changes: 21 additions & 0 deletions source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Slackbot.Net.SlackClients.Http.Models.Responses;
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.ConversationsList;
using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsRepliesResponse;
using Slackbot.Net.SlackClients.Http.Models.Responses.FileUpload;
Expand Down Expand Up @@ -128,9 +129,29 @@ public async Task<ConversationsRepliesResponse> ConversationsReplies(string chan
new KeyValuePair<string, string>("limit", (limit ?? 1000).ToString()),
new KeyValuePair<string, string>("include_all_metadata", "true"),
};
if (cursor != null)
{
parameters.Add(new KeyValuePair<string, string>("cursor", cursor));
}
return await _client.PostParametersAsForm<ConversationsRepliesResponse>(parameters, "conversations.replies", s => _logger.LogTrace(s));
}

/// <inheritdoc/>
public async Task<ConversationsHistoryResponse> ConversationsHistory(string channel, int? limit = null, string cursor = null)
{
var parameters = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("channel", channel),
new KeyValuePair<string, string>("limit", (limit ?? 100).ToString()),
new KeyValuePair<string, string>("include_all_metadata", "true"),
};
if (cursor != null)
{
parameters.Add(new KeyValuePair<string, string>("cursor", cursor));
}
return await _client.PostParametersAsForm<ConversationsHistoryResponse>(parameters, "conversations.history", s => _logger.LogTrace(s));
}

/// <inheritdoc/>
public async Task<ConversationsOpenResponse> ConversationsOpen(string[] users)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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), asserting the bot-author fields populate.
public class ConversationsBotMessageDeserializationTests(ITestOutputHelper helper)
{
private ISlackClient ClientReturning(string json) =>
new SlackClient(
new HttpClient(new StubHttpMessageHandler(json)) { BaseAddress = new Uri("https://slack.com/api/") },
new XUnitLogger<ISlackClient>(helper));

// Based on https://docs.slack.dev/reference/events/message/bot_message
private const string BotMessageJson = """
{
"type": "message",
"subtype": "bot_message",
"text": "Pushing is the answer",
"ts": "1358877455.000010",
"username": "github",
"bot_id": "BB12033",
"bot_profile": { "id": "BB12033", "app_id": "A123ABC456", "name": "github" }
}
""";

private const string HumanMessageJson = """
{ "type": "message", "user": "U123ABC456", "text": "hello there", "ts": "1512085950.000216" }
""";

[Fact]
public async Task ConversationsReplies_PopulatesBotFields_OnBotMessage()
{
var json = $$"""
{ "ok": true, "messages": [ {{HumanMessageJson}}, {{BotMessageJson}} ], "has_more": false }
""";
var response = await ClientReturning(json).ConversationsReplies("C0EC3DG5N", "1358877455.000010");

Assert.True(response.Ok);

var bot = Assert.Single(response.Messages, m => m.Bot_Id != null);
Assert.Equal("BB12033", bot.Bot_Id);
Assert.NotNull(bot.Bot_Profile);
Assert.Equal("github", bot.Bot_Profile.Name);
Assert.Equal("A123ABC456", bot.Bot_Profile.App_Id);

var human = Assert.Single(response.Messages, m => m.User == "U123ABC456");
Assert.Null(human.Bot_Id);
Assert.Null(human.Bot_Profile);
}

// Real conversations.replies shape for an app/bot user: bot_id + app_id set,
// user present, no bot_profile. Bot_Id is the reliable detector.
[Fact]
public async Task ConversationsReplies_AppBotMessage_PopulatesBotIdAndAppId()
{
var json = """
{
"ok": true,
"messages": [
{ "user": "U8TRAM5DF", "bot_id": null, "app_id": null, "subtype": null, "ts": "1780072138.186269", "text": "<@U08SS4D12PQ> this is an app mention" },
{ "user": "U08SS4D12PQ", "bot_id": "B08SS4CUV0E", "app_id": "A08SX23BHS9", "subtype": null, "ts": "1780072145.328429", "text": "Hei! :wave:" }
]
}
""";
var response = await ClientReturning(json).ConversationsReplies("C0EC3DG5N", "1780072138.186269");

Assert.True(response.Ok);

var bot = Assert.Single(response.Messages, m => m.Bot_Id != null);
Assert.Equal("B08SS4CUV0E", bot.Bot_Id);
Assert.Equal("A08SX23BHS9", bot.App_Id);
Assert.Equal("U08SS4D12PQ", bot.User); // app-bot users still carry a user id
Assert.Null(bot.Bot_Profile);

var human = Assert.Single(response.Messages, m => m.Bot_Id == null);
Assert.Equal("U8TRAM5DF", human.User);
Assert.Null(human.App_Id);
}

[Fact]
public async Task ConversationsHistory_PopulatesBotFields_AndPagination()
{
var json = $$"""
{
"ok": true,
"messages": [ {{BotMessageJson}}, {{HumanMessageJson}} ],
"has_more": true,
"response_metadata": { "next_cursor": "bmV4dF90czoxNTEyMDg1ODYxMDAwNTQz" }
}
""";
var response = await ClientReturning(json).ConversationsHistory("C0EC3DG5N");

Assert.True(response.Ok);
Assert.True(response.Has_More);
Assert.Equal("bmV4dF90czoxNTEyMDg1ODYxMDAwNTQz", response.Response_Metadata.Next_Cursor);

var bot = Assert.Single(response.Messages, m => m.Bot_Id != null);
Assert.Equal("BB12033", bot.Bot_Id);
Assert.Equal("github", bot.Bot_Profile.Name);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Net;
using System.Text;

namespace Slackbot.Net.Tests.Helpers;

// Returns a canned JSON body for any request, so the real SlackClient deserialization
// path (including its JsonSerializerOptions/naming policy) runs without hitting Slack.
public class StubHttpMessageHandler(string json) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
}