Skip to content
Merged
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
@@ -1,5 +1,6 @@
namespace Slackbot.Net.Endpoints.Abstractions;

[Obsolete("Put post-uninstall logic directly in your IWorkspaceInstallationHandler.Uninstall implementation instead. IUninstall will be removed in a future version.")]
public interface IUninstall
{
Task OnUninstalled(string teamId, string teamName);
Expand Down
16 changes: 12 additions & 4 deletions source/src/Slackbot.Net.Endpoints/Configurations/ITokenStore.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
namespace Slackbot.Net.Abstractions.Hosting;

/// <summary>
/// Provider of tokens from all workspaces that have installed your distributed Slack app
/// Obsolete: renamed to <see cref="IWorkspaceInstallationHandler"/>, with Insert/Delete
/// renamed to Install/Uninstall to match the Slack app-installation lifecycle events they
/// actually respond to. Kept for backwards compatibility: existing implementations of this
/// interface (their Insert/Delete methods unchanged) automatically satisfy
/// <see cref="IWorkspaceInstallationHandler"/> too via the forwarding below. Note: because the
/// forwarding methods are explicit implementations of IWorkspaceInstallationHandler, they're only
/// reachable via an IWorkspaceInstallationHandler-typed reference, not via ITokenStore itself.
/// </summary>
public interface ITokenStore
[Obsolete("ITokenStore has been renamed to IWorkspaceInstallationHandler, and its Insert/Delete methods renamed to Install/Uninstall to match the Slack app installation lifecycle. Implement IWorkspaceInstallationHandler instead. This interface will be removed in a future version.")]
public interface ITokenStore : IWorkspaceInstallationHandler
{
Task<Workspace> Delete(string teamId);
Task Insert(Workspace slackTeam);
}

public record Workspace(string TeamId, string TeamName, string Token);
Task IWorkspaceInstallationHandler.Install(Workspace slackTeam) => Insert(slackTeam);
Task<Workspace> IWorkspaceInstallationHandler.Uninstall(string teamId) => Delete(teamId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Slackbot.Net.Abstractions.Hosting;

/// <summary>
/// Reacts to a Slack app's installation lifecycle: install (OAuth success) and
/// uninstall (the `app_uninstalled` / `tokens_revoked` events), giving you a hook to
/// persist or remove the workspace's access token however you see fit.
/// </summary>
public interface IWorkspaceInstallationHandler
{
/// <summary>Called when a workspace completes the OAuth installation flow for your app.</summary>
Task Install(Workspace slackTeam);

/// <summary>
/// Called when a workspace uninstalls your app or revokes its tokens.
/// Return the removed <see cref="Workspace"/>, or null if none was found for <paramref name="teamId"/>.
/// </summary>
Task<Workspace> Uninstall(string teamId);
}

public record Workspace(string TeamId, string TeamName, string Token);
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,19 @@ public static ISlackbotHandlersBuilder AddSlackBotEvents(this IServiceCollection
}

public static ISlackbotHandlersBuilder AddSlackBotEvents<T>(this IServiceCollection services)
where T : class, ITokenStore
where T : class, IWorkspaceInstallationHandler
{
services.AddSingleton<ITokenStore, T>();
services.AddSingleton<IWorkspaceInstallationHandler, T>();

// Backwards compatibility: if T still implements the obsolete ITokenStore interface,
// make it resolvable that way too, so existing code depending on ITokenStore keeps working.
#pragma warning disable CS0618 // Type or member is obsolete
if (typeof(ITokenStore).IsAssignableFrom(typeof(T)))
{
services.AddSingleton<ITokenStore>(sp => (ITokenStore)sp.GetRequiredService<IWorkspaceInstallationHandler>());
}
#pragma warning restore CS0618

return services.AddSlackBotEvents();
}

Expand All @@ -39,5 +49,7 @@ public class OAuthOptions
public string CLIENT_ID { get; set; }
public string CLIENT_SECRET { get; set; }
public string SuccessRedirectUri { get; set; } = "/success?default=1";

[Obsolete("Put post-install logic directly in your IWorkspaceInstallationHandler.Install implementation instead. OnSuccess will be removed in a future version.")]
public Func<string, string, IServiceProvider, Task> OnSuccess { get; set; } = (_, _, _) => Task.CompletedTask;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace Slackbot.Net.Endpoints.Middlewares;
internal class SlackbotCodeTokenExchangeMiddleware(RequestDelegate next)
{
public async Task Invoke(HttpContext ctx, OAuthClient oAuthAccessClient, IServiceProvider provider,
IOptions<OAuthOptions> options, ITokenStore slackTeamRepository,
IOptions<OAuthOptions> options, IWorkspaceInstallationHandler installationHandler,
ILogger<SlackbotCodeTokenExchangeMiddleware> logger)
{
logger.LogInformation("Installing!");
Expand All @@ -32,13 +32,18 @@ public async Task Invoke(HttpContext ctx, OAuthClient oAuthAccessClient, IServic
if (response.Ok)
{
logger.LogInformation($"Oauth response! ok:{response.Ok}");
await slackTeamRepository.Insert(new Workspace
await installationHandler.Install(new Workspace
(
response.Team.Id,
response.Team.Name,
response.Access_Token
));

// Backwards compatibility: OnSuccess is obsolete in favor of putting this logic
// directly in IWorkspaceInstallationHandler.Install, but keep invoking it if set.
#pragma warning disable CS0618 // Type or member is obsolete
await options.Value.OnSuccess(response.Team.Id, response.Team.Name, provider);
#pragma warning restore CS0618

ctx.Response.Redirect(options.Value.SuccessRedirectUri);
}
Expand Down
31 changes: 20 additions & 11 deletions source/src/Slackbot.Net.Endpoints/Middlewares/Uninstall.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,32 @@ public Uninstall(RequestDelegate next, ILogger<Uninstall> logger)

public async Task Invoke(HttpContext context)
{
var tokenStore = context.RequestServices.GetService<ITokenStore>() ??
new NoopTokenStore(context.RequestServices.GetService<ILogger<NoopTokenStore>>() ??
NullLogger<NoopTokenStore>.Instance);
var installationHandler = context.RequestServices.GetService<IWorkspaceInstallationHandler>() ??
new NoopWorkspaceInstallationHandler(context.RequestServices.GetService<ILogger<NoopWorkspaceInstallationHandler>>() ??
NullLogger<NoopWorkspaceInstallationHandler>.Instance);

// Backwards compatibility: IUninstall is obsolete in favor of putting this logic
// directly in IWorkspaceInstallationHandler.Uninstall, but keep invoking it if registered.
#pragma warning disable CS0618 // Type or member is obsolete
var uninstaller = context.RequestServices.GetService<IUninstall>() ??
new NoopUninstaller(context.RequestServices.GetService<ILogger<NoopUninstaller>>() ??
NullLogger<NoopUninstaller>.Instance);
#pragma warning restore CS0618
var metadata = context.Items[HttpItemKeys.EventMetadataKey] as EventMetaData;
_logger.LogInformation($"Deleting team with TeamId: `{metadata.Team_Id}`");
var deleted = await tokenStore.Delete(metadata.Team_Id);
_logger.LogInformation($"Uninstalling team with TeamId: `{metadata.Team_Id}`");
var deleted = await installationHandler.Uninstall(metadata.Team_Id);
if (deleted is null)
{
_logger.LogWarning(
"Token store returned null for '{TeamId}'. Will not trigger registered OnUninstalled handlers. ",
"Workspace installation handler returned null for '{TeamId}'. Will not trigger registered OnUninstalled handlers. ",
metadata.Team_Id);
}
else
{
#pragma warning disable CS0618 // Type or member is obsolete
await uninstaller.OnUninstalled(deleted?.TeamId, deleted?.TeamName);
_logger.LogInformation($"Deleted team with TeamId: `{metadata.Team_Id}`");
#pragma warning restore CS0618
_logger.LogInformation($"Uninstalled team with TeamId: `{metadata.Team_Id}`");
}

context.Response.StatusCode = 200;
Expand All @@ -50,6 +57,7 @@ public static bool ShouldRun(HttpContext ctx)
}
}

#pragma warning disable CS0618 // Type or member is obsolete
public class NoopUninstaller(ILogger<NoopUninstaller> logger) : IUninstall
{
public Task OnUninstalled(string teamId, string teamName)
Expand All @@ -58,16 +66,17 @@ public Task OnUninstalled(string teamId, string teamName)
return Task.CompletedTask;
}
}
#pragma warning restore CS0618

public class NoopTokenStore(ILogger<NoopTokenStore> logger) : ITokenStore
public class NoopWorkspaceInstallationHandler(ILogger<NoopWorkspaceInstallationHandler> logger) : IWorkspaceInstallationHandler
{
public Task<Workspace> Delete(string teamId)
public Task<Workspace> Uninstall(string teamId)
{
logger.LogDebug("No-op. Returning null for deleting workspace!");
logger.LogDebug("No-op. Returning null for uninstalling workspace!");
return Task.FromResult<Workspace>(null);
}

public Task Insert(Workspace slackTeam)
public Task Install(Workspace slackTeam)
{
logger.LogDebug("No-op. Not storing workspace!");
return Task.CompletedTask;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using Microsoft.Extensions.DependencyInjection;
using Slackbot.Net.Abstractions.Hosting;
using Slackbot.Net.Endpoints.Hosting;

namespace Slackbot.Net.Tests;

public class WorkspaceInstallationHandlerTests
{
[Fact]
public async Task FreshImplementerOfIWorkspaceInstallationHandlerWorks()
{
var services = new ServiceCollection();
services.AddSlackBotEvents<FreshHandler>();
var provider = services.BuildServiceProvider();

var handler = provider.GetRequiredService<IWorkspaceInstallationHandler>();
var freshHandler = Assert.IsType<FreshHandler>(handler);

await handler.Install(new Workspace("T1", "Team", "tok"));
var deleted = await handler.Uninstall("T1");

Assert.Single(freshHandler.Installed);
Assert.Equal("T1", freshHandler.Installed[0].TeamId);
Assert.Single(freshHandler.Uninstalled);
Assert.Equal("T1", freshHandler.Uninstalled[0]);
Assert.Equal("T1", deleted.TeamId);
}

#pragma warning disable CS0618 // Type or member is obsolete
[Fact]
public async Task LegacyITokenStoreImplementerStillWorksThroughIWorkspaceInstallationHandler()
{
var services = new ServiceCollection();
services.AddSlackBotEvents<LegacyStore>();
var provider = services.BuildServiceProvider();

var handler = provider.GetRequiredService<IWorkspaceInstallationHandler>();
var legacyStore = Assert.IsType<LegacyStore>(handler);

await handler.Install(new Workspace("T1", "Team", "tok"));
var deleted = await handler.Uninstall("T1");

Assert.Single(legacyStore.Inserted);
Assert.Equal("T1", legacyStore.Inserted[0].TeamId);
Assert.Single(legacyStore.Deleted);
Assert.Equal("T1", legacyStore.Deleted[0]);
Assert.Equal("T1", deleted.TeamId);

var legacyView = provider.GetRequiredService<ITokenStore>();
Assert.Same(handler, legacyView);
}

private sealed class LegacyStore : ITokenStore
{
public List<Workspace> Inserted { get; } = [];
public List<string> Deleted { get; } = [];

public Task Insert(Workspace slackTeam)
{
Inserted.Add(slackTeam);
return Task.CompletedTask;
}

public Task<Workspace> Delete(string teamId)
{
Deleted.Add(teamId);
return Task.FromResult(new Workspace(teamId, "Team", "tok"));
}
}
#pragma warning restore CS0618

private sealed class FreshHandler : IWorkspaceInstallationHandler
{
public List<Workspace> Installed { get; } = [];
public List<string> Uninstalled { get; } = [];

public Task Install(Workspace slackTeam)
{
Installed.Add(slackTeam);
return Task.CompletedTask;
}

public Task<Workspace> Uninstall(string teamId)
{
Uninstalled.Add(teamId);
return Task.FromResult(new Workspace(teamId, "Team", "tok"));
}
}
}
Loading