From 2d36a60f5d639d9c3277e3087b31edc3d3470edd Mon Sep 17 00:00:00 2001 From: Dmitri Shimanski Date: Tue, 29 Jul 2025 23:37:04 +0300 Subject: [PATCH] Create project file --- .dockerignore | 25 + .github/workflows/main.yml | 61 +++ .github/workflows/nonmain.yml | 29 ++ .gitignore | 5 + Aoyo.Taiga.sln | 22 + Aoyo.Taiga/Aoyo.Taiga.csproj | 22 + Aoyo.Taiga/Aoyo.Taiga.http | 6 + Aoyo.Taiga/Controllers/TaigaWebHook.cs | 446 ++++++++++++++++++ Aoyo.Taiga/Discord/DiscordHandler.cs | 51 ++ Aoyo.Taiga/Discord/DiscordService.cs | 27 ++ Aoyo.Taiga/Dockerfile | 39 ++ Aoyo.Taiga/Program.cs | 18 + Aoyo.Taiga/Properties/launchSettings.json | 23 + Aoyo.Taiga/Startup.cs | 53 +++ Aoyo.Taiga/WebHookTypes/BaseTaigaItem.cs | 54 +++ Aoyo.Taiga/WebHookTypes/DiscordDTO.cs | 13 + Aoyo.Taiga/WebHookTypes/ITaigaItem.cs | 55 +++ Aoyo.Taiga/WebHookTypes/Payloads.cs | 5 + Aoyo.Taiga/WebHookTypes/TaigaChange.cs | 15 + Aoyo.Taiga/WebHookTypes/TaigaFieldChange.cs | 12 + Aoyo.Taiga/WebHookTypes/TaigaIssue.cs | 30 ++ Aoyo.Taiga/WebHookTypes/TaigaMilestone.cs | 21 + Aoyo.Taiga/WebHookTypes/TaigaPoint.cs | 13 + Aoyo.Taiga/WebHookTypes/TaigaPriority.cs | 18 + Aoyo.Taiga/WebHookTypes/TaigaProject.cs | 18 + Aoyo.Taiga/WebHookTypes/TaigaSeverity.cs | 18 + Aoyo.Taiga/WebHookTypes/TaigaStatus.cs | 18 + Aoyo.Taiga/WebHookTypes/TaigaTask.cs | 22 + Aoyo.Taiga/WebHookTypes/TaigaType.cs | 15 + Aoyo.Taiga/WebHookTypes/TaigaUser.cs | 21 + Aoyo.Taiga/WebHookTypes/TaigaUserStory.cs | 45 ++ Aoyo.Taiga/WebHookTypes/TaigaWebhookParser.cs | 19 + .../WebHookTypes/TaigaWebhookPayload.cs | 35 ++ Aoyo.Taiga/WebHookTypes/WebhookAction.cs | 10 + Aoyo.Taiga/appsettings.Development.json | 8 + Aoyo.Taiga/appsettings.json | 9 + LICENCE | 201 ++++++++ README.md | 78 +++ 38 files changed, 1580 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/nonmain.yml create mode 100644 .gitignore create mode 100644 Aoyo.Taiga.sln create mode 100644 Aoyo.Taiga/Aoyo.Taiga.csproj create mode 100644 Aoyo.Taiga/Aoyo.Taiga.http create mode 100644 Aoyo.Taiga/Controllers/TaigaWebHook.cs create mode 100644 Aoyo.Taiga/Discord/DiscordHandler.cs create mode 100644 Aoyo.Taiga/Discord/DiscordService.cs create mode 100644 Aoyo.Taiga/Dockerfile create mode 100644 Aoyo.Taiga/Program.cs create mode 100644 Aoyo.Taiga/Properties/launchSettings.json create mode 100644 Aoyo.Taiga/Startup.cs create mode 100644 Aoyo.Taiga/WebHookTypes/BaseTaigaItem.cs create mode 100644 Aoyo.Taiga/WebHookTypes/DiscordDTO.cs create mode 100644 Aoyo.Taiga/WebHookTypes/ITaigaItem.cs create mode 100644 Aoyo.Taiga/WebHookTypes/Payloads.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaChange.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaFieldChange.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaIssue.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaMilestone.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaPoint.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaPriority.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaProject.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaSeverity.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaStatus.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaTask.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaType.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaUser.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaUserStory.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaWebhookParser.cs create mode 100644 Aoyo.Taiga/WebHookTypes/TaigaWebhookPayload.cs create mode 100644 Aoyo.Taiga/WebHookTypes/WebhookAction.cs create mode 100644 Aoyo.Taiga/appsettings.Development.json create mode 100644 Aoyo.Taiga/appsettings.json create mode 100644 LICENCE create mode 100644 README.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..cd44fb0 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,61 @@ +name: .NET CI/CD + +on: + push: + branches: [ "master", "develop", "main" ] + paths-ignore: + - 'README.md' + - '*.md' + +jobs: + build: + name: Unit and Integration tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal + + compose: + name: Push Docker image to ghcr.io + runs-on: ubuntu-latest + needs: build + permissions: + packages: write + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: | + ghcr.io/${{ github.repository }} + + - name: Build and push Docker images + uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + with: + context: . + file: Aoyo.Taiga/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/nonmain.yml b/.github/workflows/nonmain.yml new file mode 100644 index 0000000..5917813 --- /dev/null +++ b/.github/workflows/nonmain.yml @@ -0,0 +1,29 @@ +name: .NET CI + +on: + push: + branches: [ "*" ] + paths-ignore: + - 'README.md' + - '*.md' + pull_request: + branches: [ "*" ] + +jobs: + build: + name: Unit and Integration tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/Aoyo.Taiga.sln b/Aoyo.Taiga.sln new file mode 100644 index 0000000..d8a0a6b --- /dev/null +++ b/Aoyo.Taiga.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aoyo.Taiga", "Aoyo.Taiga\Aoyo.Taiga.csproj", "{CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B8D94470-53F6-4C05-BADF-1A97910B3B74}" + ProjectSection(SolutionItems) = preProject + compose.yaml = compose.yaml + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Aoyo.Taiga/Aoyo.Taiga.csproj b/Aoyo.Taiga/Aoyo.Taiga.csproj new file mode 100644 index 0000000..4bee911 --- /dev/null +++ b/Aoyo.Taiga/Aoyo.Taiga.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + Linux + + + + + + + + + + + .dockerignore + + + + diff --git a/Aoyo.Taiga/Aoyo.Taiga.http b/Aoyo.Taiga/Aoyo.Taiga.http new file mode 100644 index 0000000..211732f --- /dev/null +++ b/Aoyo.Taiga/Aoyo.Taiga.http @@ -0,0 +1,6 @@ +@Aoyo.Taiga_HostAddress = http://localhost:8080 + +GET {{Aoyo.Taiga_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Aoyo.Taiga/Controllers/TaigaWebHook.cs b/Aoyo.Taiga/Controllers/TaigaWebHook.cs new file mode 100644 index 0000000..0a5238b --- /dev/null +++ b/Aoyo.Taiga/Controllers/TaigaWebHook.cs @@ -0,0 +1,446 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Aoyo.Taiga.WebHookTypes; +using Discord; +using Discord.WebSocket; +using Microsoft.AspNetCore.Mvc; + +namespace Aoyo.Taiga.Controllers; + + +[Route("api/v1/TaigaWebHook")] +[ApiController] +[SuppressMessage("ReSharper", "UnusedVariable")] +[SuppressMessage("ReSharper", "UnusedMember.Local")] +public class TaigaWebHook : Controller +{ + private const string ChannelIdConfigurationPath = "Discord:Id"; + private const string TaigaKeyPath = "Taiga:Key"; + private readonly ulong _id; + private ITextChannel? _logChannel; + private readonly DiscordSocketClient _client; + private readonly ILogger _logger; + private readonly string _key; + private IConfiguration _config; + + public TaigaWebHook(DiscordSocketClient client, ILogger logger, IConfiguration config) + { + _config = config; + _client = client; + _logger = logger; + _id = config.GetValue(ChannelIdConfigurationPath) ?? throw new NullReferenceException("Channel id should to be provided"); + _key = config.GetValue(TaigaKeyPath) ?? throw new NullReferenceException("Taiga KEY should to be provided!") ; + _logChannel = client.GetChannel(_id) as ITextChannel ?? throw new NullReferenceException("Channel not found."); + } + + [HttpPost("post")] + public async Task PostAsync() + { + try + { + var signature = Request.Headers["X-TAIGA-WEBHOOK-SIGNATURE"].First()!; + + using var reader = new StreamReader(Request.Body); + var data = await reader.ReadToEndAsync(); + _logger.LogInformation(data); + + if (_config.GetValue("ASPNETCORE_ENVIRONMENT") == "Production") + if (!VerifySignature(_key, data, signature)) + { + return BadRequest("Invalid signature"); + } + + + var webhookType = DetermineWebhookType(data); + + switch (webhookType) + { + case "userstory": + await HandleUserStoryWebhook(data); + break; + case "issue": + await HandleIssueWebhook(data); + break; + case "task": + await HandleTaskWebhook(data); + break; + default: + _logger.LogWarning("Unsupported type of webhook: {WebhookType}", webhookType); + return BadRequest($"Unsupported type of webhook: {webhookType}"); + } + + return Ok(); + } + catch (Exception exception) + { + _logger.LogError(exception, exception.Message); + return BadRequest(exception.Message); + } + } + + private bool VerifySignature(string key, string data, string signature) + { + using HMACSHA1 hmac = new HMACSHA1(Encoding.UTF8.GetBytes(key)); + var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(data)); + var computedHash = Convert.ToHexString(hashBytes).ToLower(); + + return computedHash.Length == signature.Length; + } + + private async Task HandleUserStoryWebhook(string json) + { + try + { + var webhook = TaigaWebhookParser.ParseUserStoryWebhook(json); + var userStory = webhook.Data; + var action = webhook.GetAction(); + + _logger.LogInformation("Handling User Story: {Subject} (ID: {Id}), Action: {Action}", + userStory.Subject, userStory.Id, action); + + switch (action) + { + case WebhookAction.Create: + await OnUserStoryCreated(userStory, webhook); + break; + case WebhookAction.Change: + await OnUserStoryChanged(userStory, webhook); + break; + case WebhookAction.Delete: + await OnUserStoryDeleted(userStory, webhook); + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error when handled User Story webhook"); + throw; + } + } + + private async Task HandleIssueWebhook(string json) + { + try + { + var webhook = TaigaWebhookParser.ParseIssueWebhook(json); + var issue = webhook.Data; + var action = webhook.GetAction(); + + _logger.LogInformation("Processing Issue: {Subject} (ID: {Id}), Action: {Action}", + issue.Subject, issue.Id, action); + + switch (action) + { + case WebhookAction.Create: + await OnIssueCreated(issue, webhook); + break; + case WebhookAction.Change: + await OnIssueChanged(issue, webhook); + break; + case WebhookAction.Delete: + await OnIssueDeleted(issue, webhook); + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error when processing Issue webhook"); + throw; + } + } + + private async Task HandleTaskWebhook(string json) + { + try + { + var webhook = TaigaWebhookParser.ParseTaskWebhook(json); + var task = webhook.Data; + var action = webhook.GetAction(); + + _logger.LogInformation("Processing Task: {Subject} (ID: {Id}), Action: {Action}", + task.Subject, task.Id, action); + + switch (action) + { + case WebhookAction.Create: + await OnTaskCreated(task, webhook); + break; + case WebhookAction.Change: + await OnTaskChanged(task, webhook); + break; + case WebhookAction.Delete: + await OnTaskDeleted(task, webhook); + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error when processing Task webhook"); + throw; + } + } + + private async Task OnUserStoryCreated(TaigaUserStory userStory, UserStoryWebhookPayload? webhook) + { + _logger.LogInformation("Created new User Story: {Subject} in project {ProjectName}", + userStory.Subject, userStory.Project.Name); + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Created", + Type = "User Story", + Date = webhook.Date, + Description = $"**{userStory.Subject}**\n" + + $"> Project: {userStory.Project.Name}\n" + + $"> Status: {userStory.Status.Name}\n" + + $"> Points: {userStory.TotalPoints}", + Color = Color.Green + }); + } + + private async Task OnUserStoryChanged(TaigaUserStory userStory, UserStoryWebhookPayload? webhook) + { + _logger.LogInformation("Changed User Story: {Subject}", userStory.Subject); + + var changes = ""; + if (webhook.Change?.Diff != null) + { + foreach (var change in webhook.Change.Diff) + { + changes += $"• {change.Key}: {change.Value.From} → {change.Value.To}\n"; + } + } + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Changed", + Type = "User Story", + Date = webhook.Date, + Description = $"**{userStory.Subject}**\n" + + $"> Project: {userStory.Project.Name}\n" + + $"> Changes:\n{changes}", + Color = Color.LightOrange + }); + } + + private async Task OnUserStoryDeleted(TaigaUserStory userStory, UserStoryWebhookPayload? webhook) + { + _logger.LogInformation("Deleted User Story: {Subject} (ID: {Id})", + userStory.Subject, userStory.Id); + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Deleted", + Type = "User Story", + Date = webhook.Date, + Description = $"**{userStory.Subject}**\n" + + $"> Project: {userStory.Project.Name}\n" + + $"> ID: {userStory.Id}", + Color = Color.DarkRed + }); + } + + // Методы для обработки Issue событий + private async Task OnIssueCreated(TaigaIssue issue, IssueWebhookPayload? webhook) + { + _logger.LogInformation("Created new Issue: {Subject} in project {ProjectName}", + issue.Subject, issue.Project.Name); + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Created", + Type = "Issue", + Date = webhook.Date, + Description = $"**{issue.Subject}**\n" + + $"> Project: {issue.Project.Name}\n" + + $"> Type: {issue.Type.Name}\n" + + $"> Prority: {issue.Priority.Name}", + Color = Color.Blue + }); + } + + private async Task OnIssueChanged(TaigaIssue issue, IssueWebhookPayload? webhook) + { + _logger.LogInformation("Changed Issue: {Subject}", issue.Subject); + + var changes = ""; + if (webhook.Change?.Diff != null) + { + foreach (var change in webhook.Change.Diff) + { + changes += $"> • {change.Key}: {change.Value.From} → {change.Value.To}\n"; + } + } + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Changed", + Type = "Issue", + Date = webhook.Date, + Description = $"**{issue.Subject}**\n" + + $"> Project: {issue.Project.Name}\n" + + $"> Changes:\n{changes}", + Color = Color.LightOrange + }); + } + + private async Task OnIssueDeleted(TaigaIssue issue, IssueWebhookPayload? webhook) + { + _logger.LogInformation("Deleted Issue: {Subject} (ID: {Id})", + issue.Subject, issue.Id); + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Deleted", + Type = "Issue", + Date = webhook.Date, + Description = $"**{issue.Subject}**\n" + + $"> Project: {issue.Project.Name}\n" + + $"> ID: {issue.Id}", + Color = Color.Red + }); + } + + private async Task OnTaskCreated(TaigaTask task, TaskWebhookPayload? webhook) + { + _logger.LogInformation("Created new Task: {Subject} in project {ProjectName}", + task.Subject, task.Project.Name); + + var description = $"**{task.Subject}**\n" + + $"> Project: {task.Project.Name}\n" + + $"> Исполнитель: {task.AssignedTo?.FullName ?? "Не назначен"}"; + + if (task.UserStory != null) + { + description += $"\n> User Story: {task.UserStory.Subject} (#{task.UserStory.Ref})"; + } + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Created", + Type = "Task", + Date = webhook.Date, + Description = description, + Color = Color.Green + }); + } + + private async Task OnTaskChanged(TaigaTask task, TaskWebhookPayload? webhook) + { + _logger.LogInformation("Changed Task: {Subject}", task.Subject); + + var changes = ""; + if (webhook.Change?.Diff != null) + { + foreach (var change in webhook.Change.Diff) + { + changes += $"• {change.Key}: {change.Value.From} → {change.Value.To}\n"; + } + } + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Changed", + Type = "Task", + Date = webhook.Date, + Description = $"**{task.Subject}**\n" + + $"> Project: {task.Project.Name}\n" + + $"> Changes:\n{changes}", + Color = Color.Orange + }); + } + + private async Task OnTaskDeleted(TaigaTask task, TaskWebhookPayload? webhook) + { + _logger.LogInformation("Deleted Task: {Subject} (ID: {Id})", + task.Subject, task.Id); + + await SendDiscordNotification(new DiscordDTO + { + By = webhook.By, + Action = "Deleted", + Type = "Task", + Date = webhook.Date, + Description = $"**{task.Subject}**\n" + + $"> Project: {task.Project.Name}\n" + + $"> ID: {task.Id}", + Color = Color.Red + }); + } + + private async Task SendDiscordNotification(DiscordDTO payload) + { + try + { + _logChannel ??= _client.GetChannel(_id) as ITextChannel; + + if (_logChannel == null) + { + _logger.LogError("Не удалось получить Discord канал с ID: {ChannelId}", _id); + return; + } + + await _logChannel.SendMessageAsync(embed: + new EmbedBuilder() + .WithAuthor(payload.By.Username, payload.By.Photo) + .WithTitle($"{payload.Action} {payload.Type.ToLower()}") + .WithDescription(payload.Description) + .WithColor(payload.Color) + .WithTimestamp(payload.Date) + .Build()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error when sending message into Discord"); + } + } + + + private string DetermineWebhookType(string json) + { + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + if (root.TryGetProperty("type", out var typeElement)) + { + return typeElement.GetString()?.ToLower()!; + } + + if (root.TryGetProperty("data", out var dataElement)) + { + if (dataElement.TryGetProperty("points", out _)) + { + return "userstory"; + } + else if (dataElement.TryGetProperty("type", out _) && dataElement.TryGetProperty("priority", out _)) + { + return "issue"; + } + else if (dataElement.TryGetProperty("user_story", out _)) + { + return "task"; + } + } + + return "unknown"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot parse this type of webhook"); + return "unknown"; + } + } + +} \ No newline at end of file diff --git a/Aoyo.Taiga/Discord/DiscordHandler.cs b/Aoyo.Taiga/Discord/DiscordHandler.cs new file mode 100644 index 0000000..b238d54 --- /dev/null +++ b/Aoyo.Taiga/Discord/DiscordHandler.cs @@ -0,0 +1,51 @@ +using System.Reflection; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; + +namespace Aoyo.Taiga.Discord; + +public class DiscordHandler( + DiscordSocketClient client, + InteractionService interactionService, + ILogger logger, + IServiceScope provider) +{ + + public async Task InitializeAsync() + { + client.Ready += ClientOnReady; + client.Log += ClientOnLog; + await interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), provider.ServiceProvider); + client.InteractionCreated += ClientOnInteractionCreated; + } + + private async Task ClientOnInteractionCreated(SocketInteraction arg) + { + try + { + var context = new SocketInteractionContext(client, arg); + var result = await interactionService.ExecuteCommandAsync(context, provider.ServiceProvider); + if (result.Error is not null) + { + throw new Exception($"{result.ErrorReason}: {result.Error}"); + } + } + catch (Exception ex) + { + await arg.RespondAsync(":x: " + ex.Message + "", ephemeral: true); + logger.LogError(ex, ex.Message); + } + } + + private Task ClientOnLog(LogMessage arg) + { + logger.LogInformation(exception: arg.Exception, message:arg.Message); + return Task.CompletedTask; + } + + private async Task ClientOnReady() + { + await interactionService.RegisterCommandsGloballyAsync(); + } +} \ No newline at end of file diff --git a/Aoyo.Taiga/Discord/DiscordService.cs b/Aoyo.Taiga/Discord/DiscordService.cs new file mode 100644 index 0000000..34d1dc4 --- /dev/null +++ b/Aoyo.Taiga/Discord/DiscordService.cs @@ -0,0 +1,27 @@ +using Discord; +using Discord.WebSocket; + +namespace Aoyo.Taiga.Discord; + +public class DiscordService( + DiscordSocketClient client, + IConfiguration configuration, + ILogger logger, + DiscordHandler interactionHandler) + : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + client.Log += ClientOnLog; + await client.LoginAsync(TokenType.Bot, configuration.GetValue("Discord:Token")); + await client.StartAsync(); + await interactionHandler.InitializeAsync(); + await Task.Delay(Timeout.Infinite, stoppingToken); + } + + private Task ClientOnLog(LogMessage arg) + { + logger.LogInformation(exception: arg.Exception, message:arg.Message); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Aoyo.Taiga/Dockerfile b/Aoyo.Taiga/Dockerfile new file mode 100644 index 0000000..678ee2e --- /dev/null +++ b/Aoyo.Taiga/Dockerfile @@ -0,0 +1,39 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Aoyo.Taiga/Aoyo.Taiga.csproj", "Aoyo.Taiga/"] +RUN dotnet restore "Aoyo.Taiga/Aoyo.Taiga.csproj" +COPY . . +WORKDIR "/src/Aoyo.Taiga" +RUN dotnet build "./Aoyo.Taiga.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Aoyo.Taiga.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +RUN apt-get update && \ + apt-get install -y curl && \ + rm -rf /var/lib/apt/lists/* \ + +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD curl -f http://localhost:8080/aoyo/health || exit 1 + +ENTRYPOINT ["dotnet", "Aoyo.Taiga.dll"] + +LABEL org.opencontainers.image.title="Aoyo.Taiga" \ + org.opencontainers.image.description="Taiga`s webhook catcher" \ + org.opencontainers.image.version="1.0.0" \ + org.opencontainers.image.url="https://github.com/yawaflua/Aoyo.Taiga" \ + org.opencontainers.image.source="https://github.com/yawaflua/Aoyo.Taiga" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.authors="Dmitrii Shimanskii " \ + org.opencontainers.image.created="2025-07-29T12:00:00Z" \ + org.opencontainers.image.vendor="Diamond Studio" \ No newline at end of file diff --git a/Aoyo.Taiga/Program.cs b/Aoyo.Taiga/Program.cs new file mode 100644 index 0000000..a587545 --- /dev/null +++ b/Aoyo.Taiga/Program.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore; + +namespace Aoyo.Taiga; + +public class Program +{ + public static void Main(string[] args) + { + WebHost. + CreateDefaultBuilder(args). + UseStartup(). + UseKestrel( + l => + l.ListenAnyIP(8080) + ). + Build().Run(); + } +} \ No newline at end of file diff --git a/Aoyo.Taiga/Properties/launchSettings.json b/Aoyo.Taiga/Properties/launchSettings.json new file mode 100644 index 0000000..2bff1f6 --- /dev/null +++ b/Aoyo.Taiga/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http_release": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Release" + } + } + } +} diff --git a/Aoyo.Taiga/Startup.cs b/Aoyo.Taiga/Startup.cs new file mode 100644 index 0000000..fb32133 --- /dev/null +++ b/Aoyo.Taiga/Startup.cs @@ -0,0 +1,53 @@ +using Aoyo.Taiga.Discord; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; + +namespace Aoyo.Taiga; + +public class Startup(IConfiguration configuration) +{ + private readonly DiscordSocketConfig _socketConfig = new() + { + GatewayIntents = GatewayIntents.All, + AlwaysDownloadUsers = true + }; + + public void ConfigureServices(IServiceCollection services) + { + var factory = LoggerFactory.Create(builder => builder.AddFilter(k => k >= + (configuration.GetValue("Logging:LogLevel:Default") ?? + LogLevel.Information)).AddConsole()); + services.AddControllers(); + services.AddHttpLogging(); + services.AddSingleton(factory); + services.AddRouting(); + services.AddSingleton(_socketConfig); + services.AddSingleton(configuration); + services.AddSingleton(); + services.AddSingleton(x => new InteractionService(x.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(x => x.CreateScope()); + services.AddHostedService(); + services.AddHealthChecks(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UsePathBase("/aoyo/"); + app.UseHealthChecks("/health"); + app.UseHttpLogging(); + + app.UseRouting(); + + app.UseEndpoints(endpoints => { + endpoints.MapControllers(); + endpoints.MapHealthChecks("/heath"); + }); + } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/BaseTaigaItem.cs b/Aoyo.Taiga/WebHookTypes/BaseTaigaItem.cs new file mode 100644 index 0000000..427cee0 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/BaseTaigaItem.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public abstract class BaseTaigaItem : ITaigaItem +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("ref")] + public int Ref { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("created_date")] + public DateTime CreatedDate { get; set; } + + [JsonPropertyName("modified_date")] + public DateTime? ModifiedDate { get; set; } + + [JsonPropertyName("owner")] + public TaigaUser Owner { get; set; } + + [JsonPropertyName("assigned_to")] + public TaigaUser? AssignedTo { get; set; } + + [JsonPropertyName("project")] + public TaigaProject Project { get; set; } + + [JsonPropertyName("status")] + public TaigaStatus Status { get; set; } + + [JsonPropertyName("tags")] + public List Tags { get; set; } = new List(); + + [JsonPropertyName("is_closed")] + public bool IsClosed { get; set; } + + [JsonPropertyName("is_blocked")] + public bool IsBlocked { get; set; } + + [JsonPropertyName("blocked_note")] + public string BlockedNote { get; set; } + + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("watchers")] + public List Watchers { get; set; } = new List(); +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/DiscordDTO.cs b/Aoyo.Taiga/WebHookTypes/DiscordDTO.cs new file mode 100644 index 0000000..10fcff0 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/DiscordDTO.cs @@ -0,0 +1,13 @@ +using Discord; + +namespace Aoyo.Taiga.WebHookTypes; + +public class DiscordDTO +{ + public TaigaUser By { get; set; } + public string Action { get; set; } + public string Type { get; set; } + public DateTime Date { get; set; } + public string Description { get; set; } + public Color Color { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/ITaigaItem.cs b/Aoyo.Taiga/WebHookTypes/ITaigaItem.cs new file mode 100644 index 0000000..ddebb1b --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/ITaigaItem.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public interface ITaigaItem +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("ref")] + public int Ref { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("created_date")] + public DateTime CreatedDate { get; set; } + + [JsonPropertyName("modified_date")] + public DateTime? ModifiedDate { get; set; } + + [JsonPropertyName("owner")] + public TaigaUser Owner { get; set; } + + [JsonPropertyName("assigned_to")] + public TaigaUser AssignedTo { get; set; } + + [JsonPropertyName("project")] + public TaigaProject Project { get; set; } + + [JsonPropertyName("status")] + public TaigaStatus Status { get; set; } + + [JsonPropertyName("tags")] + public List Tags { get; set; } + + [JsonPropertyName("is_closed")] + public bool IsClosed { get; set; } + + [JsonPropertyName("is_blocked")] + public bool IsBlocked { get; set; } + + [JsonPropertyName("blocked_note")] + public string BlockedNote { get; set; } + + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("watchers")] + public List Watchers { get; set; } + +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/Payloads.cs b/Aoyo.Taiga/WebHookTypes/Payloads.cs new file mode 100644 index 0000000..5450c29 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/Payloads.cs @@ -0,0 +1,5 @@ +namespace Aoyo.Taiga.WebHookTypes; + +public class UserStoryWebhookPayload : TaigaWebhookPayload { } +public class IssueWebhookPayload : TaigaWebhookPayload { } +public class TaskWebhookPayload : TaigaWebhookPayload { } \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaChange.cs b/Aoyo.Taiga/WebHookTypes/TaigaChange.cs new file mode 100644 index 0000000..d4d5e89 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaChange.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaChange +{ + [JsonPropertyName("comment")] + public string Comment { get; set; } + + [JsonPropertyName("comment_html")] + public string CommentHtml { get; set; } + + [JsonPropertyName("diff")] + public Dictionary Diff { get; set; } = new Dictionary(); +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaFieldChange.cs b/Aoyo.Taiga/WebHookTypes/TaigaFieldChange.cs new file mode 100644 index 0000000..9262b72 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaFieldChange.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaFieldChange +{ + [JsonPropertyName("from")] + public object From { get; set; } + + [JsonPropertyName("to")] + public object To { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaIssue.cs b/Aoyo.Taiga/WebHookTypes/TaigaIssue.cs new file mode 100644 index 0000000..672da02 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaIssue.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaIssue : BaseTaigaItem +{ + [JsonPropertyName("type")] + public TaigaType Type { get; set; } + + [JsonPropertyName("priority")] + public TaigaPriority Priority { get; set; } + + [JsonPropertyName("severity")] + public TaigaSeverity Severity { get; set; } + + [JsonPropertyName("milestone")] + public TaigaMilestone Milestone { get; set; } + + [JsonPropertyName("generated_user_stories")] + public List GeneratedUserStories { get; set; } = new List(); + + [JsonPropertyName("external_reference")] + public List ExternalReference { get; set; } = new List(); + + [JsonPropertyName("due_date")] + public DateTime? DueDate { get; set; } + + [JsonPropertyName("due_date_reason")] + public string DueDateReason { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaMilestone.cs b/Aoyo.Taiga/WebHookTypes/TaigaMilestone.cs new file mode 100644 index 0000000..a8ea4e0 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaMilestone.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaMilestone +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("estimated_start")] + public DateTime? EstimatedStart { get; set; } + + [JsonPropertyName("estimated_finish")] + public DateTime? EstimatedFinish { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaPoint.cs b/Aoyo.Taiga/WebHookTypes/TaigaPoint.cs new file mode 100644 index 0000000..7c4f7ec --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaPoint.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaPoint +{ + [JsonPropertyName("role")] + public string Role { get; set; } + [JsonPropertyName("name")] + public string Name { get; set; } + [JsonPropertyName("value")] + public float? Value { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaPriority.cs b/Aoyo.Taiga/WebHookTypes/TaigaPriority.cs new file mode 100644 index 0000000..62d8dc6 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaPriority.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaPriority +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("color")] + public string Color { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaProject.cs b/Aoyo.Taiga/WebHookTypes/TaigaProject.cs new file mode 100644 index 0000000..82ed18d --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaProject.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaProject +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaSeverity.cs b/Aoyo.Taiga/WebHookTypes/TaigaSeverity.cs new file mode 100644 index 0000000..0e06948 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaSeverity.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaSeverity +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("color")] + public string Color { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaStatus.cs b/Aoyo.Taiga/WebHookTypes/TaigaStatus.cs new file mode 100644 index 0000000..9624466 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaStatus.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaStatus +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("color")] + public string Color { get; set; } + + [JsonPropertyName("is_closed")] + public bool IsClosed { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaTask.cs b/Aoyo.Taiga/WebHookTypes/TaigaTask.cs new file mode 100644 index 0000000..242d72f --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaTask.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaTask : BaseTaigaItem +{ + [JsonPropertyName("user_story")] + public TaigaUserStory? UserStory { get; set; } + + [JsonPropertyName("milestone")] + public TaigaMilestone Milestone { get; set; } + + + [JsonPropertyName("external_reference")] + public List ExternalReference { get; set; } = new List(); + + [JsonPropertyName("due_date")] + public DateTime? DueDate { get; set; } + + [JsonPropertyName("due_date_reason")] + public string DueDateReason { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaType.cs b/Aoyo.Taiga/WebHookTypes/TaigaType.cs new file mode 100644 index 0000000..7ef9039 --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaType.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaType +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("color")] + public string Color { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaUser.cs b/Aoyo.Taiga/WebHookTypes/TaigaUser.cs new file mode 100644 index 0000000..ac6a0cc --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaUser.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaUser +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("full_name")] + public string FullName { get; set; } + + [JsonPropertyName("email")] + public string Email { get; set; } + + [JsonPropertyName("photo")] + public string Photo { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaUserStory.cs b/Aoyo.Taiga/WebHookTypes/TaigaUserStory.cs new file mode 100644 index 0000000..7c7e45f --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaUserStory.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaUserStory : BaseTaigaItem +{ + [JsonPropertyName("points")] + public List Points { get; set; } = new (); + + [JsonPropertyName("total_points")] + public float? TotalPoints { get; set; } + + [JsonPropertyName("milestone")] + public TaigaMilestone Milestone { get; set; } + + [JsonPropertyName("client_requirement")] + public bool ClientRequirement { get; set; } + + [JsonPropertyName("team_requirement")] + public bool TeamRequirement { get; set; } + + [JsonPropertyName("generated_from_issue")] + public int? GeneratedFromIssue { get; set; } + + [JsonPropertyName("generated_from_task")] + public int? GeneratedFromTask { get; set; } + + [JsonPropertyName("from_task_ref")] + public int? FromTaskRef { get; set; } + + [JsonPropertyName("external_reference")] + public List ExternalReference { get; set; } = new List(); + + [JsonPropertyName("tribe_gig")] + public string TribeGig { get; set; } + + [JsonPropertyName("kanban_order")] + public int KanbanOrder { get; set; } + + [JsonPropertyName("sprint_order")] + public int SprintOrder { get; set; } + + [JsonPropertyName("backlog_order")] + public int BacklogOrder { get; set; } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaWebhookParser.cs b/Aoyo.Taiga/WebHookTypes/TaigaWebhookParser.cs new file mode 100644 index 0000000..852cd4a --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaWebhookParser.cs @@ -0,0 +1,19 @@ +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaWebhookParser +{ + public static UserStoryWebhookPayload? ParseUserStoryWebhook(string json) + { + return System.Text.Json.JsonSerializer.Deserialize(json); + } + + public static IssueWebhookPayload? ParseIssueWebhook(string json) + { + return System.Text.Json.JsonSerializer.Deserialize(json); + } + + public static TaskWebhookPayload? ParseTaskWebhook(string json) + { + return System.Text.Json.JsonSerializer.Deserialize(json); + } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/TaigaWebhookPayload.cs b/Aoyo.Taiga/WebHookTypes/TaigaWebhookPayload.cs new file mode 100644 index 0000000..b8333cf --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/TaigaWebhookPayload.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Aoyo.Taiga.WebHookTypes; + +public class TaigaWebhookPayload where T : ITaigaItem +{ + [JsonPropertyName("action")] + public string Action { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("by")] + public TaigaUser By { get; set; } + + [JsonPropertyName("date")] + public DateTime Date { get; set; } + + [JsonPropertyName("data")] + public T Data { get; set; } + + [JsonPropertyName("change")] + public TaigaChange? Change { get; set; } + + public WebhookAction GetAction() + { + return Action?.ToLower() switch + { + "create" => WebhookAction.Create, + "change" => WebhookAction.Change, + "delete" => WebhookAction.Delete, + _ => WebhookAction.Change + }; + } +} \ No newline at end of file diff --git a/Aoyo.Taiga/WebHookTypes/WebhookAction.cs b/Aoyo.Taiga/WebHookTypes/WebhookAction.cs new file mode 100644 index 0000000..88a0f2d --- /dev/null +++ b/Aoyo.Taiga/WebHookTypes/WebhookAction.cs @@ -0,0 +1,10 @@ +namespace Aoyo.Taiga.WebHookTypes +{ + public enum WebhookAction + { + Create, + Change, + Delete, + Test + } +} \ No newline at end of file diff --git a/Aoyo.Taiga/appsettings.Development.json b/Aoyo.Taiga/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Aoyo.Taiga/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Aoyo.Taiga/appsettings.json b/Aoyo.Taiga/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Aoyo.Taiga/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..166f393 --- /dev/null +++ b/LICENCE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Dmitri Shimanski + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c941a7 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Aoyo.Taiga + +**Aoyo.Taiga** is a lightweight C# application that listens for webhooks from [Taiga](https://taiga.io/) and sends a notification to a specified Discord channel via a bot. + +## ✨ Features + +- Receives webhook events from Taiga. +- Sends formatted messages to a designated Discord channel. +- Lightweight and easy to run (locally or in a container). +- Supports configuration via `environment variables` or `appsettings.json`. + +## ⚙️ Required Configuration + +The app requires the following configuration keys to function: + +### Environment variables (`ENV`) +- `Discord__Token` – your Discord bot token. +- `Discord__Id` – target Discord channel ID. +- `Taiga__Key` – secret key to validate incoming Taiga webhooks. + +### OR `appsettings.json`: + +```json +{ + "Discord": { + "Token": "your_discord_token", + "Id": "your_channel_id" + }, + "Taiga": { + "Key": "your_taiga_webhook_secret" + } +} +``` +Only one method of configuration is required. You can choose between env vars or appsettings.json. +🐳 Run with Docker + +You can pull and run the prebuilt image from GitHub Container Registry (GHCR): +```bash +docker run -d \ + -e Discord__Token=your_token \ + -e Discord__Id=your_channel_id \ + -e Taiga__Key=your_webhook_key \ + -p 8080:80 \ + ghcr.io/yawaflua/aoyo.taiga:latest +``` + +🧪 Run Locally + +Make sure you have the .NET SDK installed. +1. Clone the repo +```shell +git clone https://github.com/yawaflua/Aoyo.Taiga.git +``` +cd Aoyo.Taiga + +2. Configure credentials + +Either set env variables or fill in appsettings.json. +3. Run the app + +```bash +dotnet run --project Aoyo.Taiga +``` + +By default, it will listen on http://localhost:8080/aoyo. +🚀 Behavior + +Once a webhook is sent from Taiga (with the correct key), the app will verify it and forward a message to the Discord channel defined by the Discord__Id. +📫 Webhook Endpoint + +You can point your Taiga webhook to: + +`POST https://example.mycooldomain.co.il/aoyo/api/v1/TaigaWebHook/post` + +Make sure to include the Taiga__Key as a query parameter or in the header to pass validation. + +Project can be used under [Apache 2.0](LICENCE) +