From 779e24c64bfc40d6acdaeb14f823ef70ad4dccab Mon Sep 17 00:00:00 2001 From: Georg Dangl Date: Tue, 30 Apr 2024 12:16:51 +0200 Subject: [PATCH] Add backend to send teams messages via webhook --- .../Controllers/TeamsMessagesController.cs | 27 ++++ .../TeamsMessages/NewCommentPost.cs | 16 ++ .../Services/TeamsMessages/TeamsFact.cs | 13 ++ .../Services/TeamsMessages/TeamsImage.cs | 10 ++ .../Services/TeamsMessages/TeamsMessage.cs | 23 +++ .../Services/TeamsMessages/TeamsSection.cs | 19 +++ .../Services/TeamsMessagesService.cs | 150 ++++++++++++++++++ src/IPA.Bcfier/IPA.Bcfier.csproj | 1 + 8 files changed, 259 insertions(+) create mode 100644 src/IPA.Bcfier.App/Controllers/TeamsMessagesController.cs create mode 100644 src/IPA.Bcfier.App/Models/Controllers/TeamsMessages/NewCommentPost.cs create mode 100644 src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsFact.cs create mode 100644 src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsImage.cs create mode 100644 src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsMessage.cs create mode 100644 src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsSection.cs create mode 100644 src/IPA.Bcfier.App/Services/TeamsMessagesService.cs diff --git a/src/IPA.Bcfier.App/Controllers/TeamsMessagesController.cs b/src/IPA.Bcfier.App/Controllers/TeamsMessagesController.cs new file mode 100644 index 00000000..b3ea252d --- /dev/null +++ b/src/IPA.Bcfier.App/Controllers/TeamsMessagesController.cs @@ -0,0 +1,27 @@ +using IPA.Bcfier.App.Models.Controllers.TeamsMessages; +using IPA.Bcfier.App.Services; +using Microsoft.AspNetCore.Mvc; +using System.Net; + +namespace IPA.Bcfier.App.Controllers +{ + [ApiController] + [Route("api/teams-messages")] + public class TeamsMessagesController : ControllerBase + { + private readonly TeamsMessagesService _teamsMessagesService; + + public TeamsMessagesController(TeamsMessagesService teamsMessagesService) + { + _teamsMessagesService = teamsMessagesService; + } + + [HttpPost("projects/{projectId}/topics/{topicId}/comments")] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + public async Task AnnounceNewCommentInProjectTopicAsync(Guid projectId, Guid topicId, [FromBody] TeamsMessagePost model) + { + await _teamsMessagesService.AnnounceNewCommentInProjectTopicAsync(projectId, model); + return NoContent(); + } + } +} diff --git a/src/IPA.Bcfier.App/Models/Controllers/TeamsMessages/NewCommentPost.cs b/src/IPA.Bcfier.App/Models/Controllers/TeamsMessages/NewCommentPost.cs new file mode 100644 index 00000000..3d70696d --- /dev/null +++ b/src/IPA.Bcfier.App/Models/Controllers/TeamsMessages/NewCommentPost.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace IPA.Bcfier.App.Models.Controllers.TeamsMessages +{ + public class TeamsMessagePost + { + public string? TopicTitle { get; set; } + + public string? Comment { get; set; } + + public string? ViewpointBase64 { get; set; } + + [Required] + public string Username { get; set; } = string.Empty; + } +} diff --git a/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsFact.cs b/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsFact.cs new file mode 100644 index 00000000..822bc59a --- /dev/null +++ b/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsFact.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace IPA.Bcfier.App.Models.Services.TeamsMessages +{ + public class TeamsFact + { + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("value")] + public string Value { get; set; } = string.Empty; + } +} diff --git a/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsImage.cs b/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsImage.cs new file mode 100644 index 00000000..05305767 --- /dev/null +++ b/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsImage.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace IPA.Bcfier.App.Models.Services.TeamsMessages +{ + public class TeamsImage + { + [JsonProperty("image")] + public string ImageBase64DataUrl { get; set; } = string.Empty; + } +} diff --git a/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsMessage.cs b/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsMessage.cs new file mode 100644 index 00000000..cc84dbc9 --- /dev/null +++ b/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsMessage.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace IPA.Bcfier.App.Models.Services.TeamsMessages +{ + public class TeamsMessage + { + [JsonProperty("@type")] + public string Type { get; } = "MessageCard"; + + [JsonProperty("@context")] + public string Context { get; } = "http://schema.org/extensions"; + + [JsonProperty("themeColor")] + public string ThemeColor { get; } = "0076D7"; + //public string ThemeColor { get; } = "eed300"; + + [JsonProperty("summary")] + public string Summary { get; set; } = string.Empty; + + [JsonProperty("sections")] + public List Sections { get; set; } = new List(); + } +} diff --git a/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsSection.cs b/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsSection.cs new file mode 100644 index 00000000..4db4dd19 --- /dev/null +++ b/src/IPA.Bcfier.App/Models/Services/TeamsMessages/TeamsSection.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace IPA.Bcfier.App.Models.Services.TeamsMessages +{ + public class TeamsSection + { + [JsonProperty("activityTitle")] + public string? ActivityTitle { get; set; } + + [JsonProperty("activitySubtitle")] + public string? ActivitySubtitle { get; set; } + + [JsonProperty("facts")] + public List? Facts { get; set; } + + [JsonProperty("images")] + public List? Images { get; set; } + } +} diff --git a/src/IPA.Bcfier.App/Services/TeamsMessagesService.cs b/src/IPA.Bcfier.App/Services/TeamsMessagesService.cs new file mode 100644 index 00000000..94792e1a --- /dev/null +++ b/src/IPA.Bcfier.App/Services/TeamsMessagesService.cs @@ -0,0 +1,150 @@ +using IPA.Bcfier.App.Data; +using IPA.Bcfier.App.Models.Controllers.TeamsMessages; +using IPA.Bcfier.App.Models.Services.TeamsMessages; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Processing; +using System.Text; + +namespace IPA.Bcfier.App.Services +{ + public class TeamsMessagesService + { + private readonly BcfierDbContext _context; + + public TeamsMessagesService(BcfierDbContext context) + { + _context = context; + } + + public async Task AnnounceNewCommentInProjectTopicAsync(Guid projectId, + TeamsMessagePost message) + { + var dbProject = await _context + .Projects + .Where(p => p.Id == projectId) + .Select(p => new + { + p.TeamsWebhook, + p.Name + }) + .FirstOrDefaultAsync(); + if (dbProject == default || string.IsNullOrWhiteSpace(dbProject.TeamsWebhook)) + { + // Not doing anything for projects that either don't exist + // or don't have a webhook configured + return; + } + + var teamsMessage = new TeamsMessage(); + var title = !string.IsNullOrWhiteSpace(message.TopicTitle) + ? "New topic: " + message.TopicTitle + : (!string.IsNullOrWhiteSpace(message.Comment) + ? "New comment: " + message.Comment + : "New viewpoint"); + + var mainSection = new TeamsSection + { + ActivityTitle = title, + ActivitySubtitle = "Project: " + dbProject.Name, + Facts = new List + { + new TeamsFact + { + Name = "Author", + Value = message.Username + } + } + }; + teamsMessage.Sections = new List { mainSection }; + + if (!string.IsNullOrWhiteSpace(message.ViewpointBase64)) + { + teamsMessage.Sections.Add(new TeamsSection + { + Images = new List + { + new TeamsImage + { + ImageBase64DataUrl = "data:image/png;base64," + message.ViewpointBase64 + } + } + }); + } + + await SendTeamsMessageAsync(teamsMessage, dbProject.TeamsWebhook); + } + + private async Task SendTeamsMessageAsync(TeamsMessage message, + string teamsWebhookUrl) + { + // Apparently, the max message size for Teams webhooks is 28.000 characters + // so we're ensuring we resize images to fit in there + var messageJson = GetTeamsMessageWithMaxSizeInBytes(message, 28_000); + if (string.IsNullOrWhiteSpace(messageJson)) + { + // Looks like the message was too large for the webhook + return; + } + + using var httpClient = new HttpClient(); + var body = new StringContent(messageJson, Encoding.Default, "application/json"); + var request = new HttpRequestMessage(HttpMethod.Post, teamsWebhookUrl); + request.Content = body; + + await httpClient.SendAsync(request); + } + + private static string? GetTeamsMessageWithMaxSizeInBytes(TeamsMessage message, + int maxMessageLength) + { + var jsonOptions = new JsonSerializerSettings(); + jsonOptions.Formatting = Formatting.None; + jsonOptions.NullValueHandling = NullValueHandling.Ignore; + var messageJson = JsonConvert.SerializeObject(message, jsonOptions); + + if (messageJson.Length <= maxMessageLength) + { + return messageJson; + } + + var maxTries = 5; + var currentTry = 0; + while (currentTry < maxTries) + { + maxTries++; + // Let's compress the images + foreach (var section in message.Sections.Where(s => s.Images != null)) + { + foreach (var imageSection in section.Images.Where(image => !string.IsNullOrWhiteSpace(image.ImageBase64DataUrl))) + { + var imageBase64 = imageSection.ImageBase64DataUrl.Substring(imageSection.ImageBase64DataUrl.IndexOf(",") + 1); + var imageBytes = Convert.FromBase64String(imageBase64); + + using var image = SixLabors.ImageSharp.Image.Load(new MemoryStream(imageBytes)); + var width = image.Width / 2; + var height = image.Height / 2; + image.Mutate(x => x.Resize(width, height)); + using var outMemStream = new MemoryStream(); + image.Save(outMemStream, new PngEncoder()); + imageSection.ImageBase64DataUrl = "data:image/png;base64," + Convert.ToBase64String(outMemStream.ToArray()); + } + } + + messageJson = JsonConvert.SerializeObject(message, jsonOptions); + + if (messageJson.Length <= maxMessageLength) + { + return messageJson; + } + } + + // If we're still to big, there's nothing we can do - we'll + // not keep decreasing, since we already halved it 5 times, so it + // probably was way too big anyway for anything practical here + + return null; + } + } +} diff --git a/src/IPA.Bcfier/IPA.Bcfier.csproj b/src/IPA.Bcfier/IPA.Bcfier.csproj index c5383e96..436639fb 100644 --- a/src/IPA.Bcfier/IPA.Bcfier.csproj +++ b/src/IPA.Bcfier/IPA.Bcfier.csproj @@ -8,6 +8,7 @@ +