Skip to content

Commit

Permalink
Slash commands
Browse files Browse the repository at this point in the history
resolves #26
  • Loading branch information
soxtoby committed Mar 16, 2020
1 parent b092fd5 commit 5a40142
Show file tree
Hide file tree
Showing 18 changed files with 294 additions and 29 deletions.
1 change: 1 addition & 0 deletions SlackNet.AspNetCore/AspNetCoreExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public static IServiceCollection AddSlackNet(this IServiceCollection serviceColl
serviceCollection.AddSingleton<ISlackMessageActions, SlackMessageActionsService>();
serviceCollection.AddSingleton<ISlackOptions, SlackOptionsService>();
serviceCollection.AddSingleton<ISlackViews, SlackViewsService>();
serviceCollection.AddSingleton<ISlackSlashCommands, SlackSlashCommandsService>();
serviceCollection.TryAddSingleton<IDialogSubmissionHandler, NullDialogSubmissionHandler>();
serviceCollection.AddTransient<ISlackApiClient>(c => new SlackApiClient(c.GetService<IHttp>(), c.GetService<ISlackUrlBuilder>(), c.GetService<SlackJsonSettings>(), configuration.ApiToken));

Expand Down
35 changes: 35 additions & 0 deletions SlackNet.AspNetCore/ResolvedSlashCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -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<SlashCommandResponse> Handle(SlashCommand command);
}

class ResolvedSlashCommandHandler<T> : ResolvedSlashCommandHandler
where T : ISlashCommandHandler
{
private readonly IServiceProvider _serviceProvider;

public ResolvedSlashCommandHandler(IServiceProvider serviceProvider, string command)
: base(command)
=> _serviceProvider = serviceProvider;

public override async Task<SlashCommandResponse> Handle(SlashCommand command)
{
using (var scope = _serviceProvider.CreateScope())
{
var handler = scope.ServiceProvider.GetRequiredService<T>();
return await handler.Handle(command).ConfigureAwait(false);
}
}
}
}
3 changes: 2 additions & 1 deletion SlackNet.AspNetCore/SlackEndpointConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ public class SlackEndpointConfiguration
/// Sets the path to receive Slack requests on. Defaults to "slack".
/// Configures the following routes:
/// <br /><c>/{RoutePrefix}/event</c> - Event subscriptions
/// <br /><c>/{RoutePrefix}/action</c> - Interactive components requests
/// <br /><c>/{RoutePrefix}/action</c> - Interactive component requests
/// <br /><c>/{RoutePrefix}/options</c> - Options loading (for message menus)
/// <br /><c>/{RoutePrefix}/command</c> - Slash command requests
/// </summary>
public SlackEndpointConfiguration MapToPrefix(string routePrefix)
{
Expand Down
45 changes: 43 additions & 2 deletions SlackNet.AspNetCore/SlackRequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -18,6 +19,7 @@ public interface ISlackRequestHandler
Task<SlackResponse> HandleEventRequest(HttpRequest request, SlackEndpointConfiguration config);
Task<SlackResponse> HandleActionRequest(HttpRequest request, SlackEndpointConfiguration config);
Task<SlackResponse> HandleOptionsRequest(HttpRequest request, SlackEndpointConfiguration config);
Task<SlackResponse> HandleSlashCommandRequest(HttpRequest request, SlackEndpointConfiguration config);
}

class SlackRequestHandler : ISlackRequestHandler
Expand All @@ -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(
Expand All @@ -41,6 +44,7 @@ public SlackRequestHandler(
ISlackOptions slackOptions,
IDialogSubmissionHandler dialogSubmissionHandler,
ISlackViews slackViews,
ISlackSlashCommands slackSlashCommands,
SlackJsonSettings jsonSettings)
{
_slackEvents = slackEvents;
Expand All @@ -51,6 +55,7 @@ public SlackRequestHandler(
_slackOptions = slackOptions;
_dialogSubmissionHandler = dialogSubmissionHandler;
_slackViews = slackViews;
_slackSlashCommands = slackSlashCommands;
_jsonSettings = jsonSettings;
}

Expand Down Expand Up @@ -193,6 +198,25 @@ public async Task<SlackResponse> HandleOptionsRequest(HttpRequest request, Slack
return new StringResponse(HttpStatusCode.BadRequest, "Invalid token or unrecognized content");
}

public async Task<SlackResponse> HandleSlashCommandRequest(HttpRequest request, SlackEndpointConfiguration config)
{
if (request.Method != "POST")
return new EmptyResponse(HttpStatusCode.MethodNotAllowed);

ReplaceRequestStreamWithMemoryStream(request);

var command = await DeserializeForm<SlashCommand>(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();
Expand All @@ -216,14 +240,31 @@ private async Task<SlackResponse> HandleBlockOptionsRequest(BlockOptionsRequest

private async Task<T> DeserializePayload<T>(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<T>(p, _jsonSettings.SerializerSettings))
.FirstOrDefault();
}

private async Task<T> DeserializeForm<T>(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<T>(JsonSerializer.Create(_jsonSettings.SerializerSettings));
}

private static async Task<IFormCollection> ReadForm(HttpRequest request)
{
var form = await request.ReadFormAsync().ConfigureAwait(false);
request.Body.Seek(0, SeekOrigin.Begin);
return form;
}

private static Task<string> ReadString(HttpRequest request) =>
new StreamReader(request.Body).ReadToEndAsync();

Expand Down
2 changes: 2 additions & 0 deletions SlackNet.AspNetCore/SlackRequestMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions SlackNet.AspNetCore/SlackServiceConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ public SlackServiceConfiguration RegisterViewSubmissionHandler<THandler>(string
return this;
}

public SlackServiceConfiguration RegisterSlashCommandHandler<THandler>(string command)
where THandler : class, ISlashCommandHandler
{
if (!command.StartsWith("/"))
throw new ArgumentException("Command must start with '/'", nameof(command));
_serviceCollection.AddTransient<THandler>();
_serviceCollection.AddSingleton<ResolvedSlashCommandHandler>(c => new ResolvedSlashCommandHandler<THandler>(c, command));
return this;
}

public string ApiToken { get; private set; }
}
}
20 changes: 20 additions & 0 deletions SlackNet.AspNetCore/SlackSlashCommandsService.cs
Original file line number Diff line number Diff line change
@@ -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<ResolvedSlashCommandHandler> handlers)
{
foreach (var handler in handlers)
SetHandler(handler.Command, handler);
}

public Task<SlashCommandResponse> Handle(SlashCommand command) => _commands.Handle(command);
public void SetHandler(string command, ISlashCommandHandler handler) => _commands.SetHandler(command, handler);
}
}
6 changes: 6 additions & 0 deletions SlackNet.AzureFunctionExample/SlackEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ public async Task<IActionResult> Options([HttpTrigger(AuthorizationLevel.Anonymo
return SlackResponse(await _requestHandler.HandleOptionsRequest(request, _endpointConfig).ConfigureAwait(false));
}

[FunctionName("options")]
public async Task<IActionResult> Command([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest request)
{
return SlackResponse(await _requestHandler.HandleSlashCommandRequest(request, _endpointConfig).ConfigureAwait(false));
}

private ContentResult SlackResponse(SlackResponse response)
{
return new ContentResult
Expand Down
20 changes: 20 additions & 0 deletions SlackNet.EventsExample/EchoCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Threading.Tasks;
using SlackNet.Interaction;
using SlackNet.WebApi;

namespace SlackNet.EventsExample
{
public class EchoCommand : ISlashCommandHandler
{
public async Task<SlashCommandResponse> Handle(SlashCommand command)
{
return new SlashCommandResponse
{
Message = new Message
{
Text = command.Text
}
};
}
}
}
2 changes: 2 additions & 0 deletions SlackNet.EventsExample/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public void ConfigureServices(IServiceCollection services)
.RegisterEventHandler<AppHomeOpened, AppHome>()
.RegisterBlockActionHandler<ButtonAction, AppHome>()
.RegisterViewSubmissionHandler<AppHome>(AppHome.ModalCallbackId)

.RegisterSlashCommandHandler<EchoCommand>("/echo")
);
services.AddMvc();
}
Expand Down
9 changes: 9 additions & 0 deletions SlackNet/Interaction/ISlashCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Threading.Tasks;

namespace SlackNet.Interaction
{
public interface ISlashCommandHandler
{
Task<SlashCommandResponse> Handle(SlashCommand command);
}
}
7 changes: 6 additions & 1 deletion SlackNet/Interaction/MessageResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
27 changes: 27 additions & 0 deletions SlackNet/Interaction/MessageResponseWrapper.cs
Original file line number Diff line number Diff line change
@@ -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<Attachment> Attachments => _response.Message.Attachments;
public IList<Block> 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;
}
}
32 changes: 7 additions & 25 deletions SlackNet/Interaction/MessageUpdateResponse.cs
Original file line number Diff line number Diff line change
@@ -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<Attachment> Attachments => _response.Message.Attachments;
public IList<Block> 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;
}
}
30 changes: 30 additions & 0 deletions SlackNet/Interaction/SlackSlashCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SlackNet.Interaction
{
public interface ISlackSlashCommands
{
Task<SlashCommandResponse> Handle(SlashCommand command);
void SetHandler(string command, ISlashCommandHandler handler);
}

public class SlackSlashCommands : ISlackSlashCommands
{
private readonly Dictionary<string, ISlashCommandHandler> _handlers = new Dictionary<string, ISlashCommandHandler>();

public Task<SlashCommandResponse> 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;
}
}
}
Loading

0 comments on commit 5a40142

Please sign in to comment.