diff --git a/SlackNet.AspNetCore/AspNetCoreExtensions.cs b/SlackNet.AspNetCore/AspNetCoreExtensions.cs index a8998b5..064fb15 100644 --- a/SlackNet.AspNetCore/AspNetCoreExtensions.cs +++ b/SlackNet.AspNetCore/AspNetCoreExtensions.cs @@ -21,6 +21,7 @@ public static IServiceCollection AddSlackNet(this IServiceCollection serviceColl serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.AddTransient(c => new SlackApiClient(c.GetService(), c.GetService(), c.GetService(), configuration.ApiToken)); diff --git a/SlackNet.AspNetCore/ResolvedSlashCommandHandler.cs b/SlackNet.AspNetCore/ResolvedSlashCommandHandler.cs new file mode 100644 index 0000000..f697f09 --- /dev/null +++ b/SlackNet.AspNetCore/ResolvedSlashCommandHandler.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SlackNet.Interaction; + +namespace SlackNet.AspNetCore +{ + abstract class ResolvedSlashCommandHandler : ISlashCommandHandler + { + protected ResolvedSlashCommandHandler(string command) => Command = command; + + public string Command { get; } + + public abstract Task Handle(SlashCommand command); + } + + class ResolvedSlashCommandHandler : ResolvedSlashCommandHandler + where T : ISlashCommandHandler + { + private readonly IServiceProvider _serviceProvider; + + public ResolvedSlashCommandHandler(IServiceProvider serviceProvider, string command) + : base(command) + => _serviceProvider = serviceProvider; + + public override async Task Handle(SlashCommand command) + { + using (var scope = _serviceProvider.CreateScope()) + { + var handler = scope.ServiceProvider.GetRequiredService(); + return await handler.Handle(command).ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/SlackNet.AspNetCore/SlackEndpointConfiguration.cs b/SlackNet.AspNetCore/SlackEndpointConfiguration.cs index b7bcb62..da9b74e 100644 --- a/SlackNet.AspNetCore/SlackEndpointConfiguration.cs +++ b/SlackNet.AspNetCore/SlackEndpointConfiguration.cs @@ -6,8 +6,9 @@ public class SlackEndpointConfiguration /// Sets the path to receive Slack requests on. Defaults to "slack". /// Configures the following routes: ///
/{RoutePrefix}/event - Event subscriptions - ///
/{RoutePrefix}/action - Interactive components requests + ///
/{RoutePrefix}/action - Interactive component requests ///
/{RoutePrefix}/options - Options loading (for message menus) + ///
/{RoutePrefix}/command - Slash command requests /// public SlackEndpointConfiguration MapToPrefix(string routePrefix) { diff --git a/SlackNet.AspNetCore/SlackRequestHandler.cs b/SlackNet.AspNetCore/SlackRequestHandler.cs index ea8a77e..4d4fe3c 100644 --- a/SlackNet.AspNetCore/SlackRequestHandler.cs +++ b/SlackNet.AspNetCore/SlackRequestHandler.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using SlackNet.Events; using SlackNet.Interaction; @@ -18,6 +19,7 @@ public interface ISlackRequestHandler Task HandleEventRequest(HttpRequest request, SlackEndpointConfiguration config); Task HandleActionRequest(HttpRequest request, SlackEndpointConfiguration config); Task HandleOptionsRequest(HttpRequest request, SlackEndpointConfiguration config); + Task HandleSlashCommandRequest(HttpRequest request, SlackEndpointConfiguration config); } class SlackRequestHandler : ISlackRequestHandler @@ -30,6 +32,7 @@ class SlackRequestHandler : ISlackRequestHandler private readonly ISlackOptions _slackOptions; private readonly IDialogSubmissionHandler _dialogSubmissionHandler; private readonly ISlackViews _slackViews; + private readonly ISlackSlashCommands _slackSlashCommands; private readonly SlackJsonSettings _jsonSettings; public SlackRequestHandler( @@ -41,6 +44,7 @@ public SlackRequestHandler( ISlackOptions slackOptions, IDialogSubmissionHandler dialogSubmissionHandler, ISlackViews slackViews, + ISlackSlashCommands slackSlashCommands, SlackJsonSettings jsonSettings) { _slackEvents = slackEvents; @@ -51,6 +55,7 @@ public SlackRequestHandler( _slackOptions = slackOptions; _dialogSubmissionHandler = dialogSubmissionHandler; _slackViews = slackViews; + _slackSlashCommands = slackSlashCommands; _jsonSettings = jsonSettings; } @@ -193,6 +198,25 @@ public async Task HandleOptionsRequest(HttpRequest request, Slack return new StringResponse(HttpStatusCode.BadRequest, "Invalid token or unrecognized content"); } + public async Task HandleSlashCommandRequest(HttpRequest request, SlackEndpointConfiguration config) + { + if (request.Method != "POST") + return new EmptyResponse(HttpStatusCode.MethodNotAllowed); + + ReplaceRequestStreamWithMemoryStream(request); + + var command = await DeserializeForm(request).ConfigureAwait(false); + + if (!VerifyRequest(await ReadString(request).ConfigureAwait(false), request.Headers, command.Token, config)) + return new StringResponse(HttpStatusCode.BadRequest, "Invalid signature/token"); + + var response = await _slackSlashCommands.Handle(command).ConfigureAwait(false); + + return response == null + ? (SlackResponse)new EmptyResponse(HttpStatusCode.OK) + : new JsonResponse(HttpStatusCode.OK, new SlashCommandMessageResponse(response)); + } + private static async void ReplaceRequestStreamWithMemoryStream(HttpRequest request) { var buffer = new MemoryStream(); @@ -216,14 +240,31 @@ private async Task HandleBlockOptionsRequest(BlockOptionsRequest private async Task DeserializePayload(HttpRequest request) { - var form = await request.ReadFormAsync().ConfigureAwait(false); - request.Body.Seek(0, SeekOrigin.Begin); + var form = await ReadForm(request).ConfigureAwait(false); return form["payload"] .Select(p => JsonConvert.DeserializeObject(p, _jsonSettings.SerializerSettings)) .FirstOrDefault(); } + private async Task DeserializeForm(HttpRequest request) + { + var form = await ReadForm(request).ConfigureAwait(false); + + var json = new JObject(); + foreach (var key in form.Keys) + json[key] = form[key].FirstOrDefault(); + + return json.ToObject(JsonSerializer.Create(_jsonSettings.SerializerSettings)); + } + + private static async Task ReadForm(HttpRequest request) + { + var form = await request.ReadFormAsync().ConfigureAwait(false); + request.Body.Seek(0, SeekOrigin.Begin); + return form; + } + private static Task ReadString(HttpRequest request) => new StreamReader(request.Body).ReadToEndAsync(); diff --git a/SlackNet.AspNetCore/SlackRequestMiddleware.cs b/SlackNet.AspNetCore/SlackRequestMiddleware.cs index d0d0e20..6b82313 100644 --- a/SlackNet.AspNetCore/SlackRequestMiddleware.cs +++ b/SlackNet.AspNetCore/SlackRequestMiddleware.cs @@ -30,6 +30,8 @@ public async Task Invoke(HttpContext context) await Respond(context.Response, await _requestHandler.HandleActionRequest(context.Request, _configuration).ConfigureAwait(false)).ConfigureAwait(false); else if (context.Request.Path == $"/{_configuration.RoutePrefix}/options") await Respond(context.Response, await _requestHandler.HandleOptionsRequest(context.Request, _configuration).ConfigureAwait(false)).ConfigureAwait(false); + else if (context.Request.Path == $"/{_configuration.RoutePrefix}/command") + await Respond(context.Response, await _requestHandler.HandleSlashCommandRequest(context.Request, _configuration).ConfigureAwait(false)).ConfigureAwait(false); else await _next(context).ConfigureAwait(false); } diff --git a/SlackNet.AspNetCore/SlackServiceConfiguration.cs b/SlackNet.AspNetCore/SlackServiceConfiguration.cs index de41904..5846f3d 100644 --- a/SlackNet.AspNetCore/SlackServiceConfiguration.cs +++ b/SlackNet.AspNetCore/SlackServiceConfiguration.cs @@ -136,6 +136,16 @@ public SlackServiceConfiguration RegisterViewSubmissionHandler(string return this; } + public SlackServiceConfiguration RegisterSlashCommandHandler(string command) + where THandler : class, ISlashCommandHandler + { + if (!command.StartsWith("/")) + throw new ArgumentException("Command must start with '/'", nameof(command)); + _serviceCollection.AddTransient(); + _serviceCollection.AddSingleton(c => new ResolvedSlashCommandHandler(c, command)); + return this; + } + public string ApiToken { get; private set; } } } \ No newline at end of file diff --git a/SlackNet.AspNetCore/SlackSlashCommandsService.cs b/SlackNet.AspNetCore/SlackSlashCommandsService.cs new file mode 100644 index 0000000..3147777 --- /dev/null +++ b/SlackNet.AspNetCore/SlackSlashCommandsService.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using SlackNet.Interaction; + +namespace SlackNet.AspNetCore +{ + class SlackSlashCommandsService : ISlackSlashCommands + { + private readonly SlackSlashCommands _commands = new SlackSlashCommands(); + + public SlackSlashCommandsService(IEnumerable handlers) + { + foreach (var handler in handlers) + SetHandler(handler.Command, handler); + } + + public Task Handle(SlashCommand command) => _commands.Handle(command); + public void SetHandler(string command, ISlashCommandHandler handler) => _commands.SetHandler(command, handler); + } +} \ No newline at end of file diff --git a/SlackNet.AzureFunctionExample/SlackEndpoints.cs b/SlackNet.AzureFunctionExample/SlackEndpoints.cs index 3f716d8..270ad89 100644 --- a/SlackNet.AzureFunctionExample/SlackEndpoints.cs +++ b/SlackNet.AzureFunctionExample/SlackEndpoints.cs @@ -38,6 +38,12 @@ public async Task Options([HttpTrigger(AuthorizationLevel.Anonymo return SlackResponse(await _requestHandler.HandleOptionsRequest(request, _endpointConfig).ConfigureAwait(false)); } + [FunctionName("options")] + public async Task Command([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest request) + { + return SlackResponse(await _requestHandler.HandleSlashCommandRequest(request, _endpointConfig).ConfigureAwait(false)); + } + private ContentResult SlackResponse(SlackResponse response) { return new ContentResult diff --git a/SlackNet.EventsExample/EchoCommand.cs b/SlackNet.EventsExample/EchoCommand.cs new file mode 100644 index 0000000..fc37c68 --- /dev/null +++ b/SlackNet.EventsExample/EchoCommand.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using SlackNet.Interaction; +using SlackNet.WebApi; + +namespace SlackNet.EventsExample +{ + public class EchoCommand : ISlashCommandHandler + { + public async Task Handle(SlashCommand command) + { + return new SlashCommandResponse + { + Message = new Message + { + Text = command.Text + } + }; + } + } +} \ No newline at end of file diff --git a/SlackNet.EventsExample/Startup.cs b/SlackNet.EventsExample/Startup.cs index b3ea4d7..8a56268 100644 --- a/SlackNet.EventsExample/Startup.cs +++ b/SlackNet.EventsExample/Startup.cs @@ -40,6 +40,8 @@ public void ConfigureServices(IServiceCollection services) .RegisterEventHandler() .RegisterBlockActionHandler() .RegisterViewSubmissionHandler(AppHome.ModalCallbackId) + + .RegisterSlashCommandHandler("/echo") ); services.AddMvc(); } diff --git a/SlackNet/Interaction/ISlashCommandHandler.cs b/SlackNet/Interaction/ISlashCommandHandler.cs new file mode 100644 index 0000000..5572814 --- /dev/null +++ b/SlackNet/Interaction/ISlashCommandHandler.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace SlackNet.Interaction +{ + public interface ISlashCommandHandler + { + Task Handle(SlashCommand command); + } +} \ No newline at end of file diff --git a/SlackNet/Interaction/MessageResponse.cs b/SlackNet/Interaction/MessageResponse.cs index a84d3eb..62c46d0 100644 --- a/SlackNet/Interaction/MessageResponse.cs +++ b/SlackNet/Interaction/MessageResponse.cs @@ -2,7 +2,12 @@ namespace SlackNet.Interaction { - public class MessageResponse + public interface IMessageResponse + { + Message Message { get; set; } + } + + public class MessageResponse : IMessageResponse { public ResponseType ResponseType { get; set; } public bool ReplaceOriginal { get; set; } diff --git a/SlackNet/Interaction/MessageResponseWrapper.cs b/SlackNet/Interaction/MessageResponseWrapper.cs new file mode 100644 index 0000000..0321353 --- /dev/null +++ b/SlackNet/Interaction/MessageResponseWrapper.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using SlackNet.Blocks; +using SlackNet.WebApi; + +namespace SlackNet.Interaction +{ + public class MessageResponseWrapper : IReadOnlyMessage + { + private readonly IMessageResponse _response; + public MessageResponseWrapper(IMessageResponse response) => _response = response; + + public string Channel => _response.Message.Channel; + public string Text => _response.Message.Text; + public ParseMode Parse => _response.Message.Parse; + public bool LinkNames => _response.Message.LinkNames; + public IList Attachments => _response.Message.Attachments; + public IList Blocks => _response.Message.Blocks; + public bool UnfurlLinks => _response.Message.UnfurlLinks; + public bool UnfurlMedia => _response.Message.UnfurlMedia; + public string Username => _response.Message.Username; + public bool AsUser => _response.Message.AsUser; + public string IconUrl => _response.Message.IconUrl; + public string IconEmoji => _response.Message.IconEmoji; + public string ThreadTs => _response.Message.ThreadTs; + public bool ReplyBroadcast => _response.Message.ReplyBroadcast; + } +} \ No newline at end of file diff --git a/SlackNet/Interaction/MessageUpdateResponse.cs b/SlackNet/Interaction/MessageUpdateResponse.cs index 677781f..07ea062 100644 --- a/SlackNet/Interaction/MessageUpdateResponse.cs +++ b/SlackNet/Interaction/MessageUpdateResponse.cs @@ -1,30 +1,12 @@ -using System.Collections.Generic; -using SlackNet.Blocks; -using SlackNet.WebApi; - -namespace SlackNet.Interaction +namespace SlackNet.Interaction { - public class MessageUpdateResponse : IReadOnlyMessage + public class MessageUpdateResponse : MessageResponseWrapper { - private readonly MessageResponse _response; - public MessageUpdateResponse(MessageResponse response) => _response = response; + private readonly MessageResponse _updateResponse; + public MessageUpdateResponse(MessageResponse response) : base(response) => _updateResponse = response; - public ResponseType ResponseType => _response.ResponseType; - public bool ReplaceOriginal => _response.ReplaceOriginal; - public bool DeleteOriginal => _response.DeleteOriginal; - public string Channel => _response.Message.Channel; - public string Text => _response.Message.Text; - public ParseMode Parse => _response.Message.Parse; - public bool LinkNames => _response.Message.LinkNames; - public IList Attachments => _response.Message.Attachments; - public IList Blocks => _response.Message.Blocks; - public bool UnfurlLinks => _response.Message.UnfurlLinks; - public bool UnfurlMedia => _response.Message.UnfurlMedia; - public string Username => _response.Message.Username; - public bool AsUser => _response.Message.AsUser; - public string IconUrl => _response.Message.IconUrl; - public string IconEmoji => _response.Message.IconEmoji; - public string ThreadTs => _response.Message.ThreadTs; - public bool ReplyBroadcast => _response.Message.ReplyBroadcast; + public ResponseType ResponseType => _updateResponse.ResponseType; + public bool ReplaceOriginal => _updateResponse.ReplaceOriginal; + public bool DeleteOriginal => _updateResponse.DeleteOriginal; } } \ No newline at end of file diff --git a/SlackNet/Interaction/SlackSlashCommands.cs b/SlackNet/Interaction/SlackSlashCommands.cs new file mode 100644 index 0000000..01ecc6a --- /dev/null +++ b/SlackNet/Interaction/SlackSlashCommands.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SlackNet.Interaction +{ + public interface ISlackSlashCommands + { + Task Handle(SlashCommand command); + void SetHandler(string command, ISlashCommandHandler handler); + } + + public class SlackSlashCommands : ISlackSlashCommands + { + private readonly Dictionary _handlers = new Dictionary(); + + public Task Handle(SlashCommand command) => + _handlers.TryGetValue(command.Command, out var handler) + ? handler.Handle(command) + : Task.FromResult((SlashCommandResponse)null); + + public void SetHandler(string command, ISlashCommandHandler handler) + { + if (!command.StartsWith("/")) + throw new ArgumentException("Command must start with '/'.", nameof(command)); + + _handlers[command] = handler; + } + } +} \ No newline at end of file diff --git a/SlackNet/Interaction/SlashCommand.cs b/SlackNet/Interaction/SlashCommand.cs new file mode 100644 index 0000000..2fcdee1 --- /dev/null +++ b/SlackNet/Interaction/SlashCommand.cs @@ -0,0 +1,54 @@ +namespace SlackNet.Interaction +{ + public class SlashCommand + { + /// + /// This is a verification token, a deprecated feature that you shouldn't use any more. + /// It was used to verify that requests were legitimately being sent by Slack to your app, + /// but you should use the signed secrets functionality to do this instead. + /// + public string Token { get; set; } + + /// + /// The command that was typed in to trigger this request. + /// + public string Command { get; set; } + + /// + /// This is the part of the Slash Command after the command itself, + /// and it can contain absolutely anything that the user might decide to type. + /// It is common to use this text parameter to provide extra context for the command. + /// + public string Text { get; set; } + + /// + /// A temporary webhook URL that you can use to generate messages responses. + /// + public string ResponseUrl { get; set; } + + /// + /// A short-lived ID that will let your app open a modal. + /// + public string TriggerId { get; set; } + + /// + /// The ID of the user who triggered the command. + /// + public string UserId { get; set; } + + /// + /// The plain text name of the user who triggered the command. + /// Do not rely on this field as it is being phased out, use the instead. + /// + public string UserName { get; set; } + + public string TeamId { get; set; } + public string TeamName { get; set; } + + public string EnterpriseId { get; set; } + public string EnterpriseName { get; set; } + + public string ChannelId { get; set; } + public string ChannelName { get; set; } + } +} \ No newline at end of file diff --git a/SlackNet/Interaction/SlashCommandMessageResponse.cs b/SlackNet/Interaction/SlashCommandMessageResponse.cs new file mode 100644 index 0000000..18ebdf0 --- /dev/null +++ b/SlackNet/Interaction/SlashCommandMessageResponse.cs @@ -0,0 +1,10 @@ +namespace SlackNet.Interaction +{ + public class SlashCommandMessageResponse : MessageResponseWrapper + { + private readonly SlashCommandResponse _commandResponse; + public SlashCommandMessageResponse(SlashCommandResponse response) : base(response) => _commandResponse = response; + + public ResponseType ResponseType => _commandResponse.ResponseType; + } +} \ No newline at end of file diff --git a/SlackNet/Interaction/SlashCommandResponse.cs b/SlackNet/Interaction/SlashCommandResponse.cs new file mode 100644 index 0000000..290fb9e --- /dev/null +++ b/SlackNet/Interaction/SlashCommandResponse.cs @@ -0,0 +1,10 @@ +using SlackNet.WebApi; + +namespace SlackNet.Interaction +{ + public class SlashCommandResponse : IMessageResponse + { + public Message Message { get; set; } + public ResponseType ResponseType { get; set; } = ResponseType.Ephemeral; + } +} \ No newline at end of file