Skip to content
Draft
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
12 changes: 10 additions & 2 deletions src/OutPost2.Core/Connections/CapabilityCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ private sealed record Entry(string Phrase, CapabilityRisk Level);
[Capabilities.PeopleRead] = new("Look people up and read your contacts", CapabilityRisk.None),
[Capabilities.ContactsWrite] = new("Add and change your contacts", CapabilityRisk.Writes),

[Capabilities.ChatRead] = new("Read your Teams chats", CapabilityRisk.None),
[Capabilities.ChatRead] = new("Read your chats", CapabilityRisk.None),

// ActsAsYou. A Teams message is the most immediate thing this product can do in
// somebody else's name — it lands on a phone, it is attributed to the user, and
// unlike mail there is no delay and nothing to recall it with.
[Capabilities.ChatSend] = new("Send Teams messages as you", CapabilityRisk.ActsAsYou),
[Capabilities.ChatSend] = new("Send chat messages as you", CapabilityRisk.ActsAsYou),

[Capabilities.IssuesRead] = new("Read issues", CapabilityRisk.None),
[Capabilities.IssuesWrite] = new("Create and update issues", CapabilityRisk.Writes),
Expand Down Expand Up @@ -93,6 +93,12 @@ private sealed record Entry(string Phrase, CapabilityRisk Level);
// the wrong API entirely.
ProviderKind.Google =>
[Capabilities.MailRead, Capabilities.MailWrite, Capabilities.MailSend],

// Chat only (OP2-86). The same two capabilities Teams declares, because the chat
// tools route by provider; a Slack connection offered mail or calendar would be
// called with the wrong API entirely.
ProviderKind.Slack =>
[Capabilities.ChatRead, Capabilities.ChatSend],
_ => [],
};

Expand Down Expand Up @@ -161,6 +167,7 @@ public static CapabilityRisk Risk(string capability) =>
ProviderKind.Microsoft or ProviderKind.Google => $"check {label} email",
ProviderKind.YouTrack => $"what's open in {label}",
ProviderKind.GitHub or ProviderKind.Gitea => $"what pull requests am I blocking on {label}",
ProviderKind.Slack => $"any messages on {label}",
_ => label,
};

Expand All @@ -172,6 +179,7 @@ public static CapabilityRisk Risk(string capability) =>
ProviderKind.GitHub => "GitHub pull requests and issues",
ProviderKind.Gitea => "Gitea and Forgejo pull requests and issues",
ProviderKind.Google => "Gmail",
ProviderKind.Slack => "Slack workspaces",
_ => provider.ToString(),
};

Expand Down
7 changes: 7 additions & 0 deletions src/OutPost2.Core/Connections/Connection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ public enum ProviderKind
/// like the rest, so adding it needs no migration.
/// </summary>
Gitea,

/// <summary>
/// A Slack workspace, through a pasted user token (OP2-86). Chat only: DMs, group DMs and
/// the channels the user is in, behind the same chat tools as Teams. Stored as its
/// name, like the rest, so adding it needs no migration.
/// </summary>
Slack,
}

public enum AuthKind
Expand Down
28 changes: 26 additions & 2 deletions src/OutPost2.Core/Teams/TeamsModels.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
namespace OutPost2.Core.Teams;

/// <summary>
/// A Teams conversation the user is part of (OP2-79). Chats only — channels are
/// deliberately out of scope, see <c>TeamsService</c> for why.
/// A conversation the user is part of: a Teams chat (OP2-79) or a Slack conversation
/// (OP2-86). Teams contributes chats only — its channels are deliberately out of scope,
/// see <c>TeamsService</c> for why. Slack contributes DMs, group DMs and the channels the
/// user is a member of, see <c>SlackService</c>.
/// </summary>
public sealed record Chat
{
Expand Down Expand Up @@ -73,6 +75,12 @@ public enum ChatKind

/// <summary>Anything Graph reports that this version does not know about.</summary>
Other,

/// <summary>
/// A Slack channel, public or private (OP2-86). Named, with a membership too large to
/// describe it by. Teams never produces one: Teams channels are not read.
/// </summary>
Channel,
}

/// <summary>One message in a chat, already reduced to something worth reading aloud.</summary>
Expand Down Expand Up @@ -106,6 +114,22 @@ public sealed record ChatMessage

/// <summary>Names of files attached to the message. The files themselves are not fetched.</summary>
public IReadOnlyList<string> Attachments { get; init; } = [];

/// <summary>
/// How many replies hang off this message in a thread (Slack, OP2-86). The replies are
/// NOT in the transcript: a thread reply is not a message in the channel, and the
/// transcript is the channel. Saying they exist is what stops a quiet-looking message
/// from hiding a forty-reply argument underneath it. Zero for Teams, which has no
/// threads in chats.
/// </summary>
public int ThreadReplies { get; init; }

/// <summary>
/// A thread reply the sender ALSO posted to the channel ("also send to #channel"),
/// which is the only way a reply appears at the top level. Worth saying, because it
/// answers a message that may not be in view.
/// </summary>
public bool IsThreadReply { get; init; }
}

/// <summary>
Expand Down
163 changes: 163 additions & 0 deletions src/OutPost2.Infrastructure/Connections/SlackConnectionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using Microsoft.Extensions.Logging;
using OutPost2.Core.Connections;
using OutPost2.Infrastructure.Teams;

namespace OutPost2.Infrastructure.Connections;

public interface ISlackConnectionService
{
Task<SlackProbe> ProbeAsync(string token, CancellationToken ct = default);

Task<Connection> ConnectAsync(Guid userId, string token, string label, CancellationToken ct = default);

Task<bool> CheckHealthAsync(Connection connection, CancellationToken ct = default);
}

/// <param name="UserId">The user's Slack id (U…), which is how "me" is recognised in a conversation.</param>
/// <param name="Url">The workspace, e.g. https://acme.slack.com — what tells two of them apart.</param>
/// <param name="CanSend">Whether the token carries <c>chat:write</c>.</param>
public sealed record SlackProbe(
bool Ok, string? UserId, string? User, string? Team, string? Url, string? Error, bool CanSend = false);

/// <summary>
/// Connecting a Slack workspace with a pasted user token (OP2-86).
/// <para>
/// <b>A user token, not a bot token.</b> A user token (<c>xoxp-</c>) acts as the person:
/// it reads their DMs and group DMs and every channel they are in, and what it posts
/// appears under their name — which is what this product is for. A bot token
/// (<c>xoxb-</c>) acts as the app, cannot read a DM it is not part of, and can only see a
/// channel it has been invited into, so "has anyone messaged me" would always answer from
/// the bot's inbox rather than the user's. Both are refused here before Slack is asked.
/// </para>
/// <para>
/// <b>Pasted, not signed in.</b> A Slack OAuth flow needs a Slack app registered for the
/// DEPLOYMENT, with its client secret in configuration and its redirect URL pointed at
/// this server — the way Google is optional behind <c>GoogleOptions.IsConfigured</c>. A
/// pasted token needs none of that: the user creates an app in their own workspace (or
/// uses one their admin made), installs it, and copies the User OAuth Token. It is the
/// same honour-system shape as YouTrack, GitHub and Gitea, with one difference worth
/// having: Slack's scopes are real limits, so a scope the token lacks is reported as not
/// granted rather than as a switch.
/// </para>
/// <para>
/// <b>Scopes are per method and per conversation type</b>, so the probe reads the
/// <c>x-oauth-scopes</c> header from <c>auth.test</c> and names every reading scope that
/// is missing. <c>chat:write</c> is optional: without it the connection reads and its
/// send capability is recorded as not granted.
/// </para>
/// </summary>
public sealed class SlackConnectionService(
IHttpClientFactory httpFactory,
IConnectionStore connections,
ILogger<SlackConnectionService> log) : ISlackConnectionService
{
public async Task<SlackProbe> ProbeAsync(string token, CancellationToken ct = default)
{
token = (token ?? "").Trim();
if (token.Length == 0) return Fail("Paste a Slack user token.");

if (token.StartsWith("xoxb-", StringComparison.Ordinal))
return Fail("That's a bot token (xoxb-). Outpost needs the User OAuth Token (xoxp-): a bot can't "
+ "read your DMs, only conversations it has been invited into.");

if (token.StartsWith("xapp-", StringComparison.Ordinal))
return Fail("That's an app-level token (xapp-), which can't read or send messages. "
+ "Copy the User OAuth Token (xoxp-) from OAuth & Permissions instead.");

// Token rotation: a 12-hour access token that needs a refresh token and the app's
// client secret to renew. Outpost holds neither, so it would stop working by tonight.
if (token.StartsWith("xoxe", StringComparison.Ordinal))
return Fail("That's a rotating Slack token, which expires after 12 hours and can't be renewed from here. "
+ "Turn off token rotation for the app and copy its User OAuth Token (xoxp-).");

try
{
using var http = SlackApi.Configure(httpFactory.CreateClient("slack"), token);
using var resp = await http.PostAsync("auth.test", null, ct);

AuthTest me;
try { me = await SlackApi.ReadAsync<AuthTest>(resp, "this token", "sign in", ct); }
catch (SlackApiException ex)
{
return Fail(ex.Error is "invalid_auth" or "not_authed" or "token_revoked" or "token_expired" or "account_inactive"
? "Slack rejected that token."
: ex.Message);
}

if (me.BotId is not null)
return Fail("That token belongs to a bot. Outpost needs the User OAuth Token (xoxp-), which acts as you.");

if (string.IsNullOrWhiteSpace(me.UserId))
return Fail("Slack accepted the token but didn't say whose it is.");

var scopes = SlackApi.Scopes(resp);
var missing = scopes is null ? [] : SlackApi.ReadScopes.Where(s => !scopes.Contains(s)).ToList();

if (missing.Count > 0)
return Fail($"That token is missing the {Join(missing)} {(missing.Count == 1 ? "scope" : "scopes")}. "
+ "Add them as User Token Scopes under OAuth & Permissions, reinstall the app, and paste the new token.");

// No header means Slack did not say, not that nothing was granted. Calls name a
// missing scope themselves, so this is allowed through rather than refused.
if (scopes is null)
log.LogInformation("Slack's auth.test carried no x-oauth-scopes header; scopes unchecked.");

return new SlackProbe(true, me.UserId, me.User, me.Team, me.Url?.TrimEnd('/'), null,
CanSend: scopes is null || scopes.Contains(SlackApi.SendScope));
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return Fail($"Could not reach Slack: {ex.Message}");
}
}

private static SlackProbe Fail(string error) => new(false, null, null, null, null, error);

private static string Join(IReadOnlyList<string> xs) =>
xs.Count == 1 ? xs[0] : string.Join(", ", xs.Take(xs.Count - 1)) + " and " + xs[^1];

public async Task<Connection> ConnectAsync(Guid userId, string token, string label, CancellationToken ct = default)
{
token = (token ?? "").Trim();
var probe = await ProbeAsync(token, ct);
if (!probe.Ok) throw new InvalidOperationException(probe.Error ?? "Token validation failed.");

// Read on. Send is granted only when the token really carries chat:write, and
// starts OFF either way, as code write does for GitHub and Gitea: a Slack message
// lands under the user's name on somebody's phone and cannot be recalled.
var capabilities = new List<Capability>
{
new() { Name = Capabilities.ChatRead, Granted = true, Enabled = true, IsSelfImposedOnly = true },
new() { Name = Capabilities.ChatSend, Granted = probe.CanSend, Enabled = false, IsSelfImposedOnly = true },
};

var instance = probe.Url ?? "https://slack.com";

log.LogInformation("Connecting Slack workspace {Team} ({Instance}) as {User} (label {Label}, can send: {CanSend}).",
probe.Team, instance, probe.User, label, probe.CanSend);

var connection = await connections.UpsertAsync(
userId, ProviderKind.Slack, instance, probe.UserId,
label, AuthKind.PersonalAccessToken, token, capabilities, ct);

await connections.SetAccountIdentityAsync(connection, probe.User, isPersonal: false, ct);

// A non-rotating user token has no expiry, and Slack reports none.
await connections.SetCredentialExpiryAsync(connection, null, ct);

return connection;
}

public async Task<bool> CheckHealthAsync(Connection connection, CancellationToken ct = default)
{
var token = await connections.GetCredentialAsync(connection, ct);
if (token is null) return false;

var probe = await ProbeAsync(token, ct);
await connections.SetStateAsync(connection.UserId, connection.Id,
probe.Ok ? ConnectionState.Active : ConnectionState.Expired, ct);
return probe.Ok;
}

private sealed record AuthTest(string? Url, string? Team, string? User, string? TeamId, string? UserId, string? BotId);
}
64 changes: 64 additions & 0 deletions src/OutPost2.Infrastructure/Teams/ChatProviders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using OutPost2.Core.Connections;
using OutPost2.Core.Teams;

namespace OutPost2.Infrastructure.Teams;

/// <summary>
/// One chat service's implementation of <see cref="ITeamsService"/> (OP2-86), the way code
/// got <c>ICodeProvider</c> and mail got <c>IMailProvider</c>. The interface keeps its
/// Teams name because the chat tools already speak it; what it describes — list the
/// conversations, read one, find one by a person's name, post to it — is what Slack has
/// too.
/// </summary>
public interface IChatProvider : ITeamsService
{
ProviderKind Provider { get; }
}

/// <summary>
/// Sends each call to the implementation for the connection's provider, and does nothing
/// else. Anything provider-specific — what a conversation is, how a mention reads, what
/// a thread is — belongs in the provider, where it is tested against that provider's wire
/// format.
/// </summary>
public sealed class ChatServiceRouter(IEnumerable<IChatProvider> providers) : ITeamsService
{
private readonly Dictionary<ProviderKind, IChatProvider> _by = Index(providers);

private static Dictionary<ProviderKind, IChatProvider> Index(IEnumerable<IChatProvider> providers)
{
var by = new Dictionary<ProviderKind, IChatProvider>();
foreach (var p in providers)
if (!by.TryAdd(p.Provider, p))
throw new InvalidOperationException($"Two chat providers are registered for {p.Provider}.");
return by;
}

public bool Supports(ProviderKind provider) => _by.ContainsKey(provider);

/// <summary>
/// A connection of a kind with no chat implementation is a plain sentence, not a call
/// to Graph with somebody's Slack token.
/// </summary>
private ITeamsService For(Connection c) =>
_by.TryGetValue(c.Provider, out var p)
? p
: throw new TeamsException(
$"{c.Label} is a {CapabilityCatalog.ProviderHeading(c.Provider)} connection, and Outpost can't read chats on it.");

public Task<IReadOnlyList<Chat>> ListChatsAsync(
Connection connection, int top = 15, bool unreadOnly = false, CancellationToken ct = default) =>
For(connection).ListChatsAsync(connection, top, unreadOnly, ct);

public Task<ChatTranscript> ReadChatAsync(
Connection connection, string chatId, int count = 20, CancellationToken ct = default) =>
For(connection).ReadChatAsync(connection, chatId, count, ct);

public Task<IReadOnlyList<Chat>> FindChatsWithAsync(
Connection connection, string who, CancellationToken ct = default) =>
For(connection).FindChatsWithAsync(connection, who, ct);

public Task<Chat> SendAsync(
Connection connection, string chatId, string text, CancellationToken ct = default) =>
For(connection).SendAsync(connection, chatId, text, ct);
}
Loading
Loading