// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace EpicGames.Slack { /// /// Exception thrown due to an error response from Slack /// public class SlackException : Exception { /// /// Slack error code /// public string Code { get; } /// /// Constructor /// public SlackException(string code) : base(code) { Code = code; } } /// /// Response from posting a Slack message /// public class SlackMessageId { /// /// The channel id. Note that posting messages is more lenient than updating messages, and will accept a user ID as a recipient channel. The actual DM channel this resolves to will be returned in this parameter. /// public string Channel { get; set; } /// /// Thread containing the message /// public string? ThreadTs { get; set; } /// /// Timestamp of the message /// public string Ts { get; set; } /// /// Constructor /// public SlackMessageId(string channel, string? threadTs, string ts) { Channel = channel; ThreadTs = threadTs; Ts = ts; } /// public override string ToString() => (ThreadTs == null) ? $"{Channel}:{Ts}" : $"{Channel}:{ThreadTs}:{Ts}"; } /// /// Wrapper around Slack client functionality /// public class SlackClient { class SlackResponse { [JsonPropertyName("ok")] public bool Ok { get; set; } [JsonPropertyName("error")] public string? Error { get; set; } } readonly HttpClient _httpClient; readonly JsonSerializerOptions _serializerOptions; readonly ILogger _logger; /// /// Constructor /// /// Http client for connecting to slack. Should have the necessary authorization headers. /// Logger interface public SlackClient(HttpClient httpClient, ILogger logger) { _httpClient = httpClient; _serializerOptions = new JsonSerializerOptions(); _serializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; _logger = logger; } static bool ShouldLogError(TResponse response) where TResponse : SlackResponse => !response.Ok; private Task SendRequestAsync(string requestUrl, object request, CancellationToken cancellationToken) where TResponse : SlackResponse { return SendRequestAsync(requestUrl, request, ShouldLogError, cancellationToken); } private async Task SendRequestAsync(string requestUrl, object request, Func shouldLogError, CancellationToken cancellationToken) where TResponse : SlackResponse { using (HttpRequestMessage sendMessageRequest = new HttpRequestMessage(HttpMethod.Post, requestUrl)) { string requestJson = JsonSerializer.Serialize(request, _serializerOptions); using (StringContent messageContent = new StringContent(requestJson, Encoding.UTF8, "application/json")) { sendMessageRequest.Content = messageContent; return await SendRequestAsync(sendMessageRequest, requestJson, shouldLogError, cancellationToken); } } } private Task SendRequestAsync(HttpRequestMessage request, string requestJson, CancellationToken cancellationToken) where TResponse : SlackResponse { return SendRequestAsync(request, requestJson, ShouldLogError, cancellationToken); } private async Task SendRequestAsync(HttpRequestMessage request, string requestJson, Func shouldLogError, CancellationToken cancellationToken) where TResponse : SlackResponse { HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); byte[] responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); TResponse responseObject = JsonSerializer.Deserialize(responseBytes)!; if (shouldLogError(responseObject)) { try { throw new SlackException(responseObject.Error ?? "unknown"); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to send Slack message to {Url} ({Error}). Request: {Request}. Response: {Response}", request.RequestUri, responseObject.Error, requestJson, Encoding.UTF8.GetString(responseBytes)); throw; } } return responseObject; } #region Chat const string PostMessageUrl = "https://slack.com/api/chat.postMessage"; const string UpdateMessageUrl = "https://slack.com/api/chat.update"; const string GetPermalinkUrl = "https://slack.com/api/chat.getPermalink"; class PostMessageRequest { [JsonPropertyName("channel")] public string? Channel { get; set; } [JsonPropertyName("ts")] public string? Ts { get; set; } [JsonPropertyName("thread_ts")] public string? ThreadTs { get; set; } [JsonPropertyName("text")] public string? Text { get; set; } [JsonPropertyName("mrkdwn")] public bool? Markdown { get; set; } [JsonPropertyName("blocks")] public List? Blocks { get; set; } [JsonPropertyName("attachments")] public List? Attachments { get; set; } [JsonPropertyName("reply_broadcast")] public bool? ReplyBroadcast { get; set; } [JsonPropertyName("unfurl_links")] public bool? UnfurlLinks { get; set; } [JsonPropertyName("unfurl_media")] public bool? UnfurlMedia { get; set; } } class PostMessageResponse : SlackResponse { [JsonPropertyName("channel")] public string? Channel { get; set; } [JsonPropertyName("ts")] public string? Ts { get; set; } } /// /// Posts a message to a recipient /// /// Recipient of the message. May be a channel or Slack user id. /// New message to post /// Cancellation token for the operation /// Identifier for the message. Note that the returned channel value will respond to a concrete channel identifier if a user is specified as recipient, and must be used to update the message. public async Task PostMessageAsync(string recipient, SlackMessage message, CancellationToken cancellationToken = default) { return await PostOrUpdateMessageAsync(recipient, null, null, message, false, cancellationToken); } /// /// Posts a message to a recipient /// /// Parent message to post to /// New message to post /// Cancellation token for the operation /// Identifier for the message. Note that the returned channel value will respond to a concrete channel identifier if a user is specified as recipient, and must be used to update the message. public Task PostMessageToThreadAsync(SlackMessageId threadId, SlackMessage message, CancellationToken cancellationToken = default) { return PostMessageToThreadAsync(threadId, message, false, cancellationToken); } /// /// Posts a message to a recipient /// /// Parent message to post to /// New message to post /// Whether to broadcast the message to the channel /// Cancellation token for the operation /// Identifier for the message. Note that the returned channel value will respond to a concrete channel identifier if a user is specified as recipient, and must be used to update the message. public async Task PostMessageToThreadAsync(SlackMessageId threadId, SlackMessage message, bool replyBroadcast, CancellationToken cancellationToken = default) { return await PostOrUpdateMessageAsync(threadId.Channel, null, threadId.Ts, message, replyBroadcast, cancellationToken); } /// /// Updates an existing message /// /// The message id /// New message to post /// Cancellation token for the operation public Task UpdateMessageAsync(SlackMessageId id, SlackMessage message, CancellationToken cancellationToken = default) { return UpdateMessageAsync(id, message, false, cancellationToken); } /// /// Updates an existing message /// /// The message id /// New message to post /// For threaded messages, whether to broadcast the message to the channel /// Cancellation token for the operation public async Task UpdateMessageAsync(SlackMessageId id, SlackMessage message, bool replyBroadcast = false, CancellationToken cancellationToken = default) { await PostOrUpdateMessageAsync(id.Channel, id.Ts, id.ThreadTs, message, replyBroadcast, cancellationToken); } async Task PostOrUpdateMessageAsync(string recipient, string? ts, string? threadTs, SlackMessage message, bool replyBroadcast, CancellationToken cancellationToken) { PostMessageRequest request = new PostMessageRequest(); request.Channel = recipient; request.Ts = String.IsNullOrEmpty(ts) ? null : ts; request.ThreadTs = String.IsNullOrEmpty(threadTs) ? null : threadTs; request.Text = message.Text; request.Blocks = message.Blocks; request.Markdown = message.Markdown; if (replyBroadcast) { request.ReplyBroadcast = replyBroadcast; } if (message.Attachments.Count > 0) { request.Attachments = message.Attachments; } if (!message.UnfurlLinks) { request.UnfurlLinks = false; } if (!message.UnfurlMedia) { request.UnfurlMedia = false; } PostMessageResponse response = await SendRequestAsync(ts == null ? PostMessageUrl : UpdateMessageUrl, request, cancellationToken); if (!response.Ok || response.Ts == null) { throw new SlackException(response.Error ?? "unknown"); } return new SlackMessageId(response.Channel ?? recipient, threadTs, response.Ts); } class GetPermalinkRequest { [JsonPropertyName("channel")] public string? Channel { get; set; } [JsonPropertyName("message_ts")] public string? MessageTs { get; set; } } class GetPermalinkResponse : SlackResponse { [JsonPropertyName("channel")] public string? Channel { get; set; } [JsonPropertyName("permalink")] public string? Permalink { get; set; } } /// /// Gets a permalink for a message /// /// Message identifier /// Cancellation token for the operation /// Link to the message public async Task GetPermalinkAsync(SlackMessageId id, CancellationToken cancellationToken = default) { string requestUrl = $"{GetPermalinkUrl}?channel={id.Channel}&message_ts={id.Ts}"; using (HttpRequestMessage sendMessageRequest = new HttpRequestMessage(HttpMethod.Get, requestUrl)) { GetPermalinkResponse response = await SendRequestAsync(sendMessageRequest, "", cancellationToken); if (!response.Ok || response.Permalink == null) { throw new SlackException(response.Error ?? "unknown"); } return response.Permalink; } } #endregion #region Reactions const string AddReactionUrl = "https://slack.com/api/reactions.add"; const string RemoveReactionUrl = "https://slack.com/api/reactions.remove"; class ReactionMessage { [JsonPropertyName("channel")] public string? Channel { get; set; } [JsonPropertyName("timestamp")] public string? Ts { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } } /// /// Adds a reaction to a posted message /// /// Message to react to /// Name of the reaction to post /// Cancellation token for the operation public async Task AddReactionAsync(SlackMessageId messageId, string name, CancellationToken cancellationToken = default) { ReactionMessage message = new ReactionMessage(); message.Channel = messageId.Channel; message.Ts = messageId.Ts; message.Name = name; static bool ShouldLogError(SlackResponse response) => !response.Ok && !String.Equals(response.Error, "already_reacted", StringComparison.Ordinal); await SendRequestAsync(AddReactionUrl, message, ShouldLogError, cancellationToken); } /// /// Removes a reaction from a posted message /// /// Message to react to /// Name of the reaction to post /// Cancellation token for the operation public async Task RemoveReactionAsync(SlackMessageId messageId, string name, CancellationToken cancellationToken = default) { ReactionMessage message = new ReactionMessage(); message.Channel = messageId.Channel; message.Ts = messageId.Ts; message.Name = name; static bool ShouldLogError(SlackResponse response) => !response.Ok && !String.Equals(response.Error, "no_reaction", StringComparison.Ordinal); await SendRequestAsync(RemoveReactionUrl, message, ShouldLogError, cancellationToken); } #endregion #region Conversations const string ConversationsInviteUrl = "https://slack.com/api/conversations.invite"; class InviteMessage { [JsonPropertyName("channel")] public string? Channel { get; set; } [JsonPropertyName("users")] public string? Users { get; set; } // Comma separated list of ids } /// /// Invite a user to a channel /// /// Channel identifier to invite the user to /// The user id /// Cancellation token for the operation public Task InviteUserAsync(string channel, string userId, CancellationToken cancellationToken = default) => InviteUsersAsync(channel, new[] { userId }, cancellationToken); /// /// Invite a set of users to a channel /// /// Channel identifier to invite the user to /// The user id /// Cancellation token for the operation public async Task InviteUsersAsync(string channel, IEnumerable userIds, CancellationToken cancellationToken = default) { InviteMessage message = new InviteMessage(); message.Channel = channel; message.Users = String.Join(",", userIds); static bool ShouldLogError(SlackResponse response) => !response.Ok && !String.Equals(response.Error, "already_in_channel", StringComparison.Ordinal); await SendRequestAsync(ConversationsInviteUrl, message, ShouldLogError, cancellationToken); } /// /// Attempt to invite a set of users to a channel, returning the error code on failure /// /// Channel identifier to invite the user to /// The user id /// Cancellation token for the operation public async Task TryInviteUsersAsync(string channel, IEnumerable userIds, CancellationToken cancellationToken = default) { InviteMessage message = new InviteMessage(); message.Channel = channel; message.Users = String.Join(",", userIds); static bool ShouldLogError(SlackResponse response) => false; SlackResponse response = await SendRequestAsync(ConversationsInviteUrl, message, ShouldLogError, cancellationToken); return response.Ok ? null : response.Error; } const string AdminConversationsInviteUrl = "https://slack.com/api/admin.conversations.invite"; /// /// Invite a set of users to a channel using admin powers. /// /// Channel identifier to invite the user to /// The user id /// Cancellation token for the operation public async Task AdminInviteUsersAsync(string channel, IEnumerable userIds, CancellationToken cancellationToken = default) { object message = new { channel_id = channel, user_ids = String.Join(",", userIds) }; static bool ShouldLogError(SlackResponse response) => !response.Ok && !String.Equals(response.Error, "already_in_channel", StringComparison.Ordinal); await SendRequestAsync(AdminConversationsInviteUrl, message, ShouldLogError, cancellationToken); } #endregion #region Users const string UsersInfoUrl = "https://slack.com/api/users.info"; const string UsersLookupByEmailUrl = "https://slack.com/api/users.lookupByEmail"; class UserResponse : SlackResponse { [JsonPropertyName("user")] public SlackUser? User { get; set; } } /// /// Gets a user's profile /// /// The user id /// Cancellation token for the operation /// User profile public async Task GetUserAsync(string userId, CancellationToken cancellationToken = default) { using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"{UsersInfoUrl}?user={userId}")) { UserResponse response = await SendRequestAsync(request, "", ShouldLogError, cancellationToken); return response.User!; } } /// /// Finds a user by email address /// /// The user's email address /// Cancellation token for the operation /// User profile public async Task FindUserByEmailAsync(string email, CancellationToken cancellationToken = default) { try { using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"{UsersLookupByEmailUrl}?email={email}")) { static bool ShouldLogError(UserResponse response) => !response.Ok && !String.Equals(response.Error, "users_not_found", StringComparison.Ordinal); UserResponse response = await SendRequestAsync(request, "", ShouldLogError, cancellationToken); return response.User; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to get Slack user email {Url} ({Error})", $"{UsersLookupByEmailUrl}?email={email}", ex.Message); return null; } } #endregion #region Views const string ViewsOpenUrl = "https://slack.com/api/views.open"; class ViewsOpenRequest { [JsonPropertyName("trigger_id")] public string? TriggerId { get; set; } [JsonPropertyName("view")] public SlackView? View { get; set; } } /// /// Open a new modal view, in response to a trigger /// /// The trigger id, as returned as part of an interaction payload /// Definition for the view /// Cancellation token for the operation public async Task OpenViewAsync(string triggerId, SlackView view, CancellationToken cancellationToken = default) { ViewsOpenRequest request = new ViewsOpenRequest(); request.TriggerId = triggerId; request.View = view; await SendRequestAsync(ViewsOpenUrl, request, cancellationToken); } #endregion } }