Skip to content

Commit

Permalink
Merge pull request #87 from atidev/mm_bot
Browse files Browse the repository at this point in the history
Add mattermost's bot support
  • Loading branch information
AlexFate authored Jul 25, 2024
2 parents 2379fc0 + d67dd3d commit 2d1cf2c
Show file tree
Hide file tree
Showing 12 changed files with 245 additions and 66 deletions.
1 change: 1 addition & 0 deletions ATI.Services.Common/ATI.Services.Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>atisu.services.common</PackageId>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<WarningsAsErrors>Nullable</WarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<NoWarn>1701;1702;CS1591;CS1571;CS1573;CS1574</NoWarn>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Diagnostics;
using System.Net.Http;
Expand All @@ -21,14 +22,15 @@ public static class ServiceCollectionHttpClientExtensions
/// <param name="services"></param>
/// <typeparam name="TServiceOptions"></typeparam>
/// <returns></returns>s
public static IServiceCollection AddCustomHttpClient<TServiceOptions>(this IServiceCollection services, Action<HttpClient> additionalActions = null)
public static IServiceCollection AddCustomHttpClient<TServiceOptions>(this IServiceCollection services,
Action<HttpClient>? additionalActions = null)
where TServiceOptions : BaseServiceOptions
{
var (settings, logger) = GetInitialData<TServiceOptions>();

services.AddHttpClient(settings.ServiceName, httpClient =>
{
ConfigureHttpClient(httpClient, settings);
ConfigureHttpClientHeaders(httpClient, settings);
additionalActions?.Invoke(httpClient);
})
.AddDefaultHandlers(settings, logger);
Expand All @@ -45,15 +47,16 @@ public static IServiceCollection AddCustomHttpClient<TServiceOptions>(this IServ
/// <typeparam name="TAdapter">Type of the http adapter for typed HttpClient</typeparam>
/// <typeparam name="TServiceOptions"></typeparam>
/// <returns></returns>s
public static IServiceCollection AddCustomHttpClient<TAdapter, TServiceOptions>(this IServiceCollection services, Action<HttpClient> additionalActions = null)
public static IServiceCollection AddCustomHttpClient<TAdapter, TServiceOptions>(this IServiceCollection services,
Action<HttpClient>? additionalActions = null)
where TAdapter : class
where TServiceOptions : BaseServiceOptions
{
var (settings, logger) = GetInitialData<TServiceOptions>();

services.AddHttpClient<TAdapter>(httpClient =>
{
ConfigureHttpClient(httpClient, settings);
ConfigureHttpClientHeaders(httpClient, settings);
additionalActions?.Invoke(httpClient);
})
.AddDefaultHandlers(settings, logger);
Expand All @@ -62,27 +65,31 @@ public static IServiceCollection AddCustomHttpClient<TAdapter, TServiceOptions>(
}


private static (TServiceOptions settings, ILogger logger) GetInitialData<TServiceOptions>()
private static (TServiceOptions settings, ILogger? logger) GetInitialData<TServiceOptions>()
where TServiceOptions : BaseServiceOptions
{
var className = typeof(TServiceOptions).Name;
var settings = ConfigurationManager.GetSection(className).Get<TServiceOptions>();

if (settings is null)
throw new NullReferenceException($"Please configure {nameof(TServiceOptions)} options");

var logger = LogManager.GetLogger(settings.ServiceName);
return (settings, logger);
}

private static void ConfigureHttpClient(HttpClient httpClient, BaseServiceOptions settings)
private static void ConfigureHttpClientHeaders(HttpClient httpClient, BaseServiceOptions settings)
{
if (settings.AdditionalHeaders is { Count: > 0 })
if (settings.AdditionalHeaders is { Count: <= 0 }) return;

foreach (var header in settings.AdditionalHeaders)
{
foreach (var header in settings.AdditionalHeaders)
{
httpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
}
httpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
}
}

private static IHttpClientBuilder AddDefaultHandlers<TServiceOptions>(this IHttpClientBuilder builder, TServiceOptions settings, ILogger logger)
private static IHttpClientBuilder AddDefaultHandlers<TServiceOptions>(this IHttpClientBuilder builder,
TServiceOptions settings, ILogger? logger)
where TServiceOptions : BaseServiceOptions
{
return builder
Expand Down
17 changes: 9 additions & 8 deletions ATI.Services.Common/Http/HttpContextHelper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using ATI.Services.Common.Logging;
using ATI.Services.Common.Metrics;
Expand All @@ -15,23 +16,23 @@ internal static class HttpContextHelper
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();

public static string[] MetricsHeadersValues(HttpContext? httpContext) => GetHeadersValues(httpContext, MetricsLabelsAndHeaders.UserHeaders);
public static Dictionary<string, string> HeadersAndValuesToProxy(HttpContext? httpContext,IReadOnlyCollection<string>? headersToProxy) => GetHeadersAndValues(httpContext, headersToProxy);
public static IReadOnlyDictionary<string, string> HeadersAndValuesToProxy(HttpContext? httpContext, IReadOnlyCollection<string>? headersToProxy) => GetHeadersAndValues(httpContext, headersToProxy);


private static string[] GetHeadersValues(HttpContext? httpContext, IReadOnlyCollection<string>? headersNames)
{
try
{
if (headersNames is null || headersNames.Count == 0)
return Array.Empty<string>();
return [];

var headersValues = headersNames.Select(label => GetHeaderValue(httpContext, label)).ToArray();
return headersValues;
}
catch (ObjectDisposedException ex) // when thing happen outside http ctx e.g eventbus event handler
{
Logger.ErrorWithObject(ex, headersNames);
return Array.Empty<string>();
return [];
}
}

Expand All @@ -41,15 +42,15 @@ private static string GetHeaderValue(HttpContext? context, string headerName)
return "This service";

if (context.Request.Headers.TryGetValue(headerName, out var headerValues) && !StringValues.IsNullOrEmpty(headerValues))
return headerValues[0];
return headerValues[0]!;

return "Empty";
}

private static Dictionary<string, string> GetHeadersAndValues(HttpContext? httpContext, IReadOnlyCollection<string>? headersNames)
private static IReadOnlyDictionary<string, string> GetHeadersAndValues(HttpContext? httpContext, IReadOnlyCollection<string>? headersNames)
{
if (headersNames is null || headersNames.Count == 0 || httpContext is null)
return new Dictionary<string, string>();
return ReadOnlyDictionary<string, string>.Empty;

return headersNames
.Select(header => httpContext.Request.Headers.TryGetValue(header, out var headerValues)
Expand All @@ -60,8 +61,8 @@ private static Dictionary<string, string> GetHeadersAndValues(HttpContext? httpC
Value = headerValues[0]
}
: null)
.Where(headerAndValue => headerAndValue != null)
.Where(headerAndValue => headerAndValue is not null)
.ToDictionary(headerAndValue => headerAndValue!.Header,
headerAndValue => headerAndValue!.Value);
headerAndValue => headerAndValue!.Value)!;
}
}
142 changes: 117 additions & 25 deletions ATI.Services.Common/Mattermost/MattermostAdapter.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
#nullable enable
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using ATI.Services.Common.Behaviors;
using ATI.Services.Common.Http.Extensions;
using ATI.Services.Common.Logging;
using JetBrains.Annotations;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using NLog;

namespace ATI.Services.Common.Mattermost;

public class MattermostAdapter
/// <summary>
/// Адаптер для интеграции с Mattermost
/// </summary>
[PublicAPI]
public class MattermostAdapter(
IHttpClientFactory httpClientFactory,
MattermostOptions mattermostOptions)
{
private readonly ILogger _logger = LogManager.GetCurrentClassLogger();
private readonly MattermostOptions _mattermostOptions;
private readonly HttpClient _httpClient;
private const string MattermostPostMessageUrl = "/api/v4/posts";
private const string DefaultIconEmoji = ":upside_down_face:";
private const string DefaultBotName = "Nameless Bot";
public const string HttpClientName = nameof(MattermostAdapter);

private readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings
private static readonly JsonSerializerSettings JsonSerializerSettings = new()
{
ContractResolver = new DefaultContractResolver
{
Expand All @@ -29,47 +38,130 @@ public class MattermostAdapter
},
NullValueHandling = NullValueHandling.Include
};

public MattermostAdapter(
IHttpClientFactory httpClientFactory,
MattermostOptions mattermostOptions)
{
_mattermostOptions = mattermostOptions;
_httpClient = httpClientFactory.CreateClient(nameof(MattermostAdapter));
}

private readonly ILogger _logger = LogManager.GetCurrentClassLogger();

/// <summary>
/// Отправляет сообщение используя webhook
/// </summary>
/// <param name="text">Текст сообщения (поддерживает markdown)</param>
public async Task<OperationResult> SendAlertAsync(string text)
{
if (mattermostOptions.WebHook is null)
return new(ActionStatus.LogicalError, "Please setup WebHook in MattermostOptions");

try
{
var url = _mattermostOptions.MattermostAddress + _mattermostOptions.WebHook;
using var httpClient = httpClientFactory.CreateClient(HttpClientName);
var url = mattermostOptions.MattermostAddress + mattermostOptions.WebHook;
var payload = new MattermostWebHookRequestBody
{
Text = text,
IconEmoji = _mattermostOptions.IconEmoji,
Username = _mattermostOptions.UserName
IconEmoji = mattermostOptions.IconEmoji ?? DefaultIconEmoji,
Username = mattermostOptions.UserName ?? DefaultBotName
};

// We do it, because mattermost api return text format instead of json
var httpContent = new HttpRequestMessage
using var httpContent = new HttpRequestMessage
{
Method = HttpMethod.Post,
Content = new StringContent(JsonConvert.SerializeObject(payload, _jsonSerializerSettings),Encoding.UTF8, "application/json"),
Content = new StringContent(JsonConvert.SerializeObject(payload, JsonSerializerSettings),
Encoding.UTF8, "application/json"),
RequestUri = new Uri(url)
};

var httpResponse = await _httpClient.SendAsync(httpContent);
using var httpResponse = await httpClient.SendAsync(httpContent);

if (httpResponse.IsSuccessStatusCode)
return OperationResult.Ok;

var errResponse = $"Mattermost return error status code {httpResponse.StatusCode}";
_logger.ErrorWithObject(null, errResponse, new { request = httpContent, response = httpResponse });
return new OperationResult(ActionStatus.ExternalServerError, errResponse);
}
catch (Exception e)
{
_logger.ErrorWithObject(e, "Something went wrong when try to send alert");
return new(e);
}
}

/// <summary>
/// Отправить сообщение в канал используя интеграцию через бота
/// </summary>
/// <param name="message">Текст сообщения (поддерживает markdown)</param>
/// <param name="channelId">Id канала</param>
public async Task<OperationResult<PostMessageResponse>> SendMessageAsync(string message, string channelId)
{
var payload = new MattermostNotificationPayload
{
Message = message,
ChannelId = channelId
};
return await SendBotMessageAsync(payload);
}

/// <summary>
/// Отправить сообщение в тред
/// </summary>
/// <param name="message">Текст сообщения</param>
/// <param name="channelId">Id канала</param>
/// <param name="rootId">Id корневого сообщения (первого сообщения в треде)</param>
/// <returns></returns>
public async Task<OperationResult<PostMessageResponse>> SendThreadMessageAsync(
string message,
string channelId,
string rootId
)
{
var payload = new MattermostNotificationPayload
{
ChannelId = channelId,
RootId = rootId,
Message = message,
};
return await SendBotMessageAsync(payload);
}

private async Task<OperationResult<PostMessageResponse>> SendBotMessageAsync(MattermostNotificationPayload payload)
{
try
{
using var httpClient = httpClientFactory.CreateClient(HttpClientName);
var url = mattermostOptions.MattermostAddress + MattermostPostMessageUrl;

using var httpContent = new HttpRequestMessage
{
Method = HttpMethod.Post,
Content = new StringContent(JsonConvert.SerializeObject(payload, JsonSerializerSettings),
Encoding.UTF8, "application/json"),
RequestUri = new Uri(url),
Headers =
{
Authorization = new AuthenticationHeaderValue("Bearer", mattermostOptions.BotAccessToken)
}
};

using var httpResponse = await httpClient.SendAsync(httpContent);

if (!httpResponse.IsSuccessStatusCode)
{
_logger.ErrorWithObject(null, "Mattermost return error status code", new { request = httpContent, response = httpResponse });
return new OperationResult(ActionStatus.InternalServerError);
var responseErr = await httpResponse.Content.ReadAsStringAsync();
_logger.ErrorWithObject(null, "Mattermost return error status code", new { request = httpContent, response = responseErr });
return new(ActionStatus.ExternalServerError, responseErr);
}

return OperationResult.Ok;
var ans = await httpResponse.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<PostMessageResponse>(ans, JsonSerializerSettings);

return result is not null
? new(result)
: new (ActionStatus.LogicalError, $"Не удалось десериализовать ответ. Тело: {ans}");
}
catch (Exception e)
{
_logger.ErrorWithObject(e, "Something went wrong when try to send alert");
return new(ActionStatus.InternalServerError);
_logger.ErrorWithObject(e, "Something went wrong when try to send alert");
return new(e);
}
}
}
8 changes: 6 additions & 2 deletions ATI.Services.Common/Mattermost/MattermostExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
using ATI.Services.Common.Extensions;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;

namespace ATI.Services.Common.Mattermost;

[PublicAPI]
public static class MattermostExtensions
{
public static IServiceCollection AddMattermost(this IServiceCollection services)
{
services.ConfigureByName<MattermostProviderOptions>();
return services
.AddSingleton<MattermostProvider>();

services.AddHttpClient(MattermostAdapter.HttpClientName);

return services.AddSingleton<MattermostProvider>();
}
}
23 changes: 23 additions & 0 deletions ATI.Services.Common/Mattermost/MattermostNotificationPayload.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#nullable enable
using JetBrains.Annotations;

namespace ATI.Services.Common.Mattermost;

[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
internal class MattermostNotificationPayload
{
/// <summary>
/// Id канала
/// </summary>
public required string ChannelId { get; init; }

/// <summary>
/// Текст сообщения
/// </summary>
public required string Message { get; init; }

/// <summary>
/// Id корневого сообщения (для отправки в тред)
/// </summary>
public string? RootId { get; init; }
}
Loading

0 comments on commit 2d1cf2c

Please sign in to comment.