diff --git a/.github/workflows/pack-and-publish.yml b/.github/workflows/pack-and-publish.yml index c4fda08..1dc0c08 100644 --- a/.github/workflows/pack-and-publish.yml +++ b/.github/workflows/pack-and-publish.yml @@ -6,11 +6,9 @@ on: pull_request: branches: ["main"] -env: - SOLUTION_DIR: src/Hooki - jobs: build-and-test: + environment: Test runs-on: ubuntu-latest steps: @@ -21,13 +19,24 @@ jobs: dotnet-version: 8.0.x - name: Restore dependencies - run: dotnet restore ${{ env.SOLUTION_DIR }} + run: dotnet restore src/Hooki - name: Build - run: dotnet build ${{ env.SOLUTION_DIR }} --configuration Release --no-restore + run: dotnet build src/Hooki --configuration Release --no-restore + + - name: Run Unit Tests + run: dotnet test src/Hooki.UnitTests/Hooki.UnitTests.csproj --no-restore - - name: Test - run: dotnet test ${{ env.SOLUTION_DIR }} --no-restore + - name: Run Integration Tests + env: + DOTNET_ENVIRONMENT: ${{ vars.DOTNET_ENVIRONMENT }} + TEST_DISCORD_WEBHOOK_URL: ${{ secrets.TEST_DISCORD_WEBHOOK_URL }} + TEST_MICROSOFT_TEAMS_WEBHOOK_URL: ${{ secrets.TEST_MICROSOFT_TEAMS_WEBHOOK_URL }} + TEST_SLACK_WEBHOOK_URL: ${{ secrets.TEST_SLACK_WEBHOOK_URL }} + TEST_PIPEDREAM_URL: ${{ secrets.TEST_PIPEDREAM_URL }} + TEST_IMAGE_FILE_NAME: ${{ vars.TEST_IMAGE_FILE_NAME }} + TEST_IMAGE_CLOUD_URL: ${{ secrets.TEST_IMAGE_CLOUD_URL }} + run: dotnet test src/Hooki.IntegrationTests/Hooki.IntegrationTests.csproj --no-restore pack-and-publish: needs: build-and-test @@ -42,10 +51,10 @@ jobs: dotnet-version: 8.0.x - name: Restore dependencies - run: dotnet restore ${{ env.SOLUTION_DIR }} + run: dotnet restore src/Hooki - name: Build - run: dotnet build ${{ env.SOLUTION_DIR }} --configuration Release --no-restore + run: dotnet build src/Hooki --configuration Release --no-restore - name: Set version and trim leading 'v' run: | @@ -54,7 +63,7 @@ jobs: echo "Set VERSION to $version" - name: Pack - run: dotnet pack ${{ env.SOLUTION_DIR }} --configuration Release --no-build -p:PackageVersion=${{env.VERSION}} --output ./nupkgs + run: dotnet pack src/Hooki/Hooki.csproj --configuration Release --no-build -p:PackageVersion=${{env.VERSION}} --output ./nupkgs - name: Publish to NuGet run: dotnet nuget push "./nupkgs/Hooki.*" --source https://api.nuget.org/v3/index.json --api-key ${{secrets.NUGET_API_KEY}} --skip-duplicate \ No newline at end of file diff --git a/README.md b/README.md index f649014..2b0836b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ - [About Hooki](#star2-about-the-project) * [Features](#dart-features) * [Why use Hooki?](#key-why-use-hooki) +- [Trusted By](#office-trusted-by) - [Getting Started](#toolbox-getting-started) * [Prerequisites](#bangbang-prerequisites) - [Usage](#eyes-usage) @@ -89,6 +90,23 @@ Hooki is a powerful .NET library designed to simplify the creation of webhook pa - **Focus on Content:** Concentrate on your payload's data and style rather than low-level JSON structure. - **Flexibility:** Easily extensible for custom webhook requirements while maintaining type safety. +## ๐Ÿข Trusted By + +
+ + + + + + + +
+ + Cloudcat Logo + +
Cloudcat.dev
+
+ ## ๐Ÿงฐ Getting Started @@ -109,6 +127,10 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Hooki.Discord.Enums; +using Hooki.Discord.Models.BuildingBlocks; +using Hooki.Discord.Models; + public class DiscordWebhookService { private readonly IHttpClientFactory _httpClientFactory; @@ -201,10 +223,12 @@ public class ExampleController ## ๐Ÿงญ Roadmap * [x] POCOs -* [ ] Implement Unit Tests +* [x] Implement Unit Tests +* [x] Provide builders utilising fluent api to reduce boilerplate code when creating webhook payloads +* [ ] Support Files and Polls in Discord Webhook +* [ ] Implement type safety POCOs for Discord message components * [ ] Introduce Validation to provide a better developer experience (Apps are not returning error details for 400s) * [ ] Remove the use of objects in numerous places and replace with a clean union type solution for type safety and readability -* [ ] Provide builders utilising fluent api to reduce boilerplate code when creating webhook payloads * [ ] Support other languages? diff --git a/docs/examples/discord-examples.md b/docs/examples/discord-examples.md index 72c431d..b98946e 100644 --- a/docs/examples/discord-examples.md +++ b/docs/examples/discord-examples.md @@ -3,6 +3,10 @@ ![plot](../screenshots/discord-example-screenshot.png) ```csharp +using Hooki.Discord.Enums; +using Hooki.Discord.Models.BuildingBlocks; +using Hooki.Discord.Models; + return new DiscordWebhookPayload { Username = "Alertu Webhook", @@ -35,4 +39,55 @@ return new DiscordWebhookPayload } } }; -``` \ No newline at end of file +``` + +![plot](../screenshots/discord-poll-example-screenshot.png) + +```csharp +using Hooki.Discord.Models; +using Hooki.Discord.Models.BuildingBlocks; + +var pollPayload = new DiscordWebhookPayload +{ + Poll = new PollCreateRequest + { + Question = new PollMedia + { + Text = "What is your favorite TV show?", + }, + Duration = 24, + AllowMultiSelect = true, + Answers = new List + { + new PollAnswer + { + AnswerId = 1, + PollMedia = new PollMedia + { + Text = "Penguin", + Emoji = new Emoji { Name = "๐Ÿง" } + } + }, + new PollAnswer + { + AnswerId = 2, + PollMedia = new PollMedia + { + Text = "Game of Thrones", + Emoji = new Emoji { Name = "โ„๏ธ" } + } + }, + new PollAnswer + { + AnswerId = 3, + PollMedia = new PollMedia + { + Text = "Breaking Bad", + Emoji = new Emoji { Name = "๐Ÿงช" } + } + } + } + } +}; +``` + diff --git a/docs/examples/microsoft-teams-examples.md b/docs/examples/microsoft-teams-examples.md index 980c641..66c75ca 100644 --- a/docs/examples/microsoft-teams-examples.md +++ b/docs/examples/microsoft-teams-examples.md @@ -3,6 +3,10 @@ ![plot](../screenshots/microsoft-teams-example-screenshot.png) ```csharp +using Hooki.MicrosoftTeams.Models; +using Hooki.MicrosoftTeams.Models.BuildingBlocks; +using Hooki.MicrosoftTeams.Models.Actions; + return new MessageCard { ThemeColor = "0x0EA5E9", // Light blue color diff --git a/docs/examples/slack-examples.md b/docs/examples/slack-examples.md index 97c4f5d..1749512 100644 --- a/docs/examples/slack-examples.md +++ b/docs/examples/slack-examples.md @@ -3,6 +3,11 @@ ![plot](../screenshots/slack-example-screenshot.png) ```csharp +using Hooki.Slack.Models; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + return new SlackWebhookPayload { Blocks = new List diff --git a/docs/guides/discord-guide.md b/docs/guides/discord-guide.md index 2100550..0f516b4 100644 --- a/docs/guides/discord-guide.md +++ b/docs/guides/discord-guide.md @@ -1,13 +1,32 @@ -# Guide: Creating Discord Webhook Payloads - -This guide will walk you through using our library to create Discord webhook payloads. The library provides a set of Plain Old CLR Objects (POCOs) that correspond to the Discord webhook payload structure. +# Guide: Creating Discord Webhook Payloads with Hooki + +This guide will walk you through using the Hooki library to create Discord webhook payloads. The library provides a set of Plain Old CLR Objects (POCOs) that correspond to the Discord webhook payload structure. + +## Table of Contents + +1. [Basic Structure](#basic-structure) +2. [Adding Embeds](#adding-embeds) + - [Embed Author](#embed-author) + - [Embed Fields](#embed-fields) + - [Embed Images](#embed-images) +3. [Polls](#polls) +4. [Attachments and Files](#attachments-and-files) + - [Multipart/form-data](#multipartform-data) + - [Attachments](#attachments) + - [Using Files with Attachments](#using-files-with-attachments) +5. [Complete Example](#complete-example) +6. [Markdown Styling in Discord Webhooks](#markdown-styling-in-discord-webhooks) +7. [Best Practices and Limitations](#best-practices-and-limitations) +8. [Additional Resources](#additional-resources) ## Basic Structure The main object you'll be working with is `DiscordWebhookPayload`. Here's a basic example of how to create one: ```csharp -vvar payload = new DiscordWebhookPayload +using Hooki.Discord.Models; + +var payload = new DiscordWebhookPayload { Username = "My Webhook", AvatarUrl = "https://example.com/avatar.png", @@ -17,9 +36,12 @@ vvar payload = new DiscordWebhookPayload ## Adding Embeds -Embeds are rich content blocks that can contain various elements. Embeds will contain most of your webhook payload content. Here's how to add an embed: +Embeds are rich content blocks that can contain various elements. Here's how to add an embed: ```csharp +using Hooki.Discord.Models; +using Hooki.Discord.Models.BuildingBlocks; + var payload = new DiscordWebhookPayload { Username = "My Webhook", @@ -35,7 +57,7 @@ var payload = new DiscordWebhookPayload }; ``` -## Embed Author +### Embed Author You can add author information to an embed: @@ -44,15 +66,15 @@ new Embed { Author = new EmbedAuthor { - Name = "Author Name", - Url = "https://example.com/author", - IconUrl = "https://example.com/author-icon.png" + Name = "Alertu", + Url = "https://alertu.io", + IconUrl = "https://example.com/logo.png" }, // ... other embed properties } ``` -## Embed Fields +### Embed Fields Fields are useful for displaying key-value pairs of information: @@ -68,129 +90,170 @@ new Embed } ``` -## Complete Example +### Embed Images + +Embeds support thumbnails and images. You can provide these images in two ways: -Here's a more comprehensive example that puts it all together: +1. Reference an attachment: ```csharp -var payload = new DiscordWebhookPayload +new Embed { - Username = "Alert Webhook", - AvatarUrl = "https://example.com/alert-avatar.png", - Embeds = new List + Title = "Test Embed Title", + Description = "Test Embed Description", + Thumbnail = new EmbedThumbnail { - new Embed - { - Author = new EmbedAuthor - { - Name = "AlertSystem", - Url = "https://alertsystem.com", - IconUrl = "https://example.com/alert-icon.png" - }, - Title = "New Alert Triggered", - Description = "[**View Alert**](https://alertsystem.com/alert/123) | [**View in Dashboard**](https://alertsystem.com/dashboard)", - Color = 3066993, // Green color in decimal - Fields = new List - { - new EmbedField { Name = "Alert Type", Value = "CPU Usage", Inline = true }, - new EmbedField { Name = "Severity", Value = "High", Inline = true }, - new EmbedField { Name = "Triggered At", Value = DateTime.Now.ToString("f"), Inline = false }, - new EmbedField { Name = "Affected Resources", Value = "web-server-01, web-server-02", Inline = false } - } - } + Url = "attachment://hooki-icon.png" + }, + Image = new EmbedImage + { + Url = "attachment://hooki-icon.png" } -}; +} ``` -## Markdown Styling in Discord Webhooks - -Discord supports a subset of markdown formatting in webhook messages, allowing you to style your text for better readability and emphasis. Here are some common markdown techniques you can use: +2. Provide a direct URL to a public image: -### Text Formatting - -1. **Bold**: Surround text with double asterisks or double underscores - ``` - **bold text** or __bold text__ - ``` - -2. *Italic*: Surround text with single asterisks or single underscores - ``` - *italic text* or _italic text_ - ``` +```csharp +new Embed +{ + Title = "Test Embed Title", + Description = "Test Embed Description", + Thumbnail = new EmbedThumbnail + { + Url = "https://example.com/thumbnail.png" + }, + Image = new EmbedImage + { + Url = "https://example.com/image.png" + } +} +``` -3. ***Bold Italic***: Combine bold and italic - ``` - ***bold italic*** or **_bold italic_** or __*bold italic*__ - ``` +## Polls -4. ~~Strikethrough~~: Surround text with double tildes - ``` - ~~strikethrough~~ - ``` +Polls are a great way to gather feedback from Discord server members automatically. Here's how to create a poll: -5. `Inline code`: Surround text with backticks - ``` - `inline code` - ``` +```csharp +var pollPayload = new DiscordWebhookPayload +{ + Poll = new PollCreateRequest + { + Question = new PollMedia + { + Text = "What is your favorite TV show?", + }, + Duration = 24, + AllowMultiSelect = true, + Answers = new List + { + new PollAnswer + { + AnswerId = 1, + PollMedia = new PollMedia + { + Text = "Penguin", + Emoji = new Emoji { Name = "๐Ÿง" } + } + }, + // ... more answers + } + } +}; +``` -### Code Blocks +**Note:** Emojis cannot be used in Questions. -For multi-line code blocks, use triple backticks: +## Attachments and Files -``` -โ€‹``` -Multi-line -code block -โ€‹``` -``` +### Multipart/form-data -You can also specify the language for syntax highlighting: +When using attachments or files, you need to use multipart/form-data as the content for the webhook request. The `MultipartContent` property on the `DiscordWebhookPayload` class builds this for you: +```csharp +await _httpClient.PostAsync(url, discordPayload.MultipartContent); ``` -โ€‹```python -def hello_world(): - print("Hello, Discord!") -โ€‹``` -``` - -### Links -Create links using square brackets for the text and parentheses for the URL: -``` -[Visit Discord](https://discord.com) -``` +### Attachments -### Lists +To use attachments on their own: -Unordered lists use asterisks, plus signs, or hyphens: -``` -* Item 1 -* Item 2 -* Item 3 +```csharp +var payload = new DiscordWebhookPayload +{ + Content = "This is a test discord webhook payload", + Attachments = new List + { + new Attachment + { + Id = DiscordSnowflakeId, + FileName = TestImageFileName, + ContentType = "image/png", + Height = 128, + Width = 128, + Size = 19251, + Content = GetTestImageBytes() // Implement this method to return your image bytes + } + } +}; ``` -Ordered lists use numbers: -``` -1. First item -2. Second item -3. Third item -``` +### Using Files with Attachments -### Quotes +To use files alongside attachments: -Use the greater-than symbol for quotes: -``` -> This is a quote +```csharp +var payload = new DiscordWebhookPayload +{ + Content = "Test Content", + Embeds = new List + { + new Embed + { + Title = "Test Embed Title", + Description = "Test Embed Description", + Thumbnail = new EmbedThumbnail + { + Url = "attachment://hooki-icon.png" + }, + Image = new EmbedImage + { + Url = "attachment://hooki-icon.png" + } + } + }, + Attachments = new List + { + new Attachment + { + Id = DiscordSnowflakeId, + Description = "Hooki Logo", + FileName = "hooki-icon.png", + Height = 128, + Width = 128 + } + }, + Files = new List + { + new FileContent + { + SnowflakeId = "123456789", + FileName = "hooki-icon.png", + ContentType = "image/png", + FileContents = GetImageBytes() // Implement this method to return the image bytes + } + } +}; ``` -### Example in Code +## Complete Example -Here's how you might use markdown styling in your webhook payload: +Here's a comprehensive example that puts it all together: ```csharp var payload = new DiscordWebhookPayload { - Username = "Alertu Webhook", + Username = "Alert Webhook", AvatarUrl = "https://example.com/alert-avatar.png", Embeds = new List { @@ -198,46 +261,59 @@ var payload = new DiscordWebhookPayload { Author = new EmbedAuthor { - Name = "Alertu-system", + Name = "AlertSystem", Url = "https://alertsystem.com", IconUrl = "https://example.com/alert-icon.png" }, - Title = "**New Alert Triggered**", - Description = $"[**View in AlertSystem**](https://alertsystem.com) | [**View in Azure**](https://portal.azure.com)", - Color = 15158332, // Red color in decimal + Title = "New Alert Triggered", + Description = "[**View Alert**](https://alertsystem.com/alert/123) | [**View in Dashboard**](https://alertsystem.com/dashboard)", + Color = 3066993, // Green color in decimal Fields = new List { - new EmbedField { Name = "**Summary**", Value = $"```Test Summary```", Inline = false }, - new EmbedField { Name = "Organization", Value = $"*Test Organization*", Inline = true }, - new EmbedField { Name = "Project", Value = $"*Test Project*", Inline = true }, - new EmbedField { Name = "Cloud Provider", Value = $"`Azure`", Inline = true }, - new EmbedField { Name = "Resources", Value = $"โ€ข test-redis\nโ€ข test-postgreSQL", Inline = false }, - new EmbedField { Name = "Severity", Value = $"**Critical**", Inline = true }, - new EmbedField { Name = "Status", Value = $"**Open**", Inline = true }, - new EmbedField { Name = "Triggered At", Value = $"_{DateTimeOffset.UtcNow.ToString("f")}_", Inline = true }, - new EmbedField { Name = "Resolved At", Value = $"_{DateTimeOffset.UtcNow.ToString("f")}_", Inline = true } + new EmbedField { Name = "Alert Type", Value = "CPU Usage", Inline = true }, + new EmbedField { Name = "Severity", Value = "High", Inline = true }, + new EmbedField { Name = "Triggered At", Value = DateTime.Now.ToString("f"), Inline = false }, + new EmbedField { Name = "Affected Resources", Value = "web-server-01, web-server-02", Inline = false } } } } }; ``` -Remember that while markdown can enhance readability, it's important to use it judiciously to maintain a clean and professional appearance in your webhook messages. - -## Notes +## Markdown Styling in Discord Webhooks -1. Ensure that you provide a value for at least one of: `Content`, `Embeds`, or `File` in the `DiscordWebhookPayload`. +Discord supports a subset of markdown formatting in webhook messages. Here are some common markdown techniques: + +- **Bold**: `**bold text**` or `__bold text__` +- *Italic*: `*italic text*` or `_italic text_` +- ***Bold Italic***: `***bold italic***` +- ~~Strikethrough~~: `~~strikethrough~~` +- `Inline code`: `` `inline code` `` +- Code blocks: + ``` + ```language + code here + ``` + ``` +- [Links](https://example.com): `[Link text](https://example.com)` +- Lists: + ``` + * Unordered item + 1. Ordered item + ``` +- Quotes: `> This is a quote` + +## Best Practices and Limitations + +1. Provide a value for at least one of: `Content`, `Embeds`, or `File` in the `DiscordWebhookPayload`. 2. The `Color` property in `Embed` should be a decimal representation of a hexadecimal color code. 3. You can add up to 10 embeds per message. 4. The total size of all embeds in a message must not exceed 6000 characters. -5. Remember to respect Discord's rate limits when sending webhooks. +5. Respect Discord's rate limits when sending webhooks. +6. File upload limit applies to all files in a request. The default limit is 25 MiB, but it can vary based on the guild's boost tier. -For more details on Discord's webhook structure and limitations, refer to the [official Discord documentation](https://discord.com/developers/docs/resources/webhook#execute-webhook). - -## Links - -1. [Good example of a Discord webhook](https://birdie0.github.io/discord-webhooks-guide/discord_webhook.html) +## Additional Resources +1. [Example of a Discord webhook](https://birdie0.github.io/discord-webhooks-guide/discord_webhook.html) 2. [Discord webhook payload documentation](https://discord.com/developers/docs/resources/webhook#execute-webhook) - -3. [Discord embed object documentation](https://discord.com/developers/docs/resources/message#embed-object) \ No newline at end of file +3. [Discord API Rate Limits](https://discord.com/developers/docs/topics/rate-limits) \ No newline at end of file diff --git a/docs/guides/microsoft-teams-guide.md b/docs/guides/microsoft-teams-guide.md index 295c826..c653459 100644 --- a/docs/guides/microsoft-teams-guide.md +++ b/docs/guides/microsoft-teams-guide.md @@ -1,12 +1,28 @@ -# Guide: Creating Microsoft Teams Message Card Payloads +# Guide: Creating Microsoft Teams Message Card Payloads with Hooki -This guide will walk you through using the provided POCOs (Plain Old CLR Objects) to create a message card payload for Microsoft Teams. The message card allows you to send rich, interactive messages to Teams channels or personal chats. +This guide will walk you through using the Hooki library to create message card payloads for Microsoft Teams. Message cards allow you to send rich, interactive messages to Teams channels or personal chats. + +## Table of Contents + +1. [Basic Structure](#basic-structure) +2. [Adding Sections](#adding-sections) +3. [Adding Facts](#adding-facts) +4. [Adding Actions](#adding-actions) + - [OpenUri Action](#openuri-action) + - [HttpPost Action](#httppost-action) + - [ActionCard Action](#actioncard-action) +5. [Complete Example](#complete-example) +6. [Markdown Styling in Teams Message Cards](#markdown-styling-in-teams-message-cards) +7. [Best Practices and Limitations](#best-practices-and-limitations) +8. [Additional Resources](#additional-resources) ## Basic Structure The main object you'll be working with is `MessageCard`. Here's a basic example of how to create one: ```csharp +using Hooki.MicrosoftTeams.Models; + var messageCard = new MessageCard { Type = "MessageCard", @@ -20,15 +36,11 @@ var messageCard = new MessageCard ## Adding Sections -Sections allow you to group related information. - -If your card represents a single "entity", you may be able to get away with not using any section. That said, sections support the concept of an "activity" which is often a good way to represent data in a card. - -If your card represents multiple "entities" or is, for instance, a digest for a particular news source, you will definitely want to use multiple sections, one per "entity." - -**Note: Don't include more than 10 sections** +Sections allow you to group related information. Here's how to add a section: ```csharp +using Hooki.MicrosoftTeams.BuildingBlocks; + var section = new Section { ActivityTitle = "**Section Title**", @@ -40,16 +52,22 @@ var section = new Section messageCard.Sections = new List
{ section }; ``` +**Note:** Don't include more than 10 sections in a single card. + ## Adding Facts Facts are key-value pairs that can be added to a section: ```csharp -section.Facts = new List +using Hooki.MicrosoftTeams.BuildingBlocks; + +var facts = new List { new Fact { Name = "Project:", Value = "Project Phoenix" }, new Fact { Name = "Status:", Value = "In Progress" } }; + +section.Facts = facts; ``` ## Adding Actions @@ -59,6 +77,9 @@ Actions allow users to interact with your card. There are different types of act ### OpenUri Action ```csharp +using Hooki.MicrosoftTeams.Actions; +using Hooki.MicrosoftTeams.BuildingBlocks; + var openUriAction = new OpenUriAction { Name = "View Details", @@ -74,6 +95,8 @@ messageCard.PotentialActions = new List { openUriAction }; ### HttpPost Action ```csharp +using Hooki.MicrosoftTeams.Actions; + var httpPostAction = new HttpPostAction { Name = "Approve", @@ -89,6 +112,9 @@ messageCard.PotentialActions.Add(httpPostAction); ActionCard allows you to create a set of inputs that users can fill out: ```csharp +using Hooki.MicrosoftTeams.Actions; +using Hooki.MicrosoftTeams.Inputs; + var actionCard = new ActionCardAction { Name = "Add Comment", @@ -116,9 +142,13 @@ messageCard.PotentialActions.Add(actionCard); ## Complete Example -Here's a more comprehensive example that puts it all together: +Here's a comprehensive example that puts it all together: ```csharp +using Hooki.MicrosoftTeams.Models; +using Hooki.MicrosoftTeams.BuildingBlocks; +using Hooki.MicrosoftTeams.Actions; + var messageCard = new MessageCard { ThemeColor = "0x0EA5E9", // Light blue color @@ -130,7 +160,7 @@ var messageCard = new MessageCard ActivityTitle = "**Azure Metric Alert triggered**", ActivitySubtitle = "**Severity - Critical | Status - Open**", ActivityText = "This is a test summary for the Azure Metric Alert", - ActivityImage = "https://example-url/image.png", + ActivityImage = "https://example.com/alert-image.png", Facts = new List { new Fact { Name = "Organization Name:", Value = "Test Organization" }, @@ -167,9 +197,9 @@ var messageCard = new MessageCard }; ``` -## Markdown Styling +## Markdown Styling in Teams Message Cards -Microsoft Teams supports a subset of Markdown in message cards. Here's a table of common formatting options: +Microsoft Teams supports a subset of Markdown in message cards. Here are some common formatting options: | Effect | Markdown | Example | |--------|----------|---------| @@ -181,8 +211,6 @@ Microsoft Teams supports a subset of Markdown in message cards. Here's a table o | Headings | `# Heading` through `###### Heading` | Varies from `

` to `

` | | Bulleted lists | `* List item` or `- List item` | โ€ข List item | -You can use these Markdown elements in various text fields of your message card, such as `ActivityTitle`, `ActivitySubtitle`, `ActivityText`, and in the `Value` field of `Fact` objects. - Example usage in a message card: ```csharp @@ -202,15 +230,18 @@ var section = new Section }; ``` -Remember that while Markdown can enhance readability, it's important to use it judiciously to maintain a clean and professional appearance in your message cards. +## Best Practices and Limitations -## Notes - -1. The `ThemeColor` property accepts a hexadecimal color value. Here is an example "0ea4e9". -2. The `Summary` field is used when the card is displayed in a notification or in a condensed view. -3. Images used in the card (like `ActivityImage`) should be accessible from the internet. -4. When using `OpenUriAction`, you can specify different URIs for different operating systems if needed. -5. The `HttpPostAction` can be used to send data back to your server when a user interacts with the card. +1. The `ThemeColor` property accepts a hexadecimal color value (e.g., "0ea4e9"). +2. Use the `Summary` field for notifications or condensed views of the card. +3. Ensure images used in the card (like `ActivityImage`) are accessible from the internet. +4. With `OpenUriAction`, you can specify different URIs for different operating systems if needed. +5. Use `HttpPostAction` to send data back to your server when a user interacts with the card. 6. Remember to handle potential actions server-side if you're using interactive elements. +7. Limit the number of sections to 10 or fewer per card for better readability. +8. Use Markdown formatting judiciously to maintain a clean and professional appearance. + +## Additional Resources -For more details on Microsoft Teams message card structure and capabilities, refer to the [official Microsoft documentation](https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference). \ No newline at end of file +1. [Microsoft Teams Message Card Reference](https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference) +2. [Microsoft Teams Developer Platform](https://learn.microsoft.com/en-us/microsoftteams/platform/) \ No newline at end of file diff --git a/docs/screenshots/discord-poll-example-screenshot.png b/docs/screenshots/discord-poll-example-screenshot.png new file mode 100644 index 0000000..9b41dbf Binary files /dev/null and b/docs/screenshots/discord-poll-example-screenshot.png differ diff --git a/src/Hooki.IntegrationTests/Config/HttpClientFixture.cs b/src/Hooki.IntegrationTests/Config/HttpClientFixture.cs new file mode 100644 index 0000000..924a937 --- /dev/null +++ b/src/Hooki.IntegrationTests/Config/HttpClientFixture.cs @@ -0,0 +1,60 @@ +using System.Net; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Polly; +using Polly.Extensions.Http; + +namespace IntegrationTests.Config; + +public class HttpClientFixture : IDisposable +{ + public HttpClient Client { get; } + public IConfiguration Configuration { get; } + + public HttpClientFixture() + { + var services = new ServiceCollection(); + + var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Development"; + + if (environment is "Development") + { + DotNetEnv.Env.TraversePath().Load(); + } + + Configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .AddEnvironmentVariables() + .Build(); + + services.AddHttpClient("ResilientClient") + .AddPolicyHandler(GetRetryPolicy()) + .AddPolicyHandler(GetCircuitBreakerPolicy()); + + var serviceProvider = services.BuildServiceProvider(); + + var httpClientFactory = serviceProvider.GetRequiredService(); + Client = httpClientFactory.CreateClient("ResilientClient"); + } + + private static IAsyncPolicy GetRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } + + private static IAsyncPolicy GetCircuitBreakerPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)); + } + + public void Dispose() + { + Client.Dispose(); + } +} \ No newline at end of file diff --git a/src/Hooki.IntegrationTests/Config/IntegrationTestBase.cs b/src/Hooki.IntegrationTests/Config/IntegrationTestBase.cs new file mode 100644 index 0000000..2da0214 --- /dev/null +++ b/src/Hooki.IntegrationTests/Config/IntegrationTestBase.cs @@ -0,0 +1,91 @@ +using System.Text; +using Hooki.Discord.Models; +using Hooki.Utilities; +using IntegrationTests.Enums; + +namespace IntegrationTests.Config; + +public abstract class IntegrationTestBase : IClassFixture +{ + private readonly HttpClient _httpClient; + + private readonly string _discordWebhookUrl; + private readonly string _microsoftTeamsWebhookUrl; + private readonly string _slackWebhookUrl; + + protected const string DiscordSnowflakeId = "1282373368523395145"; // General text channel ID in Alertu discord server + protected readonly string TestImageFileName; + protected readonly string TestImageCloudUrl; + protected readonly string PipedreamUrl; + + protected IntegrationTestBase(HttpClientFixture fixture) + { + _httpClient = fixture.Client; + var configuration = fixture.Configuration; + + TestImageFileName = configuration["TEST_IMAGE_FILE_NAME"] + ?? configuration["TestImageFileName"] + ?? throw new InvalidOperationException("Missing TestImageFileName in environment variables"); + + TestImageCloudUrl = configuration["TEST_IMAGE_CLOUD_URL"] + ?? configuration["TestImageCloudUrl"] + ?? throw new InvalidOperationException("Missing TestImageCloudUrl in environment variables"); + + PipedreamUrl = configuration["TEST_PIPEDREAM_URL"] + ?? configuration["PipedreamUrl"] + ?? throw new InvalidOperationException("Missing PipedreamUrl in environment variables"); + + _discordWebhookUrl = configuration["TEST_DISCORD_WEBHOOK_URL"] + ?? configuration["WebhookUrls:Discord"] + ?? throw new InvalidOperationException("Missing Webhook URL for Discord in environment variables."); + + _microsoftTeamsWebhookUrl = configuration["TEST_MICROSOFT_TEAMS_WEBHOOK_URL"] + ?? configuration["WebhookUrls:MicrosoftTeams"] + ?? throw new InvalidOperationException("Missing Webhook URL for Discord in environment variables."); + + _slackWebhookUrl = configuration["TEST_SLACK_WEBHOOK_URL"] + ?? configuration["WebhookUrls:Slack"] + ?? throw new InvalidOperationException("Missing Webhook URL for Discord in environment variables."); + } + + private string GetWebhookUrl(PlatformTypes platform) + { + return platform switch + { + PlatformTypes.Discord => _discordWebhookUrl, + PlatformTypes.MicrosoftTeams => _microsoftTeamsWebhookUrl, + PlatformTypes.Slack => _slackWebhookUrl, + _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, null) + }; + } + + protected async Task SendWebhookPayloadAsync(PlatformTypes platform, object payload) + { + var url = GetWebhookUrl(platform); + + var json = JsonHelper.Serialize(payload); + + var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await _httpClient.PostAsync(url, content); + } + + protected async Task SendWebhookPayloadWithMultipartFormDataContentAsync(PlatformTypes platform, object payload, MultipartFormDataContent? content) + { + var url = GetWebhookUrl(platform); + + // If we received content, send a POST request using it + if (content is not null) return await _httpClient.PostAsync(url, content); + + // If we didn't receive content and the platform is Discord then need to create the MultipartContent + if (platform is PlatformTypes.Discord && payload is DiscordWebhookPayload discordPayload) + { + return await _httpClient.PostAsync(url, discordPayload.MultipartContent); + } + + // If we didn't receive content and the platform is not Discord then generate the POST body with the standard implementation + var json = JsonHelper.Serialize(payload); + var payloadContent = new StringContent(json, Encoding.UTF8, "application/json"); + + return await _httpClient.PostAsync(url, payloadContent); + } +} \ No newline at end of file diff --git a/src/Hooki.IntegrationTests/DiscordTests.cs b/src/Hooki.IntegrationTests/DiscordTests.cs new file mode 100644 index 0000000..d5314d8 --- /dev/null +++ b/src/Hooki.IntegrationTests/DiscordTests.cs @@ -0,0 +1,209 @@ +using System.Net; +using Hooki.Discord.Builders; +using IntegrationTests.Config; +using Hooki.Discord.Models.BuildingBlocks; +using IntegrationTests.Enums; +using IntegrationTests.Extensions; + +namespace IntegrationTests; + +public class DiscordTests(HttpClientFixture fixture) : IntegrationTestBase(fixture) +{ + [Fact] + public async Task When_Sending_A_Simple_Discord_Webhook_Payload_Then_Return_204() + { + // Arrange + var payload = new DiscordWebhookPayloadBuilder() + .WithUsername("Alertu Webhook") + .WithAvatarUrl(TestImageCloudUrl) + .WithContent("This is a test discord webhook payload") + .AddEmbed(embed => embed + .WithAuthor("Alertu", "https://alertu.io", TestImageCloudUrl) + .WithTitle("Azure Metric Alert triggered") + .WithDescription("[**View in Alertu**](https://alertu.io) | [**View in Azure**](https://portal.azure.com)") + .WithColor(959721) + .AddField("Summary", "Test Summary", false) + .AddField("Organization Name", "Test Organization", true) + .AddField("Project Name", "Test Project", true) + .AddField("Cloud Provider", "Azure", true) + .AddField("Resources", string.Join(", ", "test-redis", "test-postgreSQL"), true) + .AddField("Severity", "Critical", true) + .AddField("Status", "Open", true) + .AddField("Triggered At", DateTimeOffset.UtcNow.ToString("f"), true) + .AddField("Resolved At", DateTimeOffset.UtcNow.AddMinutes(5).ToString("f"), true)) + .Build(); + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Discord, payload); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task When_Sending_Discord_Webhook_Payload_With_Only_An_Embed_Then_Return_204() + { + // Arrange + var payload = new DiscordWebhookPayloadBuilder() + .WithContent("This is a test discord webhook payload") + .AddEmbed(embed => embed + .WithAuthor("Alertu", "https://alertu.io", TestImageCloudUrl) + .WithTitle("Azure Metric Alert triggered") + .WithDescription("[**View in Alertu**](https://alertu.io) | [**View in Azure**](https://portal.azure.com)") + .WithColor(959721) + .AddField("Summary", "Test Summary", false) + .AddField("Organization Name", "Test Organization", true) + .AddField("Project Name", "Test Project", true) + .AddField("Cloud Provider", "Azure", true) + .AddField("Resources", string.Join(", ", "test-redis", "test-postgreSQL"), true) + .AddField("Severity", "Critical", true) + .AddField("Status", "Open", true) + .AddField("Triggered At", DateTimeOffset.UtcNow.ToString("f"), true) + .AddField("Resolved At", DateTimeOffset.UtcNow.AddMinutes(5).ToString("f"), true)) + .Build(); + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Discord, payload); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task When_Sending_Minimal_Discord_Webhook_Payload_With_Content_Then_Return_204() + { + // Arrange + var payload = new DiscordWebhookPayloadBuilder() + .WithContent("This is a test discord webhook payload") + .Build(); + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Discord, payload); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task When_Sending_Minimal_Discord_Webhook_Payload_With_An_Attachment_Then_Return_200() + { + // Arrange + var payload = new DiscordWebhookPayloadBuilder() + .AddAttachment(new Attachment + { + Id = DiscordSnowflakeId, + FileName = TestImageFileName, + ContentType = "image/png", + Height = 128, + Width = 128, + Size = 19251, + Content = ResourceReaderExtensions.GetEmbeddedResourceBytes("IntegrationTests.hooki-icon.png") + }) + .WithContent("This is a test discord webhook payload") + .Build(); + + // Act + var response = await SendWebhookPayloadWithMultipartFormDataContentAsync(PlatformTypes.Discord, payload, payload.MultipartContent!); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_Minimal_Discord_Webhook_Payload_With_Files_And_An_Attachment_Then_Return_200() + { + // Arrange + var payload = new DiscordWebhookPayloadBuilder() + .AddEmbed(embed => embed + .WithTitle("Test Embed Title") + .WithDescription("Test Embed Description") + .WithThumbnail($"attachment://{TestImageFileName}") + .WithImage($"attachment://{TestImageFileName}") + ) + .WithContent("Test Content") + .AddAttachment(new Attachment + { + Id = DiscordSnowflakeId, + Description = "Hooki Logo", + FileName = "hooki-icon.png", + Height = 128, + Width = 128 + }) + .AddFile(new FileContent + { + SnowflakeId = DiscordSnowflakeId, + FileName = TestImageFileName, + ContentType = "image/png", + FileContents = ResourceReaderExtensions.GetEmbeddedResourceBytes($"IntegrationTests.{TestImageFileName}") + }) + .Build(); + + // Act + var response = await SendWebhookPayloadWithMultipartFormDataContentAsync(PlatformTypes.Discord, payload, payload.MultipartContent!); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData(24, false)] + [InlineData(48, true)] + public async Task When_Sending_Minimal_Discord_Webhook_Payload_With_Poll_With_1_Answer_Then_Return_204(int duration, bool isMultiSelect) + { + // Arrange + var payload = new DiscordWebhookPayloadBuilder() + .WithPoll(poll => + poll.WithQuestion(pollmedia => + pollmedia.WithText("Penguins")) + .WithDuration(duration) + .AllowMultiSelect(isMultiSelect) + .AddAnswer(pollAnswer => + pollAnswer.WithPollMedia(pollMedia => + pollMedia.WithText("Penguins emojis are the best!") + .WithEmoji(emoji => emoji.WithName("๐Ÿง"))) + .WithAnswerId(1))) + .Build(); + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Discord, payload); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Theory] + [InlineData(24, false)] + [InlineData(48, true)] + public async Task When_Sending_Minimal_Discord_Webhook_Payload_With_Poll_With_Multiple_Answers_Then_Return_204(int duration, bool isMultiSelect) + { + // Arrange + var payload = new DiscordWebhookPayloadBuilder() + .WithPoll(poll => + poll.WithQuestion(pollmedia => + pollmedia.WithText("What is your favorite TV show?")) + .WithDuration(duration) + .AllowMultiSelect(isMultiSelect) + .AddAnswer(pollAnswer => + pollAnswer.WithPollMedia(pollMedia => + pollMedia.WithText("Penguin") + .WithEmoji(emoji => emoji.WithName("๐Ÿง"))) + .WithAnswerId(1)).AddAnswer(pollAnswer => + pollAnswer.WithPollMedia(pollMedia => + pollMedia.WithText("Game of Thrones") + .WithEmoji(emoji => emoji.WithName("โ„๏ธ"))) + .WithAnswerId(1)) + .AddAnswer(pollAnswer => + pollAnswer.WithPollMedia(pollMedia => + pollMedia.WithText("Breaking bad") + .WithEmoji(emoji => emoji.WithName("๐Ÿงช"))) + .WithAnswerId(1))) + .Build(); + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Discord, payload); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } +} + diff --git a/src/Hooki.IntegrationTests/Enums/PlatformTypes.cs b/src/Hooki.IntegrationTests/Enums/PlatformTypes.cs new file mode 100644 index 0000000..29fb06b --- /dev/null +++ b/src/Hooki.IntegrationTests/Enums/PlatformTypes.cs @@ -0,0 +1,8 @@ +namespace IntegrationTests.Enums; + +public enum PlatformTypes +{ + Discord, + MicrosoftTeams, + Slack +} \ No newline at end of file diff --git a/src/Hooki.IntegrationTests/Extensions/ResourceReaderExtensions.cs b/src/Hooki.IntegrationTests/Extensions/ResourceReaderExtensions.cs new file mode 100644 index 0000000..8288041 --- /dev/null +++ b/src/Hooki.IntegrationTests/Extensions/ResourceReaderExtensions.cs @@ -0,0 +1,29 @@ +using System.Reflection; + +namespace IntegrationTests.Extensions; + +public static class ResourceReaderExtensions +{ + public static byte[] GetEmbeddedResourceBytes(string resourceName) + { + var hookiAssembly = Assembly.GetExecutingAssembly(); + var temp = hookiAssembly.GetManifestResourceNames(); + var fullResourceName = hookiAssembly.GetManifestResourceNames() + .FirstOrDefault(name => name.EndsWith(resourceName)); + + if (fullResourceName == null) + { + throw new FileNotFoundException($"Resource not found: {resourceName}"); + } + + using var stream = hookiAssembly.GetManifestResourceStream(fullResourceName); + if (stream == null) + { + throw new InvalidOperationException($"Unable to load resource: {resourceName}"); + } + + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + return memoryStream.ToArray(); + } +} \ No newline at end of file diff --git a/src/Hooki.IntegrationTests/Hooki.IntegrationTests.csproj b/src/Hooki.IntegrationTests/Hooki.IntegrationTests.csproj new file mode 100644 index 0000000..094f798 --- /dev/null +++ b/src/Hooki.IntegrationTests/Hooki.IntegrationTests.csproj @@ -0,0 +1,40 @@ + + + + net8.0 + enable + enable + + false + true + IntegrationTests + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + diff --git a/src/Hooki.IntegrationTests/MicrosoftTeamsTests.cs b/src/Hooki.IntegrationTests/MicrosoftTeamsTests.cs new file mode 100644 index 0000000..d9fa25e --- /dev/null +++ b/src/Hooki.IntegrationTests/MicrosoftTeamsTests.cs @@ -0,0 +1,266 @@ +using IntegrationTests.Config; +using IntegrationTests.Enums; +using System.Net; +using Hooki.MicrosoftTeams.Builders; +using Hooki.MicrosoftTeams.Enums; +using Hooki.MicrosoftTeams.Models.Actions; +using Hooki.MicrosoftTeams.Models.BuildingBlocks; +using Hooki.MicrosoftTeams.Models.Inputs; + +namespace IntegrationTests; + +public class MicrosoftTeamsTests(HttpClientFixture fixture) : IntegrationTestBase(fixture) +{ + [Fact] + public async Task When_Sending_A_Valid_MicrosoftTeams_MessageCard_Webhook_Then_Return_200() + { + // Arrange + var messageCard = new MessageCardBuilder() + .WithThemeColor("0ea4e9") + .WithSummary("Test Summary") + .AddSection(section => section + .WithActivityTitle("**Azure Metric Alert triggered**") + .WithActivitySubtitle($"**Severity - Critical | Status - Open**") + .WithActivityText("Testing Webhooks") + .WithActivityImage(TestImageCloudUrl) + .AddFact(fact => fact + .WithName("Organization Name:") + .WithValue("Test Organization")) + .AddFact(fact => fact + .WithName("Project Name:") + .WithValue("Test Project")) + .AddFact(fact => fact + .WithName("Alert Group Name:") + .WithValue("Alert Group Name")) + .AddFact(fact => fact + .WithName("Cloud Provider:") + .WithValue("Azure")) + .AddFact(fact => fact + .WithName("Severity:") + .WithValue("Critical")) + .AddFact(fact => fact + .WithName("Status:") + .WithValue("Open")) + .AddFact(fact => fact + .WithName("Affected Resources:") + .WithValue(string.Join(", ", "test-redis", "test-postgreSQL"))) + .AddFact(fact => fact + .WithName("Triggered At:") + .WithValue(DateTimeOffset.UtcNow.ToString("f"))) + .AddFact(fact => fact + .WithName("Resolved At:") + .WithValue(DateTimeOffset.UtcNow.AddMinutes(5).ToString("f"))) + ) + .AddOpenUriAction("View in Alertu", "https://alertu.io") + .AddOpenUriAction($"View in Azure", "https://portal.azure.com") + .Build(); + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_A_Valid_MessageCard_With_All_Action_Types_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithThemeColor("0ea4e9") + .WithSummary("Test All Action Types") + .AddSection(section => section + .WithActivityTitle("Testing All Action Types") + ) + .AddOpenUriAction("Open URI", "https://example.com") + .AddHttpPostAction("HTTP POST", "https://example.com/api", "{\"key\":\"value\"}", "application/json", new List
()) + .AddActionCardAction("Action Card", [], []) + .AddInvokeAddInCommandAction("Invoke Add-In", "add-in-id", "command-id", null) + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_A_Valid_Minimal_MessageCard_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithText("Test All Action Types") + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_MessageCard_With_Multiple_Sections_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithTitle("Multi-Section Card") + .WithText("This card has multiple sections") + .AddSection(section => section + .WithTitle("Section 1") + .WithText("Content of section 1") + .AddFact(fact => fact.WithName("Fact 1").WithValue("Value 1")) + ) + .AddSection(section => section + .WithTitle("Section 2") + .WithText("Content of section 2") + .AddImage(image => image.WithImageUrl(TestImageCloudUrl).WithTitle("Sample Image")) + ) + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_MessageCard_With_HeroImage_And_ActivityDetails_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithTitle("Card with Hero Image and Activity") + .WithSummary("Testing HeroImage and Activity") + .AddSection(section => section + .WithHeroImage(image => image + .WithImageUrl(TestImageCloudUrl) + .WithTitle("Hero Image")) + .WithActivityTitle("Important Activity") + .WithActivitySubtitle("Subtitle for the activity") + .WithActivityText("Detailed description of the activity") + .WithActivityImage(TestImageCloudUrl) + ) + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_MessageCard_With_ActionCard_With_A_TextInput_And_DateInput_And_HttpPostAction_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithTitle("Card with Complex Action Card") + .WithText("This card demonstrates a complex Action Card") + .AddActionCardAction("Fill Form", new List + { + new TextInput + { + Id = "comment", + Title = "Input's title property", + IsMultiline = true + }, + new DateInput + { + Id = "date", + Title = "Select Date" + } + }, + new List + { + new HttpPostAction + { + Name = "Submit", + Target = PipedreamUrl, + Body = "comment={{comment.value}}&date={{date.value}}" + } + }) + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_MessageCard_With_ActionCard_With_A_MultiChoiceInput_And_HttpPostAction_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithTitle("Card with Complex Action Card") + .WithText("This card demonstrates a complex Action Card") + .AddActionCardAction("Fill Form", new List + { + new MultiChoiceInput + { + Id = "choice", + Title = "Select Options", + IsMultiSelect = true, + Choices = + [ + new Choice { Display = "Option 1", Value = "1" }, + new Choice { Display = "Option 2", Value = "2" }, + new Choice { Display = "Option 3", Value = "3" } + ] + } + }, + [ + new HttpPostAction + { + Name = "Submit", + Target = PipedreamUrl, + Body = "choice={{choice.value}}" + } + ]) + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_MessageCard_With_ActionCard_With_An_OpenUriAction_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithTitle("Card with Complex Action Card") + .WithText("This card demonstrates a complex Action Card") + .AddActionCardAction("Fill Form", null, + [ + new OpenUriAction + { + Name = "Open URI", + Targets = [new Target { OperatingSystem = OperatingSystemType.Default, Uri = "https://example.com" }] + } + ]) + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_MessageCard_With_Facts_And_StartGroup_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithTitle("Card with Various Facts and Start Group") + .WithSummary("Employee Information....") + .AddSection(section => section + .WithTitle("Employee Information") + .WithStartGroup(true) + .AddFact(fact => fact.WithName("Name").WithValue("John Doe")) + .AddFact(fact => fact.WithName("Employee ID").WithValue("EMP001")) + .AddFact(fact => fact.WithName("Department").WithValue("IT")) + .AddFact(fact => fact.WithName("Join Date").WithValue(DateTime.Now.AddYears(-2).ToString("d"))) + .AddFact(fact => fact.WithName("Performance").WithValue("Excellent")) + ) + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task When_Sending_MessageCard_With_Multiple_Images_And_Actions_Then_Return_200() + { + var messageCard = new MessageCardBuilder() + .WithTitle("Product Showcase") + .WithText("Check out our latest products!") + .AddSection(section => section + .AddImage(image => image.WithImageUrl(TestImageCloudUrl).WithTitle("Product 1")) + .AddImage(image => image.WithImageUrl(TestImageCloudUrl).WithTitle("Product 2")) + .AddImage(image => image.WithImageUrl(TestImageCloudUrl).WithTitle("Product 3"))) + .AddOpenUriAction("View Catalog", "https://example.com/catalog") + .AddHttpPostAction("Request Info", "https://example.com/api/info", "{\"request\":\"info\"}", "application/json", new List
()) + .Build(); + + var response = await SendWebhookPayloadAsync(PlatformTypes.MicrosoftTeams, messageCard); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} \ No newline at end of file diff --git a/src/Hooki.IntegrationTests/SlackTests.cs b/src/Hooki.IntegrationTests/SlackTests.cs new file mode 100644 index 0000000..777cb05 --- /dev/null +++ b/src/Hooki.IntegrationTests/SlackTests.cs @@ -0,0 +1,367 @@ +using System.Net; +using Hooki.Slack.Enums; +using Hooki.Slack.Models; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; +using IntegrationTests.Config; +using IntegrationTests.Enums; + +namespace IntegrationTests; + +public class SlackTests : IntegrationTestBase +{ + public SlackTests(HttpClientFixture fixture) : base(fixture) { } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_Context_Block_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new ContextBlock + { + Elements = new List + { + new ImageElement { ImageUrl = TestImageCloudUrl, AltText = "Image" }, + new TextObject + { Type = TextObjectType.PlainText, Text = "This is text for a context block element" } + } + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_Action_Block_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new ActionBlock + { + Elements = new List + { + new ButtonElement { Text = new TextObject{Type = TextObjectType.PlainText ,Text = "Button Text"}}, + new CheckboxElement { Options = + [ + new OptionObject + { + Text = new TextObject { Type = TextObjectType.PlainText, Text = "Choice Text" }, + Value = "Choice Value" + } + ] + } + } + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_Header_Block_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new HeaderBlock + { + Text = new TextObject + { + Type = TextObjectType.PlainText, + Text = "Header Text" + } + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_Image_Block_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new ImageBlock + { + AltText = "**Test image alt text**", + ImageUrl = TestImageCloudUrl, + Title = new TextObject + { + Type = TextObjectType.PlainText, + Text = "Test Image Title" + } + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_Input_Block_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new InputBlock + { + Label = new TextObject + { + Type = TextObjectType.PlainText, + Text = "Email" + }, + Element = new EmailInputElement + { + Placeholder = new TextObject + { + Type = TextObjectType.PlainText, + Text = "My Email Address" + } + } + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_RichText_Block_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new SectionBlock + { + Fields = + [ + new TextObject { Type = TextObjectType.Markdown, Text = "*Organization Name:*\n Test Organization" }, + new TextObject { Type = TextObjectType.Markdown, Text = "*Project Name:*\n Test Project" }, + new TextObject { Type = TextObjectType.Markdown, Text = "*Cloud Provider:*\n Test Cloud Provider" }, + new TextObject { Type = TextObjectType.Markdown, Text = "*Resources:*\n test-redis, test-postgreSQL" } + ] + }, + new SectionBlock + { + Fields = + [ + new TextObject { Type = TextObjectType.Markdown, Text = "*Severity:*\n Critical" }, + new TextObject { Type = TextObjectType.Markdown, Text = "*Status:*\n Closed" }, + new TextObject { Type = TextObjectType.Markdown, Text = $"*Triggered At:*\n{DateTimeOffset.UtcNow.ToString()}" } + ] + }, + new SectionBlock + { + Text = new TextObject + { + Type = TextObjectType.Markdown, + Text = "*Summary:*\n doodoo" + } + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_Section_Block_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new SectionBlock + { + Fields = + [ + new TextObject { Type = TextObjectType.Markdown, Text = "*Organization Name:*\n Test Organization" }, + new TextObject { Type = TextObjectType.Markdown, Text = "*Project Name:*\n Test Project" }, + new TextObject { Type = TextObjectType.Markdown, Text = "*Cloud Provider:*\n Test Cloud Provider" }, + new TextObject { Type = TextObjectType.Markdown, Text = "*Resources:*\n test-redis, test-postgreSQL" } + ] + }, + new SectionBlock + { + Fields = + [ + new TextObject { Type = TextObjectType.Markdown, Text = "*Severity:*\n Critical" }, + new TextObject { Type = TextObjectType.Markdown, Text = "*Status:*\n Closed" }, + new TextObject { Type = TextObjectType.Markdown, Text = $"*Triggered At:*\n{DateTimeOffset.UtcNow.ToString()}" } + ] + }, + new SectionBlock + { + Text = new TextObject + { + Type = TextObjectType.Markdown, + Text = "*Summary:*\n doodoo" + } + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_Video_Block_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new VideoBlock + { + Description = new TextObject + { + Type = TextObjectType.PlainText, + Text = "Test Description" + }, + AltText = "Walking on a dream", + VideoUrl = "https://www.youtube.com/embed/8876OZV_Yy0?feature=oembed&autoplay=1", + ThumbnailUrl = "https://i.ytimg.com/vi/8876OZV_Yy0/hqdefault.jpg", + Title = new TextObject + { + Type = TextObjectType.PlainText, + Text = "Test Video Title" + } + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async void When_Sending_A_Valid_Payload_With_Simple_Layout_Then_Return_200() + { + // Arrange + var payload = new SlackWebhookPayload + { + Blocks = new List + { + new HeaderBlock + { + Text = new TextObject + { + Type = TextObjectType.PlainText, + Text = "Header Text" + } + }, + new SectionBlock + { + Fields = + [ + new TextObject { Type = TextObjectType.Markdown, Text = $"*Organization Name:*\n Test Organization" }, + new TextObject { Type = TextObjectType.Markdown, Text = $"*Project Name:*\n Test Project" }, + new TextObject { Type = TextObjectType.Markdown, Text = $"*Cloud Provider:*\n Test Cloud Provider" }, + new TextObject { Type = TextObjectType.Markdown, Text = $"*Resources:*\n test-redis, test-postgreSQL" } + ] + }, + new SectionBlock + { + Fields = + [ + new TextObject { Type = TextObjectType.Markdown, Text = $"*Severity:*\n Critical" }, + new TextObject { Type = TextObjectType.Markdown, Text = $"*Status:*\n Closed" }, + new TextObject { Type = TextObjectType.Markdown, Text = $"*Triggered At:*\n{DateTimeOffset.UtcNow.ToString()}" } + ] + }, + new SectionBlock + { + Text = new TextObject + { + Type = TextObjectType.Markdown, + Text = $"*Summary:*\n doodoo" + } + }, + new ActionBlock + { + Elements = + [ + new ButtonElement + { + Text = new TextObject + { + Type = TextObjectType.PlainText, + Text = "View in Alertu" + }, + Url = "https://example.com", + Style = "primary" + }, + + new ButtonElement + { + Text = new TextObject + { + Type = TextObjectType.PlainText, + Text = "View in Azure" + }, + Url = "https://portal.azure.com" + } + ] + } + } + }; + + // Act + var response = await SendWebhookPayloadAsync(PlatformTypes.Slack, payload); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} \ No newline at end of file diff --git a/src/Hooki.IntegrationTests/appsettings.json b/src/Hooki.IntegrationTests/appsettings.json new file mode 100644 index 0000000..8baafad --- /dev/null +++ b/src/Hooki.IntegrationTests/appsettings.json @@ -0,0 +1,10 @@ +{ + "WebhookUrls": { + "Discord": "", + "MicrosoftTeams": "", + "Slack" : "" + }, + "PipedreamUrl": "", + "TestImageFileName": "", + "TestImageCloudUrl": "" +} diff --git a/src/Hooki.UnitTests/Discord/BuilderTests/AllowedMentionBuilderTests.cs b/src/Hooki.UnitTests/Discord/BuilderTests/AllowedMentionBuilderTests.cs new file mode 100644 index 0000000..0961c0a --- /dev/null +++ b/src/Hooki.UnitTests/Discord/BuilderTests/AllowedMentionBuilderTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using Hooki.Discord.Builders; +using Hooki.Discord.Enums; + +namespace Hooki.UnitTests.Discord.BuilderTests; + +public class AllowedMentionBuilderTests +{ + [Fact] + public void Build_With_All_Properties_Returns_Correct_AllowedMention() + { + // Arrange + var builder = new AllowedMentionBuilder() + .AddParse(AllowedMentionTypes.Roles) + .AddParse(AllowedMentionTypes.Users) + .AddRole("123456") + .AddUser("789012") + .WithRepliedUser(true); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Parse.Should().Contain(AllowedMentionTypes.Roles); + result.Parse.Should().Contain(AllowedMentionTypes.Users); + result.Roles.Should().Contain("123456"); + result.Users.Should().Contain("789012"); + result.RepliedUser.Should().BeTrue(); + } + + [Fact] + public void Build_With_No_Properties_Returns_Empty_AllowedMention() + { + // Arrange + var builder = new AllowedMentionBuilder(); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Parse.Should().BeNull(); + result.Roles.Should().BeNull(); + result.Users.Should().BeNull(); + result.RepliedUser.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Discord/BuilderTests/AttachmentBuilderTests.cs b/src/Hooki.UnitTests/Discord/BuilderTests/AttachmentBuilderTests.cs new file mode 100644 index 0000000..efcc08c --- /dev/null +++ b/src/Hooki.UnitTests/Discord/BuilderTests/AttachmentBuilderTests.cs @@ -0,0 +1,82 @@ +using FluentAssertions; +using Hooki.Discord.Builders; + +namespace Hooki.UnitTests.Discord.BuilderTests; + +public class AttachmentBuilderTests +{ + [Fact] + public void Build_With_Required_Properties_Returns_Correct_Attachment() + { + // Arrange + var builder = new AttachmentBuilder() + .WithId("123") + .WithFileName("test.txt"); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be("123"); + result.FileName.Should().Be("test.txt"); + + // Assert that all non-required fields are null + result.Title.Should().BeNull(); + result.Description.Should().BeNull(); + result.ContentType.Should().BeNull(); + result.Size.Should().BeNull(); + result.Url.Should().BeNull(); + result.ProxyUrl.Should().BeNull(); + result.Height.Should().BeNull(); + result.Width.Should().BeNull(); + result.Ephemeral.Should().BeNull(); + result.DurationSecs.Should().BeNull(); + result.Waveform.Should().BeNull(); + result.Flags.Should().BeNull(); + result.Content.Should().BeNull(); + } + + [Fact] + public void Build_With_All_Properties_Returns_Correct_Attachment() + { + // Arrange + var builder = new AttachmentBuilder() + .WithId("123") + .WithFileName("test.txt") + .WithTitle("Test Title") + .WithDescription("Test Description") + .WithContentType("text/plain") + .WithSize(100) + .WithUrl("http://example.com/test.txt") + .WithProxyUrl("http://proxy.example.com/test.txt") + .WithHeight(200) + .WithWidth(300) + .WithEphemeral(true) + .WithDurationSecs(10.5f) + .WithWaveform("waveform-data") + .WithFlags(1) + .WithContent([1, 2, 3]); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be("123"); + result.FileName.Should().Be("test.txt"); + result.Title.Should().Be("Test Title"); + result.Description.Should().Be("Test Description"); + result.ContentType.Should().Be("text/plain"); + result.Size.Should().Be(100); + result.Url.Should().Be("http://example.com/test.txt"); + result.ProxyUrl.Should().Be("http://proxy.example.com/test.txt"); + result.Height.Should().Be(200); + result.Width.Should().Be(300); + result.Ephemeral.Should().BeTrue(); + result.DurationSecs.Should().Be(10.5f); + result.Waveform.Should().Be("waveform-data"); + result.Flags.Should().Be(1); + result.Content.Should().BeEquivalentTo(new byte[] { 1, 2, 3 }); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Discord/BuilderTests/DiscordWebhookPayloadBuilderTests.cs b/src/Hooki.UnitTests/Discord/BuilderTests/DiscordWebhookPayloadBuilderTests.cs new file mode 100644 index 0000000..89e490f --- /dev/null +++ b/src/Hooki.UnitTests/Discord/BuilderTests/DiscordWebhookPayloadBuilderTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using Hooki.Discord.Builders; +using Hooki.Discord.Enums; +using Hooki.Discord.Models.BuildingBlocks; + +namespace Hooki.UnitTests.Discord.BuilderTests; + +public class DiscordWebhookPayloadBuilderTests +{ + [Fact] + public void Build_With_All_Properties_Returns_Correct_Payload() + { + // Arrange + var builder = new DiscordWebhookPayloadBuilder() + .WithContent("Test content") + .WithUsername("TestUser") + .WithAvatarUrl("http://example.com/avatar.png") + .WithTts(true) + .AddEmbed(e => e.WithTitle("Test Embed")) + .WithAllowedMentions(am => am.AddParse(AllowedMentionTypes.Users)) + .AddComponent(new { type = 1, style = 1, label = "Click me" }) + .AddFile(new FileContent { SnowflakeId = "123", FileName = "test.txt", FileContents = new byte[] { 1, 2, 3 }, ContentType = "text/plain" }) + .WithPayloadJson("{\"key\":\"value\"}") + .AddAttachment(new Attachment { Id = "456", FileName = "attachment.png" }) + .WithFlags(64) + .WithThreadName("Test Thread") + .AddAppliedTag("TestTag") + .WithPoll(p => p.WithQuestion(q => q.WithText("Test Question")).AddAnswer(a => a.WithAnswerId(1).WithPollMedia(m => m.WithText("Test Media")))); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Content.Should().Be("Test content"); + result.Username.Should().Be("TestUser"); + result.AvatarUrl.Should().Be("http://example.com/avatar.png"); + result.Tts.Should().BeTrue(); + result.Embeds.Should().ContainSingle(); + result.AllowedMentions.Should().NotBeNull(); + result.Components.Should().ContainSingle(); + result.Files.Should().ContainSingle(); + result.PayloadJson.Should().Be("{\"key\":\"value\"}"); + result.Attachments.Should().ContainSingle(); + result.Flags.Should().Be(64); + result.ThreadName.Should().Be("Test Thread"); + result.AppliedTags.Should().ContainSingle(); + result.Poll.Should().NotBeNull(); + result.Poll?.Question.Text.Should().Be("Test Question"); + result.Poll?.Answers.Should() + .ContainSingle(answer => answer.AnswerId == 1 && answer.PollMedia.Text == "Test Media"); + } + + [Fact] + public void Build_With_No_Properties_Returns_Empty_Payload() + { + // Arrange + var builder = new DiscordWebhookPayloadBuilder(); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Content.Should().BeNull(); + result.Username.Should().BeNull(); + result.AvatarUrl.Should().BeNull(); + result.Tts.Should().BeNull(); + result.Embeds.Should().BeNull(); + result.AllowedMentions.Should().BeNull(); + result.Components.Should().BeNull(); + result.Files.Should().BeNull(); + result.PayloadJson.Should().BeNull(); + result.Attachments.Should().BeNull(); + result.Flags.Should().BeNull(); + result.ThreadName.Should().BeNull(); + result.AppliedTags.Should().BeNull(); + result.Poll.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Discord/BuilderTests/EmbedBuilderTests.cs b/src/Hooki.UnitTests/Discord/BuilderTests/EmbedBuilderTests.cs new file mode 100644 index 0000000..d23ecc8 --- /dev/null +++ b/src/Hooki.UnitTests/Discord/BuilderTests/EmbedBuilderTests.cs @@ -0,0 +1,87 @@ +using FluentAssertions; +using Hooki.Discord.Builders; + +namespace Hooki.UnitTests.Discord.BuilderTests; + +public class EmbedBuilderTests +{ + [Fact] + public void Build_With_All_Properties_Returns_Correct_Embed() + { + const int hexColor = 959721; + const string url = "http://example.com"; + const string imageUrl = "http://example.com/footer.png"; + var timestamp = DateTimeOffset.UtcNow; + + // Arrange + var builder = new EmbedBuilder() + .WithTitle("Test Title") + .WithDescription("Test Description") + .WithUrl(url) + .WithTimestamp(timestamp) + .WithColor(hexColor) + .WithFooter("Test Footer", imageUrl) + .WithImage(imageUrl) + .WithThumbnail(imageUrl) + .WithAuthor("Test Author", "http://example.com/author", imageUrl) + .AddField("Field 1", "Value 1", true) + .AddField("Field 2", "Value 2", false); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Title.Should().Be("Test Title"); + result.Description.Should().Be("Test Description"); + result.Url.Should().Be(url); + result.Timestamp.Should().Be(timestamp); + result.Color.Should().Be(hexColor); + + result.Footer.Should().NotBeNull(); + result.Footer?.Text.Should().Be("Test Footer"); + result.Footer?.IconUrl.Should().Be(imageUrl); + + result.Image.Should().NotBeNull(); + result.Image?.Url.Should().Be(imageUrl); + + result.Thumbnail.Should().NotBeNull(); + result.Thumbnail?.Url.Should().Be(imageUrl); + + result.Author.Should().NotBeNull(); + result.Author?.Name.Should().Be("Test Author"); + result.Author?.Url.Should().Be("http://example.com/author"); + result.Author?.IconUrl.Should().Be(imageUrl); + + result.Fields.Should().NotBeNull().And.HaveCount(2); + result.Fields?[0].Name.Should().Be("Field 1"); + result.Fields?[0].Value.Should().Be("Value 1"); + result.Fields?[0].Inline.Should().BeTrue(); + result.Fields?[1].Name.Should().Be("Field 2"); + result.Fields?[1].Value.Should().Be("Value 2"); + result.Fields?[1].Inline.Should().BeFalse(); + } + + [Fact] + public void Build_With_No_Properties_Returns_Empty_Embed() + { + // Arrange + var builder = new EmbedBuilder(); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Title.Should().BeNull(); + result.Description.Should().BeNull(); + result.Url.Should().BeNull(); + result.Timestamp.Should().BeNull(); + result.Color.Should().BeNull(); + result.Footer.Should().BeNull(); + result.Image.Should().BeNull(); + result.Thumbnail.Should().BeNull(); + result.Author.Should().BeNull(); + result.Fields.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Discord/BuilderTests/FileContentBuilderTests.cs b/src/Hooki.UnitTests/Discord/BuilderTests/FileContentBuilderTests.cs new file mode 100644 index 0000000..7281458 --- /dev/null +++ b/src/Hooki.UnitTests/Discord/BuilderTests/FileContentBuilderTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using Hooki.Discord.Builders; + +namespace Hooki.UnitTests.Discord.BuilderTests; + +public class FileContentBuilderTests +{ + [Fact] + public void Build_With_All_Properties_Returns_Correct_FileContent() + { + // Arrange + var builder = new FileContentBuilder() + .WithSnowflakeId("123456") + .WithFileName("test.txt") + .WithFileContents(new byte[] { 1, 2, 3 }) + .WithContentType("application/json"); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.SnowflakeId.Should().Be("123456"); + result.FileName.Should().Be("test.txt"); + result.FileContents.Should().BeEquivalentTo(new byte[] { 1, 2, 3 }); + result.ContentType.Should().Be("application/json"); + } +} diff --git a/src/Hooki.UnitTests/Discord/BuilderTests/PollCreateRequestBuilderTests.cs b/src/Hooki.UnitTests/Discord/BuilderTests/PollCreateRequestBuilderTests.cs new file mode 100644 index 0000000..7eebbd8 --- /dev/null +++ b/src/Hooki.UnitTests/Discord/BuilderTests/PollCreateRequestBuilderTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using Hooki.Discord.Builders; + +namespace Hooki.UnitTests.Discord.BuilderTests; + +public class PollCreateRequestBuilderTests +{ + [Fact] + public void Build_With_All_Properties_Returns_Expected_Result() + { + // Arrange + var builder = new PollCreateRequestBuilder() + .WithQuestion(q => q.WithText("Test Question")) + .AddAnswer(a => a.WithAnswerId(1).WithPollMedia(m => m.WithText("Answer 1").WithEmoji(e => e.WithName("1๏ธโƒฃ").WithId("๏ธ123")))) + .AddAnswer(a => a.WithAnswerId(2).WithPollMedia(m => m.WithText("Answer 2").WithEmoji(e => e.WithName("2๏ธโƒฃ").WithId("123")))) + .AllowMultiSelect() + .WithDuration(24) + .WithLayoutType(1); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Question.Should().NotBeNull(); + result.Question.Text.Should().Be("Test Question"); + result.Question.Emoji.Should().BeNull(); + result.Answers.Should().HaveCount(2); + result.Duration.Should().Be(24); + result.AllowMultiSelect.Should().BeTrue(); + result.LayoutType.Should().Be(1); + } + + [Fact] + public void Build_With_Minimal_Properties_Returns_Expected_Result() + { + // Arrange + var builder = new PollCreateRequestBuilder(); + + // Act + Assert.Throws(() => builder.Build()); + + builder + .WithQuestion(q => q.WithText("Test Question")); + + Assert.Throws(() => builder.Build()); + + builder + .AddAnswer(a => + a.WithAnswerId(1).WithPollMedia(m => m.WithText("Answer 1").WithEmoji(e => e.WithName("1๏ธโƒฃ")))) + .AddAnswer(a => + a.WithAnswerId(2).WithPollMedia(m => m.WithText("Answer 2").WithEmoji(e => e.WithName("2๏ธโƒฃ")))); + + var result = builder.Build(); + + // Assert + result.LayoutType.Should().BeNull(); + result.Duration.Should().BeNull(); + result.AllowMultiSelect.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Hooki.UnitTests.csproj b/src/Hooki.UnitTests/Hooki.UnitTests.csproj new file mode 100644 index 0000000..30a7286 --- /dev/null +++ b/src/Hooki.UnitTests/Hooki.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + Hooki.UnitTests + + + + + + + + + + + + + + + + + + + diff --git a/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/FactBuilderTests.cs b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/FactBuilderTests.cs new file mode 100644 index 0000000..feacb66 --- /dev/null +++ b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/FactBuilderTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Hooki.MicrosoftTeams.Builders; + +namespace Hooki.UnitTests.MicrosoftTeams.BuilderTests; + +public class FactBuilderTests +{ + [Fact] + public void Build_With_All_Properties_Returns_Correct_Fact() + { + // Arrange + var builder = new FactBuilder() + .WithName("Test Name") + .WithValue("Test Value"); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Name.Should().Be("Test Name"); + result.Value.Should().Be("Test Value"); + } + + [Fact] + public void Build_Without_Properties_Throws_InvalidOperationException() + { + // Arrange + var builder = new FactBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Name is required"); + + builder.WithName("Test Name") + .Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Value is required"); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/HeaderBuilderTests.cs b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/HeaderBuilderTests.cs new file mode 100644 index 0000000..b0f01b2 --- /dev/null +++ b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/HeaderBuilderTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Hooki.MicrosoftTeams.Builders; + +namespace Hooki.UnitTests.MicrosoftTeams.BuilderTests; + +public class HeaderBuilderTests +{ + [Fact] + public void Build_WithAllProperties_ReturnsCorrectHeader() + { + // Arrange + var builder = new HeaderBuilder() + .WithName("Test Name") + .WithValue("Test Value"); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Name.Should().Be("Test Name"); + result.Value.Should().Be("Test Value"); + } + + [Fact] + public void Build_WithoutProperties_ThrowsInvalidOperationException() + { + // Arrange + var builder = new HeaderBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Name is required for Header."); + + builder.WithName("Test Name") + .Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Value is required for Header."); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/ImageBlockBuilderTests.cs b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/ImageBlockBuilderTests.cs new file mode 100644 index 0000000..d48b4c7 --- /dev/null +++ b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/ImageBlockBuilderTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using Hooki.MicrosoftTeams.Builders; + +namespace Hooki.UnitTests.MicrosoftTeams.BuilderTests; + +public class ImageBlockBuilderTests +{ + private const string TestImageUrl = "https://example.com/image.jpg"; + private const string Title = "Test Image"; + + [Fact] + public void Build_With_All_Properties_Returns_Correct_Image() + { + // Arrange + var builder = new ImageBlockBuilder() + .WithImageUrl(TestImageUrl) + .WithTitle(Title); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.ImageUrl.Should().Be(TestImageUrl); + result.Title.Should().Be(Title); + } + + [Fact] + public void Build_With_Minimum_Properties_Returns_Correct_Image() + { + // Arrange + var builder = new ImageBlockBuilder() + .WithImageUrl(TestImageUrl); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.ImageUrl.Should().Be(TestImageUrl); + result.Title.Should().BeNull(); + } + + [Fact] + public void Build_Without_ImageUrl_Throws_InvalidOperationException() + { + // Arrange + var builder = new ImageBlockBuilder() + .WithTitle(Title); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("ImageUrl is required"); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/MessageCardBuilderTests.cs b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/MessageCardBuilderTests.cs new file mode 100644 index 0000000..4519392 --- /dev/null +++ b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/MessageCardBuilderTests.cs @@ -0,0 +1,91 @@ +using FluentAssertions; +using Hooki.MicrosoftTeams.Builders; +using Hooki.MicrosoftTeams.Models.Actions; + +namespace Hooki.UnitTests.MicrosoftTeams.BuilderTests; + +public class MessageCardBuilderTests + { + private const string CorrelationId = "test-correlation-id"; + private const string Originator = "test-originator"; + private const string Title = "Test Title"; + private const string Text = "Test Text"; + private const string ThemeColor = "#FF0000"; + private const string Summary = "Test Summary"; + private const string ExpectedActor = "test-actor"; + private const string SectionTitle = "Test Section"; + + private const string Uri = "https://example.com"; + private const string Name = "Test Action"; + + [Fact] + public void Build_With_All_Properties_Returns_Correct_MessageCard() + { + // Arrange + var builder = new MessageCardBuilder() + .WithCorrelationId(CorrelationId) + .WithOriginator(Originator) + .WithTitle(Title) + .WithText(Text) + .WithThemeColor(ThemeColor) + .WithSummary(Summary) + .AddExpectedActor(ExpectedActor) + .WithHiddenOriginalBody(true) + .AddSection(s => s.WithTitle(SectionTitle)) + .AddOpenUriAction(Name, Uri); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.CorrelationId.Should().Be(CorrelationId); + result.Originator.Should().Be(Originator); + result.Title.Should().Be(Title); + result.Text.Should().Be(Text); + result.ThemeColor.Should().Be(ThemeColor); + result.Summary.Should().Be(Summary); + result.ExpectedActors.Should().ContainSingle().Which.Should().Be(ExpectedActor); + result.HideOriginalBody.Should().BeTrue(); + result.Sections.Should().ContainSingle().Which.Title.Should().Be(SectionTitle); + result.PotentialActions.Should().ContainSingle().Which.Should().BeOfType() + .Which.Name.Should().Be(Name); + } + + [Fact] + public void Build_With_Minimum_Properties_Returns_Correct_MessageCard() + { + // Arrange + var builder = new MessageCardBuilder() + .WithText(Text); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Text.Should().Be(Text); + result.CorrelationId.Should().BeNull(); + result.Originator.Should().BeNull(); + result.Title.Should().BeNull(); + result.ThemeColor.Should().BeNull(); + result.Summary.Should().BeNull(); + result.ExpectedActors.Should().BeNull(); + result.HideOriginalBody.Should().BeNull(); + result.Sections.Should().BeNull(); + result.PotentialActions.Should().BeNull(); + } + + [Fact] + public void Build_Without_Text_Or_Summary_Throws_InvalidOperationException() + { + // Arrange + var builder = new MessageCardBuilder() + .WithTitle(Title); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Either Text or Summary must be provided."); + } + } \ No newline at end of file diff --git a/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/SectionBuilderTests.cs b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/SectionBuilderTests.cs new file mode 100644 index 0000000..5faef9c --- /dev/null +++ b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/SectionBuilderTests.cs @@ -0,0 +1,117 @@ +using FluentAssertions; +using Hooki.MicrosoftTeams.Builders; +using Hooki.MicrosoftTeams.Models.Actions; + +namespace Hooki.UnitTests.MicrosoftTeams.BuilderTests +{ + public class SectionBuilderTests + { + private const string Title = "Test Section"; + private const string ActivityImage = "https://example.com/activity.jpg"; + private const string ActivityTitle = "Activity Title"; + private const string ActivitySubtitle = "Activity Subtitle"; + private const string ActivityText = "Activity Text"; + private const string HeroImageUrl = "https://example.com/hero.jpg"; + private const string HeroImageTitle = "Hero Image"; + private const string Text = "Section Text"; + private const string FactName = "Fact Name"; + private const string FactValue = "Fact Value"; + private const string ImageUrl = "https://example.com/image.jpg"; + private const string ImageTitle = "Image Title"; + + private const string Uri = "https://example.com"; + private const string Name = "Fetch"; + + [Fact] + public void Build_With_All_Properties_Returns_Correct_Section() + { + // Arrange + var builder = new SectionBuilder() + .WithTitle(Title) + .WithStartGroup(true) + .WithActivityImage(ActivityImage) + .WithActivityTitle(ActivityTitle) + .WithActivitySubtitle(ActivitySubtitle) + .WithActivityText(ActivityText) + .WithHeroImage(i => i.WithImageUrl(HeroImageUrl).WithTitle(HeroImageTitle)) + .WithText(Text) + .AddFact(f => f.WithName(FactName).WithValue(FactValue)) + .AddImage(i => i.WithImageUrl(ImageUrl).WithTitle(ImageTitle)) + .AddOpenUriAction(Name, Uri); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Title.Should().Be(Title); + result.StartGroup.Should().BeTrue(); + result.ActivityImage.Should().Be(ActivityImage); + result.ActivityTitle.Should().Be(ActivityTitle); + result.ActivitySubtitle.Should().Be(ActivitySubtitle); + result.ActivityText.Should().Be(ActivityText); + result.HeroImage.Should().NotBeNull(); + result.HeroImage!.ImageUrl.Should().Be(HeroImageUrl); + result.HeroImage.Title.Should().Be(HeroImageTitle); + result.Text.Should().Be(Text); + result.Facts.Should().ContainSingle(); + result.Facts![0].Name.Should().Be(FactName); + result.Facts[0].Value.Should().Be(FactValue); + result.Images.Should().ContainSingle(); + result.Images![0].ImageUrl.Should().Be(ImageUrl); + result.Images[0].Title.Should().Be(ImageTitle); + result.PotentialActions.Should().ContainSingle(); + result.PotentialActions![0].Should().BeOfType() + .Which.Name.Should().Be(Name); + } + + [Fact] + public void Build_With_Minimum_Properties_Returns_Correct_Section() + { + // Arrange + var builder = new SectionBuilder() + .WithTitle(Title); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Title.Should().Be(Title); + result.StartGroup.Should().BeNull(); + result.ActivityImage.Should().BeNull(); + result.ActivityTitle.Should().BeNull(); + result.ActivitySubtitle.Should().BeNull(); + result.ActivityText.Should().BeNull(); + result.HeroImage.Should().BeNull(); + result.Text.Should().BeNull(); + result.Facts.Should().BeNull(); + result.Images.Should().BeNull(); + result.PotentialActions.Should().BeNull(); + } + + [Fact] + public void Build_With_NoProperties_Returns_Empty_Section() + { + // Arrange + var builder = new SectionBuilder(); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Title.Should().BeNull(); + result.StartGroup.Should().BeNull(); + result.ActivityImage.Should().BeNull(); + result.ActivityTitle.Should().BeNull(); + result.ActivitySubtitle.Should().BeNull(); + result.ActivityText.Should().BeNull(); + result.HeroImage.Should().BeNull(); + result.Text.Should().BeNull(); + result.Facts.Should().BeNull(); + result.Images.Should().BeNull(); + result.PotentialActions.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/TargetBuilderTests.cs b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/TargetBuilderTests.cs new file mode 100644 index 0000000..04f3fb0 --- /dev/null +++ b/src/Hooki.UnitTests/MicrosoftTeams/BuilderTests/TargetBuilderTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using Hooki.MicrosoftTeams.Builders; +using Hooki.MicrosoftTeams.Enums; + +namespace Hooki.UnitTests.MicrosoftTeams.BuilderTests; + +public class TargetBuilderTests + { + private const string ValidUri = "https://example.com"; + + private const OperatingSystemType DefaultOperatingSystem = OperatingSystemType.Default; + private const OperatingSystemType CustomOperatingSystem = OperatingSystemType.Android; + + [Fact] + public void Build_With_All_Properties_Returns_Correct_Target() + { + // Arrange + var builder = new TargetBuilder() + .WithOperatingSystem(CustomOperatingSystem) + .WithUri(ValidUri); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.OperatingSystem.Should().Be(CustomOperatingSystem); + result.Uri.Should().Be(ValidUri); + } + + [Fact] + public void Build_With_Only_Uri_Returns_Target_With_Default_OS() + { + // Arrange + var builder = new TargetBuilder() + .WithUri(ValidUri); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.OperatingSystem.Should().Be(DefaultOperatingSystem); + result.Uri.Should().Be(ValidUri); + } + + [Fact] + public void Build_Without_Uri_Throws_InvalidOperationException() + { + // Arrange + var builder = new TargetBuilder() + .WithOperatingSystem(CustomOperatingSystem); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Uri is required for Target."); + } + + [Theory] + [InlineData(OperatingSystemType.IOS)] + [InlineData(OperatingSystemType.Android)] + [InlineData(OperatingSystemType.Windows)] + public void Build_With_Different_OS_Types_Returns_Correct_Target(OperatingSystemType osType) + { + // Arrange + var builder = new TargetBuilder() + .WithOperatingSystem(osType) + .WithUri(ValidUri); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.OperatingSystem.Should().Be(osType); + result.Uri.Should().Be(ValidUri); + } + } \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/ActionBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/ActionBlockBuilderTests.cs new file mode 100644 index 0000000..61857be --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/ActionBlockBuilderTests.cs @@ -0,0 +1,142 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class ActionBlockBuilderTests +{ + [Fact] + public void Build_With_Single_Element_Returns_Valid_ActionBlock() + { + // Arrange + var builder = new ActionBlockBuilder() + .AddElement(() => new ButtonElement { Text = new TextObject { Text = "Click me", Type = TextObjectType.PlainText } }); + + // Act + var result = builder.Build() as ActionBlock; + + // Assert + result.Should().NotBeNull(); + result?.Elements.Should().HaveCount(1); + result?.Elements.First().Should().BeOfType(); + (result?.Elements.First() as ButtonElement)!.Text.Text.Should().Be("Click me"); + } + + [Fact] + public void Build_With_Multiple_Elements_Returns_Valid_ActionBlock() + { + // Arrange + var builder = new ActionBlockBuilder() + .AddElement(() => new ButtonElement { Text = new TextObject { Text = "Button 1", Type = TextObjectType.PlainText } }) + .AddElement(() => new ButtonElement { Text = new TextObject { Text = "Button 2", Type = TextObjectType.PlainText } }); + + // Act + var result = builder.Build() as ActionBlock; + + // Assert + result.Should().NotBeNull(); + result?.Elements.Should().HaveCount(2); + result?.Elements.Should().AllBeOfType(); + result?.Elements.Cast().Select(b => b.Text.Text).Should().ContainInOrder("Button 1", "Button 2"); + } + + [Fact] + public void Build_With_No_Elements_Throws_InvalidOperationException() + { + // Arrange + var builder = new ActionBlockBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Elements are required"); + } + + [Fact] + public void Build_Inherits_BlockId_From_Base_Builder() + { + // Arrange + var builder = new ActionBlockBuilder() + .AddElement(() => new ButtonElement + { Text = new TextObject { Text = "Click me", Type = TextObjectType.PlainText } }); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public void Build_With_Different_Element_Types_Returns_Valid_ActionBlock() + { + // Arrange + var builder = new ActionBlockBuilder() + .AddElement(() => new ButtonElement { + Text = new TextObject { Text = "Button", Type = TextObjectType.PlainText } + }) + .AddElement(() => new SelectMenuElement { + Options = new[] { new OptionObject { Text = new TextObject { Type = TextObjectType.PlainText, Text = "Option" }, Value = "value" } }, + Placeholder = new TextObject { Text = "Select", Type = TextObjectType.PlainText } + }) + .AddElement(() => new MultiSelectMenuElement { + Options = new[] { new OptionObject { Text = new TextObject { Type = TextObjectType.PlainText, Text = "Option" }, Value = "value" } }, + Placeholder = new TextObject { Text = "Multi Select", Type = TextObjectType.PlainText } + }) + .AddElement(() => new OverflowMenuElement { + Options = new List() {new OptionObject { Text = new TextObject { Type = TextObjectType.PlainText, Text = "Option" }, Value = "value" } } + }) + .AddElement(() => new DatePickerElement { + Placeholder = new TextObject { Text = "Select date", Type = TextObjectType.PlainText } + }) + .AddElement(() => new TimePickerElement { + Placeholder = new TextObject { Text = "Select time", Type = TextObjectType.PlainText } + }) + .AddElement(() => new DateTimePickerElement()) + .AddElement(() => new RadioButtonGroupElement { + Options = new[] { new OptionObject { Text = new TextObject { Type = TextObjectType.PlainText, Text = "Option" }, Value = "value" } } + }) + .AddElement(() => new CheckboxElement { + Options = new List { new OptionObject { Text = new TextObject { Type = TextObjectType.PlainText, Text = "Option" }, Value = "value" } } + }) + .AddElement(() => new WorkflowButtonElement { + Text = new TextObject { Text = "Workflow", Type = TextObjectType.PlainText }, + Workflow = new WorkflowObject { Trigger = new TriggerObject { Url = "https://example.com" } } + }); + + // Act + var result = builder.Build() as ActionBlock; + + // Assert + result.Should().NotBeNull(); + result?.Elements.Should().HaveCount(10); + result?.Elements[0].Should().BeOfType(); + result?.Elements[1].Should().BeOfType(); + result?.Elements[2].Should().BeOfType(); + result?.Elements[3].Should().BeOfType(); + result?.Elements[4].Should().BeOfType(); + result?.Elements[5].Should().BeOfType(); + result?.Elements[6].Should().BeOfType(); + result?.Elements[7].Should().BeOfType(); + result?.Elements[8].Should().BeOfType(); + result?.Elements[9].Should().BeOfType(); + } + + [Fact] + public void AddElement_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ActionBlockBuilder(); + + // Act + var result = builder.AddElement(() => new ButtonElement + {Text = new TextObject {Text = "Text", Type = TextObjectType.PlainText}}); + + // Assert + result.Should().BeSameAs(builder); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/ButtonBlockElementBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/ButtonBlockElementBuilderTests.cs new file mode 100644 index 0000000..c91c17f --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/ButtonBlockElementBuilderTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.BlockElements; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class ButtonElementBuilderTests +{ + [Fact] + public void Build_With_Required_Properties_Returns_Valid_ButtonElement() + { + // Arrange + var builder = new ButtonElementBuilder() + .WithText(t => t.WithText("Click me").WithType(TextObjectType.PlainText)); + + // Act + var result = builder.Build() as ButtonElement; + + // Assert + result.Should().NotBeNull(); + result!.Text.Should().NotBeNull(); + result.Text.Text.Should().Be("Click me"); + result.Text.Type.Should().Be(TextObjectType.PlainText); + result.ActionId.Should().BeNull(); + result.Url.Should().BeNull(); + result.Value.Should().BeNull(); + result.Style.Should().BeNull(); + result.Confirm.Should().BeNull(); + result.AccessibilityLabel.Should().BeNull(); + } + + [Fact] + public void Build_With_All_Properties_Returns_Valid_ButtonElement() + { + // Arrange + var builder = new ButtonElementBuilder() + .WithText(t => t.WithText("Click me").WithType(TextObjectType.PlainText)) + .WithUrl("https://example.com") + .WithValue("button_value") + .WithStyle("primary") + .WithConfirm(c => c + .WithTitle(t => t.WithText("Are you sure?").WithType(TextObjectType.PlainText)) + .WithText(t => t.WithText("This action cannot be undone.").WithType(TextObjectType.PlainText)) + .WithConfirm(t => t.WithText("Yes").WithType(TextObjectType.PlainText)) + .WithDeny(t => t.WithText("No").WithType(TextObjectType.PlainText))) + .WithAccessibilityLabel("Accessible button label") + .WithActionId("button_action"); + + // Act + var result = builder.Build() as ButtonElement; + + // Assert + result.Should().NotBeNull(); + result!.ActionId.Should().Be("button_action"); + result.Text.Should().NotBeNull(); + result.Text.Text.Should().Be("Click me"); + result.Url.Should().Be("https://example.com"); + result.Value.Should().Be("button_value"); + result.Style.Should().Be("primary"); + result.Confirm.Should().NotBeNull(); + result.AccessibilityLabel.Should().Be("Accessible button label"); + } + + [Fact] + public void Build_Without_Text_Throws_InvalidOperationException() + { + // Arrange + var builder = new ButtonElementBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Text is required for a ButtonElement."); + } + + [Fact] + public void WithText_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ButtonElementBuilder(); + + // Act + var result = builder.WithText(t => t.WithText("Click me").WithType(TextObjectType.PlainText)); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithUrl_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ButtonElementBuilder(); + + // Act + var result = builder.WithUrl("https://example.com"); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithValue_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ButtonElementBuilder(); + + // Act + var result = builder.WithValue("button_value"); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithStyle_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ButtonElementBuilder(); + + // Act + var result = builder.WithStyle("primary"); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithConfirm_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ButtonElementBuilder(); + + // Act + var result = builder.WithConfirm(c => c + .WithTitle(t => t.WithText("Are you sure?").WithType(TextObjectType.PlainText)) + .WithText(t => t.WithText("This action cannot be undone.").WithType(TextObjectType.PlainText)) + .WithConfirm(t => t.WithText("Yes").WithType(TextObjectType.PlainText)) + .WithDeny(t => t.WithText("No").WithType(TextObjectType.PlainText))); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithAccessibilityLabel_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ButtonElementBuilder(); + + // Act + var result = builder.WithAccessibilityLabel("Accessible button label"); + + // Assert + result.Should().BeSameAs(builder); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/ConfirmationDialogBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/ConfirmationDialogBuilderTests.cs new file mode 100644 index 0000000..cd1474d --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/ConfirmationDialogBuilderTests.cs @@ -0,0 +1,142 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class ConfirmationDialogObjectBuilderTests +{ + [Fact] + public void Build_With_Required_Properties_Returns_Valid_ConfirmationDialogObject() + { + // Arrange + var builder = new ConfirmationDialogObjectBuilder() + .WithTitle(t => t.WithType(TextObjectType.PlainText).WithText("Title")) + .WithText(t => t.WithType(TextObjectType.PlainText).WithText("Text")) + .WithConfirm(t => t.WithType(TextObjectType.PlainText).WithText("Confirm")) + .WithDeny(t => t.WithType(TextObjectType.PlainText).WithText("Deny")); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Title.Should().NotBeNull(); + result.Title.Text.Should().Be("Title"); + result.Text.Should().NotBeNull(); + result.Text.Text.Should().Be("Text"); + result.Confirm.Should().NotBeNull(); + result.Confirm.Text.Should().Be("Confirm"); + result.Deny.Should().NotBeNull(); + result.Deny.Text.Should().Be("Deny"); + result.Style.Should().BeNull(); + } + + [Fact] + public void Build_With_All_Properties_Returns_Valid_ConfirmationDialogObject() + { + // Arrange + var builder = new ConfirmationDialogObjectBuilder() + .WithTitle(t => t.WithType(TextObjectType.PlainText).WithText("Title")) + .WithText(t => t.WithType(TextObjectType.PlainText).WithText("Text")) + .WithConfirm(t => t.WithType(TextObjectType.PlainText).WithText("Confirm")) + .WithDeny(t => t.WithType(TextObjectType.PlainText).WithText("Deny")) + .WithStyle("danger"); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Title.Should().NotBeNull(); + result.Title.Text.Should().Be("Title"); + result.Text.Should().NotBeNull(); + result.Text.Text.Should().Be("Text"); + result.Confirm.Should().NotBeNull(); + result.Confirm.Text.Should().Be("Confirm"); + result.Deny.Should().NotBeNull(); + result.Deny.Text.Should().Be("Deny"); + result.Style.Should().Be("danger"); + } + + [Fact] + public void Build_Without_Title_Throws_InvalidOperationException() + { + // Arrange + var builder = new ConfirmationDialogObjectBuilder() + .WithText(t => t.WithType(TextObjectType.PlainText).WithText("Text")) + .WithConfirm(t => t.WithType(TextObjectType.PlainText).WithText("Confirm")) + .WithDeny(t => t.WithType(TextObjectType.PlainText).WithText("Deny")); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Title is required for a ConfirmationDialogObject."); + } + + [Fact] + public void Build_Without_Text_Throws_InvalidOperationException() + { + // Arrange + var builder = new ConfirmationDialogObjectBuilder() + .WithTitle(t => t.WithType(TextObjectType.PlainText).WithText("Title")) + .WithConfirm(t => t.WithType(TextObjectType.PlainText).WithText("Confirm")) + .WithDeny(t => t.WithType(TextObjectType.PlainText).WithText("Deny")); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Text is required for a ConfirmationDialogObject."); + } + + [Fact] + public void Build_Without_Confirm_Throws_InvalidOperationException() + { + // Arrange + var builder = new ConfirmationDialogObjectBuilder() + .WithTitle(t => t.WithType(TextObjectType.PlainText).WithText("Title")) + .WithText(t => t.WithType(TextObjectType.PlainText).WithText("Text")) + .WithDeny(t => t.WithType(TextObjectType.PlainText).WithText("Deny")); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Confirm is required for a ConfirmationDialogObject."); + } + + [Fact] + public void Build_Without_Deny_Throws_InvalidOperationException() + { + // Arrange + var builder = new ConfirmationDialogObjectBuilder() + .WithTitle(t => t.WithType(TextObjectType.PlainText).WithText("Title")) + .WithText(t => t.WithType(TextObjectType.PlainText).WithText("Text")) + .WithConfirm(t => t.WithType(TextObjectType.PlainText).WithText("Confirm")); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Deny is required for a ConfirmationDialogObject."); + } + + [Fact] + public void Build_With_Different_TextObject_Types_Returns_Valid_ConfirmationDialogObject() + { + // Arrange + var builder = new ConfirmationDialogObjectBuilder() + .WithTitle(t => t.WithType(TextObjectType.PlainText).WithText("Title")) + .WithText(t => t.WithType(TextObjectType.Markdown).WithText("*Text*")) + .WithConfirm(t => t.WithType(TextObjectType.PlainText).WithText("Confirm")) + .WithDeny(t => t.WithType(TextObjectType.PlainText).WithText("Deny")); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Title.Type.Should().Be(TextObjectType.PlainText); + result.Text.Type.Should().Be(TextObjectType.Markdown); + result.Confirm.Type.Should().Be(TextObjectType.PlainText); + result.Deny.Type.Should().Be(TextObjectType.PlainText); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/ContextBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/ContextBlockBuilderTests.cs new file mode 100644 index 0000000..1463d60 --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/ContextBlockBuilderTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class ContextBlockBuilderTests +{ + [Fact] + public void Build_With_Single_Element_Returns_Valid_ContextBlock() + { + // Arrange + var builder = new ContextBlockBuilder() + .AddElement(() => new TextObject { Text = "Context text", Type = TextObjectType.PlainText }); + + // Act + var result = builder.Build() as ContextBlock; + + // Assert + result.Should().NotBeNull(); + result!.Elements.Should().HaveCount(1); + result.Elements.First().Should().BeOfType(); + (result.Elements.First() as TextObject)!.Text.Should().Be("Context text"); + } + + [Fact] + public void Build_With_Multiple_Elements_Returns_Valid_ContextBlock() + { + // Arrange + var builder = new ContextBlockBuilder() + .AddElement(() => new TextObject { Text = "Text 1", Type = TextObjectType.PlainText }) + .AddElement(() => new TextObject { Text = "Text 2", Type = TextObjectType.Markdown }); + + // Act + var result = builder.Build() as ContextBlock; + + // Assert + result.Should().NotBeNull(); + result!.Elements.Should().HaveCount(2); + result.Elements.Should().AllBeOfType(); + result.Elements.Cast().Select(t => t.Text).Should().ContainInOrder("Text 1", "Text 2"); + } + + [Fact] + public void Build_With_No_Elements_Throws_InvalidOperationException() + { + // Arrange + var builder = new ContextBlockBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("At least one element is required for an ActionBlock."); + } + + [Fact] + public void Build_Inherits_BlockId_From_Base_Builder() + { + // Arrange + var builder = new ContextBlockBuilder() + .AddElement(() => new TextObject { Text = "Context text", Type = TextObjectType.PlainText }); + + // Act + var result = builder.Build() as ContextBlock; + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public void Build_With_ImageElement_And_TextObject_Returns_Valid_ContextBlock() + { + // Arrange + var builder = new ContextBlockBuilder() + .AddElement(() => new ImageElement + { + ImageUrl = "http://example.com/image.jpg", + AltText = "Example Image" + }) + .AddElement(() => new TextObject + { + Text = "Context text", + Type = TextObjectType.PlainText + }); + + // Act + var result = builder.Build() as ContextBlock; + + // Assert + result.Should().NotBeNull(); + result!.Elements.Should().HaveCount(2); + result.Elements[0].Should().BeOfType(); + result.Elements[1].Should().BeOfType(); + + var imageElement = result.Elements[0] as ImageElement; + imageElement.Should().NotBeNull(); + imageElement!.ImageUrl.Should().Be("http://example.com/image.jpg"); + imageElement.AltText.Should().Be("Example Image"); + + var textObject = result.Elements[1] as TextObject; + textObject.Should().NotBeNull(); + textObject!.Text.Should().Be("Context text"); + textObject.Type.Should().Be(TextObjectType.PlainText); + } + + [Fact] + public void AddElement_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ContextBlockBuilder(); + + // Act + var result = builder.AddElement(() => new TextObject {Text = "Text", Type = TextObjectType.Markdown }); + + // Assert + result.Should().BeSameAs(builder); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/DividerBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/DividerBlockBuilderTests.cs new file mode 100644 index 0000000..b1f303c --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/DividerBlockBuilderTests.cs @@ -0,0 +1,38 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class DividerBlockBuilderTests +{ + [Fact] + public void Build_Returns_Valid_DividerBlock() + { + // Arrange + var builder = new DividerBlockBuilder(); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + result.BlockId.Should().BeNull(); + } + + [Fact] + public void Build_Without_BlockId_Returns_DividerBlock_With_Null_BlockId() + { + // Arrange + var builder = new DividerBlockBuilder(); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + result.BlockId.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/FileBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/FileBlockBuilderTests.cs new file mode 100644 index 0000000..98ec0b7 --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/FileBlockBuilderTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class FileBlockBuilderTests +{ + [Fact] + public void Build_With_ExternalId_And_Source_Returns_Valid_FileBlock() + { + // Arrange + var builder = new FileBlockBuilder() + .WithExternalId("external-123") + .WithSource("source-456"); + + // Act + var result = builder.Build() as FileBlock; + + // Assert + result.Should().NotBeNull(); + result!.ExternalId.Should().Be("external-123"); + result.Source.Should().Be("source-456"); + result.BlockId.Should().BeNull(); + } + + [Fact] + public void Build_With_BlockId_ExternalId_And_Source_Returns_Valid_FileBlock() + { + // Arrange + var builder = new FileBlockBuilder() + .WithExternalId("external-123") + .WithSource("source-456"); + + // Act + var result = builder.Build() as FileBlock; + + // Assert + result.Should().NotBeNull(); + result?.ExternalId.Should().Be("external-123"); + result?.Source.Should().Be("source-456"); + } + + [Fact] + public void Build_Without_ExternalId_Returns_FileBlock_With_Default_ExternalId() + { + // Arrange + var builder = new FileBlockBuilder() + .WithSource("source-456"); + + // Act + var result = builder.Build() as FileBlock; + + // Assert + result.Should().NotBeNull(); + result!.ExternalId.Should().Be(default!); + result.Source.Should().Be("source-456"); + } + + [Fact] + public void Build_Without_Source_Returns_FileBlock_With_Default_Source() + { + // Arrange + var builder = new FileBlockBuilder() + .WithExternalId("external-123"); + + // Act + var result = builder.Build() as FileBlock; + + // Assert + result.Should().NotBeNull(); + result!.ExternalId.Should().Be("external-123"); + result.Source.Should().Be(default!); + } + + [Fact] + public void WithExternalId_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new FileBlockBuilder(); + + // Act + var result = builder.WithExternalId("external-123"); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithSource_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new FileBlockBuilder(); + + // Act + var result = builder.WithSource("source-456"); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void Multiple_Builds_With_Same_Builder_Return_Different_Instances() + { + // Arrange + var builder = new FileBlockBuilder() + .WithExternalId("external-123") + .WithSource("source-456"); + + // Act + var result1 = builder.Build(); + var result2 = builder.Build(); + + // Assert + result1.Should().NotBeSameAs(result2); + (result1 as FileBlock)!.ExternalId.Should().Be((result2 as FileBlock)!.ExternalId); + (result1 as FileBlock)!.Source.Should().Be((result2 as FileBlock)!.Source); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/HeaderBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/HeaderBlockBuilderTests.cs new file mode 100644 index 0000000..e4baff0 --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/HeaderBlockBuilderTests.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class HeaderBlockBuilderTests + { + [Fact] + public void Build_With_Text_Returns_Valid_HeaderBlock() + { + // Arrange + var text = new TextObject { Text = "Header Text", Type = TextObjectType.PlainText }; + var builder = new HeaderBlockBuilder().WithText(text); + + // Act + var result = builder.Build() as HeaderBlock; + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + result?.Text.Should().Be(text); + result?.BlockId.Should().BeNull(); + } + + [Fact] + public void Build_With_Text_And_BlockId_Returns_Valid_HeaderBlock() + { + // Arrange + var text = new TextObject { Text = "Header Text", Type = TextObjectType.PlainText }; + var builder = new HeaderBlockBuilder() + .WithText(text); + + // Act + var result = builder.Build() as HeaderBlock;; + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + result?.Text.Should().Be(text); + } + + [Fact] + public void Build_Without_Text_Throws_InvalidOperationException() + { + // Arrange + var builder = new HeaderBlockBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Text is required"); + } + + [Fact] + public void WithText_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new HeaderBlockBuilder(); + var text = new TextObject { Text = "Header Text", Type = TextObjectType.PlainText }; + + // Act + var result = builder.WithText(text); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void Multiple_Builds_With_Same_Builder_Return_Different_Instances() + { + // Arrange + var text = new TextObject { Text = "Header Text", Type = TextObjectType.PlainText }; + var builder = new HeaderBlockBuilder() + .WithText(text); + + // Act + var result1 = builder.Build() as HeaderBlock;; + var result2 = builder.Build() as HeaderBlock;; + + // Assert + result1.Should().NotBeSameAs(result2); + result1?.Text.Should().Be(result2?.Text); + } + + [Fact] + public void Build_With_Non_PlainText_TextObject_Still_Returns_Valid_HeaderBlock() + { + // Arrange + var text = new TextObject { Text = "*Bold Header*", Type = TextObjectType.Markdown }; + var builder = new HeaderBlockBuilder().WithText(text); + + // Act + var result = builder.Build() as HeaderBlock; + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + result?.Text.Should().Be(text); + result?.Text.Type.Should().Be(TextObjectType.Markdown); + } + } \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/ImageBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/ImageBlockBuilderTests.cs new file mode 100644 index 0000000..cbe0d18 --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/ImageBlockBuilderTests.cs @@ -0,0 +1,171 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; +using Hooki.Slack.Enums; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class ImageBlockBuilderTests +{ + private readonly string _validAltText = "Alt Text"; + + [Fact] + public void Build_With_Required_Properties_Returns_Valid_ImageBlock() + { + // Arrange + var builder = new ImageBlockBuilder() + .WithAltText(_validAltText) + .WithImageUrl("https://example.com/image.jpg"); + + // Act + var result = builder.Build() as ImageBlock; + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + result?.AltText.Should().Be(_validAltText); + result?.ImageUrl.Should().Be("https://example.com/image.jpg"); + result?.SlackFile.Should().BeNull(); + result?.Title.Should().BeNull(); + result?.BlockId.Should().BeNull(); + } + + [Fact] + public void Build_With_All_Properties_Returns_Valid_ImageBlock() + { + // Arrange + var title = new TextObject { Text = "Image Title", Type = TextObjectType.PlainText }; + var builder = new ImageBlockBuilder() + .WithAltText(_validAltText) + .WithImageUrl("https://example.com/image.jpg") + .WithTitle(title); + + // Act + var result = builder.Build() as ImageBlock; + + // Assert + result.Should().NotBeNull(); + result?.AltText.Should().Be(_validAltText); + result?.ImageUrl.Should().Be("https://example.com/image.jpg"); + result?.Title.Should().Be(title); + result?.SlackFile.Should().BeNull(); + } + + [Fact] + public void Build_With_SlackFile_Returns_Valid_ImageBlock() + { + // Arrange + var slackFile = new SlackFileObject { Id = "F123456" }; + var builder = new ImageBlockBuilder() + .WithAltText(_validAltText) + .WithSlackFile(slackFile); + + // Act + var result = builder.Build() as ImageBlock; + + // Assert + result.Should().NotBeNull(); + result?.AltText.Should().Be(_validAltText); + result?.SlackFile.Should().Be(slackFile); + result?.ImageUrl.Should().BeNull(); + } + + [Fact] + public void Build_Without_AltText_Throws_InvalidOperationException() + { + // Arrange + var builder = new ImageBlockBuilder() + .WithImageUrl("https://example.com/image.jpg"); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("AltText is required"); + } + + [Fact] + public void Build_Without_ImageUrl_And_SlackFile_Throws_InvalidOperationException() + { + // Arrange + var builder = new ImageBlockBuilder() + .WithAltText(_validAltText); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Either ImageUrl or SlackUrl need to be provided"); + } + + [Fact] + public void WithAltText_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ImageBlockBuilder(); + + // Act + var result = builder.WithAltText(_validAltText); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithImageUrl_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ImageBlockBuilder(); + + // Act + var result = builder.WithImageUrl("https://example.com/image.jpg"); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithSlackFile_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ImageBlockBuilder(); + var slackFile = new SlackFileObject { Id = "F123456" }; + + // Act + var result = builder.WithSlackFile(slackFile); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithTitle_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new ImageBlockBuilder(); + var title = new TextObject { Text = "Image Title", Type = TextObjectType.PlainText }; + + // Act + var result = builder.WithTitle(title); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void Multiple_Builds_With_Same_Builder_Return_Different_Instances() + { + // Arrange + var builder = new ImageBlockBuilder() + .WithAltText(_validAltText) + .WithImageUrl("https://example.com/image.jpg"); + + // Act + var result1 = builder.Build() as ImageBlock; + var result2 = builder.Build() as ImageBlock; + + // Assert + result1.Should().NotBeSameAs(result2); + result1?.AltText.Should().Be(result2?.AltText); + result1?.ImageUrl.Should().Be(result2?.ImageUrl); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/InputBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/InputBlockBuilderTests.cs new file mode 100644 index 0000000..5c17cea --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/InputBlockBuilderTests.cs @@ -0,0 +1,152 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class InputBlockBuilderTests +{ + private readonly TextObject _validLabel = new TextObject { Text = "Input Label", Type = TextObjectType.PlainText }; + private readonly Func _validElement = () => new PlainTextInputElement { ActionId = "input1" }; + + [Fact] + public void Build_With_Required_Properties_Returns_Valid_InputBlock() + { + // Arrange + var builder = new InputBlockBuilder() + .WithLabel(_validLabel) + .WithElement(_validElement); + + // Act + var result = builder.Build() as InputBlock; + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + result?.Label.Should().Be(_validLabel); + result?.Element.Should().BeOfType(); + result?.DispatchAction.Should().BeNull(); + result?.Hint.Should().BeNull(); + result?.Optional.Should().BeNull(); + result?.BlockId.Should().BeNull(); + } + + [Fact] + public void Build_With_All_Properties_Returns_Valid_InputBlock() + { + // Arrange + var hint = new TextObject { Text = "Input Hint", Type = TextObjectType.PlainText }; + var builder = new InputBlockBuilder() + .WithLabel(_validLabel) + .WithElement(_validElement) + .WithDispatchAction(true) + .WithHint(hint) + .WithOptional(true); + + // Act + var result = builder.Build() as InputBlock; + + // Assert + result.Should().NotBeNull(); + result?.Label.Should().Be(_validLabel); + result?.Element.Should().BeOfType(); + result?.DispatchAction.Should().BeTrue(); + result?.Hint.Should().Be(hint); + result?.Optional.Should().BeTrue(); + } + + [Fact] + public void Build_Without_Label_Throws_InvalidOperationException() + { + // Arrange + var builder = new InputBlockBuilder() + .WithElement(_validElement); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Label must have a value"); + } + + [Fact] + public void Build_Without_Element_Throws_InvalidOperationException() + { + // Arrange + var builder = new InputBlockBuilder() + .WithLabel(_validLabel); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Element must have a value"); + } + + [Fact] + public void WithLabel_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new InputBlockBuilder(); + + // Act + var result = builder.WithLabel(_validLabel); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithElement_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new InputBlockBuilder(); + + // Act + var result = builder.WithElement(_validElement); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithDispatchAction_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new InputBlockBuilder(); + + // Act + var result = builder.WithDispatchAction(true); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithHint_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new InputBlockBuilder(); + var hint = new TextObject { Text = "Input Hint", Type = TextObjectType.PlainText }; + + // Act + var result = builder.WithHint(hint); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithOptional_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new InputBlockBuilder(); + + // Act + var result = builder.WithOptional(true); + + // Assert + result.Should().BeSameAs(builder); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/RichTextBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/RichTextBlockBuilderTests.cs new file mode 100644 index 0000000..3567fe8 --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/RichTextBlockBuilderTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.RichTextElements; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class RichTextBlockBuilderTests +{ + [Fact] + public void Build_With_Single_Element_Returns_Valid_RichTextBlock() + { + // Arrange + var builder = new RichTextBlockBuilder() + .AddElement(() => new RichTextSection { Elements = new[] { new TextElement { Text = "Hello, World!" } } }); + + // Act + var result = builder.Build() as RichTextBlock; + + // Assert + result.Should().NotBeNull(); + result!.Elements.Should().HaveCount(1); + result.Elements.First().Should().BeOfType(); + var section = result.Elements.First() as RichTextSection; + section!.Elements.Should().HaveCount(1); + section.Elements.First().Should().BeOfType(); + (section.Elements.First() as TextElement)!.Text.Should().Be("Hello, World!"); + } + + [Fact] + public void Build_With_Multiple_Elements_Returns_Valid_RichTextBlock() + { + // Arrange + var builder = new RichTextBlockBuilder() + .AddElement(() => new RichTextSection { Elements = new[] { new TextElement { Text = "Section 1" } } }) + .AddElement(() => new RichTextList + { + Style = RichTextListStyleType.Bullet, + Elements = new[] { new TextElement { Text = "Item 1" } } + }); + + // Act + var result = builder.Build() as RichTextBlock; + + // Assert + result.Should().NotBeNull(); + result!.Elements.Should().HaveCount(2); + result.Elements[0].Should().BeOfType(); + result.Elements[1].Should().BeOfType(); + } + + [Fact] + public void Build_With_No_Elements_Throws_InvalidOperationException() + { + // Arrange + var builder = new RichTextBlockBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("At least one element is required for a RichTextBlock."); + } + + [Fact] + public void Build_Inherits_BlockId_From_Base_Builder() + { + // Arrange + var builder = new RichTextBlockBuilder() + .AddElement(() => new RichTextSection { Elements = new[] { new TextElement { Text = "Test" } } }); + + // Act + var result = builder.Build() as RichTextBlock; + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public void AddElement_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new RichTextBlockBuilder(); + + // Act + var result = builder.AddElement(() => new RichTextSection { Elements = new[] { new TextElement { Text = "Test" } } }); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void Build_With_Different_RichTextBlockElement_Types() + { + // Arrange + var builder = new RichTextBlockBuilder() + .AddElement(() => new RichTextSection { Elements = new[] { new TextElement { Text = "Section" } } }) + .AddElement(() => new RichTextList { Style = RichTextListStyleType.Bullet, Elements = new[] { new TextElement { Text = "List Item" } } }) + .AddElement(() => new RichTextQuote { Elements = new[] { new TextElement { Text = "Quote" } } }) + .AddElement(() => new RichTextPreformatted { Elements = new[] { new TextElement { Text = "Code" } } }); + + // Act + var result = builder.Build() as RichTextBlock; + + // Assert + result.Should().NotBeNull(); + result!.Elements.Should().HaveCount(4); + result.Elements[0].Should().BeOfType(); + result.Elements[1].Should().BeOfType(); + result.Elements[2].Should().BeOfType(); + result.Elements[3].Should().BeOfType(); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/SectionBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/SectionBlockBuilderTests.cs new file mode 100644 index 0000000..76b9c2e --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/SectionBlockBuilderTests.cs @@ -0,0 +1,198 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class SectionBlockBuilderTests +{ + [Fact] + public void Build_With_Text_Returns_Valid_SectionBlock() + { + // Arrange + var builder = new SectionBlockBuilder() + .WithText(t => t.WithText("Section Text").WithType(TextObjectType.PlainText)); + + // Act + var result = builder.Build() as SectionBlock; + + // Assert + result.Should().NotBeNull(); + result!.Text.Should().NotBeNull(); + result.Text!.Text.Should().Be("Section Text"); + result.Text.Type.Should().Be(TextObjectType.PlainText); + result.Fields.Should().BeNull(); + result.Accessory.Should().BeNull(); + result.Expand.Should().BeNull(); + } + + [Fact] + public void Build_With_Fields_Returns_Valid_SectionBlock() + { + // Arrange + var builder = new SectionBlockBuilder() + .AddField(t => t.WithText("Field 1").WithType(TextObjectType.PlainText)) + .AddField(t => t.WithText("Field 2").WithType(TextObjectType.Markdown)); + + // Act + var result = builder.Build() as SectionBlock; + + // Assert + result.Should().NotBeNull(); + result!.Text.Should().BeNull(); + result.Fields.Should().NotBeNull(); + result.Fields!.Length.Should().Be(2); + result.Fields[0].Text.Should().Be("Field 1"); + result.Fields[0].Type.Should().Be(TextObjectType.PlainText); + result.Fields[1].Text.Should().Be("Field 2"); + result.Fields[1].Type.Should().Be(TextObjectType.Markdown); + } + + [Fact] + public void Build_With_Accessory_Returns_Valid_SectionBlock() + { + // Arrange + var builder = new SectionBlockBuilder() + .WithText(t => t.WithText("Section Text").WithType(TextObjectType.PlainText)) + .WithAccessory(() => new ButtonElement { Text = new TextObject { Text = "Click me", Type = TextObjectType.PlainText } }); + + // Act + var result = builder.Build() as SectionBlock; + + // Assert + result.Should().NotBeNull(); + result!.Text.Should().NotBeNull(); + result.Accessory.Should().NotBeNull(); + result.Accessory.Should().BeOfType(); + (result.Accessory as ButtonElement)!.Text.Text.Should().Be("Click me"); + } + + [Fact] + public void Build_With_Expand_Returns_Valid_SectionBlock() + { + // Arrange + var builder = new SectionBlockBuilder() + .WithText(t => t.WithText("Section Text").WithType(TextObjectType.PlainText)) + .WithExpand(true); + + // Act + var result = builder.Build() as SectionBlock; + + // Assert + result.Should().NotBeNull(); + result!.Text.Should().NotBeNull(); + result.Expand.Should().BeTrue(); + } + + [Fact] + public void Build_Without_Text_And_Fields_Throws_InvalidOperationException() + { + // Arrange + var builder = new SectionBlockBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Either text or at least one field is required for a SectionBlock."); + } + + [Fact] + public void Build_With_BlockId_Returns_Valid_SectionBlock() + { + // Arrange + var builder = new SectionBlockBuilder() + .WithText(t => t.WithText("Section Text").WithType(TextObjectType.PlainText)); + + // Act + var result = builder.Build() as SectionBlock; + + // Assert + result.Should().NotBeNull(); + } + + [Fact] + public void WithText_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new SectionBlockBuilder(); + + // Act + var result = builder.WithText(t => t.WithText("Section Text").WithType(TextObjectType.PlainText)); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void AddField_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new SectionBlockBuilder(); + + // Act + var result = builder.AddField(t => t.WithText("Field Text").WithType(TextObjectType.PlainText)); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithAccessory_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new SectionBlockBuilder(); + + // Act + var result = builder.WithAccessory(() => new ButtonElement { Text = new TextObject { Text = "Click me", Type = TextObjectType.PlainText } }); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithExpand_Returns_Same_Builder_Instance() + { + // Arrange + var builder = new SectionBlockBuilder(); + + // Act + var result = builder.WithExpand(true); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void Multiple_Builds_With_Same_Builder_Return_Different_Instances() + { + // Arrange + var builder = new SectionBlockBuilder() + .WithText(t => t.WithText("Section Text").WithType(TextObjectType.PlainText)); + + // Act + var result1 = builder.Build() as SectionBlock; + var result2 = builder.Build() as SectionBlock; + + // Assert + result1.Should().NotBeSameAs(result2); + result1.Should().NotBeNull(); + result2.Should().NotBeNull(); + + result1!.Text.Should().NotBeNull(); + result2!.Text.Should().NotBeNull(); + + result1.Text!.Text.Should().Be("Section Text"); + result1.Text.Type.Should().Be(TextObjectType.PlainText); + + result2.Text!.Text.Should().Be("Section Text"); + result2.Text.Type.Should().Be(TextObjectType.PlainText); + + // Ensure that modifying one TextObject doesn't affect the other + var originalText = result1.Text.Text; + result1.Text = new TextObject { Text = "Modified Text", Type = TextObjectType.PlainText }; + result2.Text!.Text.Should().Be(originalText); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/SlackWebhookPayloadBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/SlackWebhookPayloadBuilderTests.cs new file mode 100644 index 0000000..253af83 --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/SlackWebhookPayloadBuilderTests.cs @@ -0,0 +1,184 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; +using Hooki.Slack.Models.RichTextElements; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class SlackWebhookPayloadBuilderTests +{ + [Fact] + public void Build_With_Minimum_Required_Properties_Returns_Correct_SlackWebhookPayload() + { + // Arrange + var builder = new SlackWebhookPayloadBuilder() + .AddSectionBlock(b => + b.WithText(t => t.WithText("Test Text").WithType(TextObjectType.PlainText))); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Blocks.Should().HaveCount(1); + result.Blocks[0].Should().BeOfType(); + (result.Blocks[0] as SectionBlock)!.Text?.Text.Should().Be("Test Text"); + } + + [Fact] +public void Build_With_All_Possible_Blocks_Returns_Correct_SlackWebhookPayload() +{ + // Arrange + var builder = new SlackWebhookPayloadBuilder() + .AddSectionBlock(b => + b.WithText(t => t.WithText("Test Text").WithType(TextObjectType.PlainText)) + .AddField(t => t.WithText("Test Field").WithType(TextObjectType.PlainText))) + .AddImageBlock(b => + b.WithImageUrl("http://example.com/image.jpg") + .WithAltText("Image Text")) + .AddActionBlock(a => + a.AddElement(() => new ButtonElement + { Text = new TextObject { Text = "Button Text", Type = TextObjectType.PlainText } })) + .AddContextBlock(c => + { + c.AddElement(() => new ImageElement + { AltText = "Image Text", ImageUrl = "http://example.com/image.jpg" }); + c.AddElement(() => new TextObject { Type = TextObjectType.PlainText, Text = "Text" }); + }) + .AddFileBlock(f => + f.WithExternalId("external-unique-id") + .WithSource("source")) + .AddHeaderBlock(h => + h.WithText(new TextObject { Type = TextObjectType.PlainText, Text = "Header Text" })) + .AddInputBlock(i => + i.WithLabel(new TextObject { Type = TextObjectType.PlainText, Text = "Label" }) + .WithElement(() => new UrlInputElement + { + Placeholder = new TextObject { Type = TextObjectType.PlainText, Text = "Text" }, + InitialValue = "InitialValue" + })) + .AddRichTextBlock(r => + r.AddElement(() => new RichTextSection + { Elements = new[] { new ChannelElement { ChannelId = "123" } } })) + .AddVideoBlock(v => + v.WithAltText("Alt Text") + .WithTitle(to => to.WithType(TextObjectType.PlainText).WithText("Text")) + .WithThumbnailUrl("http://example.com/image.jpg") + .WithVideoUrl("http://example.com/image.jpg") + .WithDescription(to => to.WithType(TextObjectType.PlainText).WithText("Text"))); + + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Blocks.Should().HaveCount(9); + result.Blocks[0].Should().BeOfType(); + result.Blocks[1].Should().BeOfType(); + result.Blocks[2].Should().BeOfType(); + result.Blocks[3].Should().BeOfType(); + result.Blocks[4].Should().BeOfType(); + result.Blocks[5].Should().BeOfType(); + result.Blocks[6].Should().BeOfType(); + result.Blocks[7].Should().BeOfType(); + result.Blocks[8].Should().BeOfType(); + + // SectionBlock assertions + var sectionBlock = result.Blocks[0] as SectionBlock; + sectionBlock.Should().NotBeNull(); + sectionBlock!.Text?.Text.Should().Be("Test Text"); + sectionBlock.Text?.Type.Should().Be(TextObjectType.PlainText); + sectionBlock.Fields.Should().HaveCount(1); + sectionBlock.Fields![0].Text.Should().Be("Test Field"); + sectionBlock.Fields![0].Type.Should().Be(TextObjectType.PlainText); + + // ImageBlock assertions + var imageBlock = result.Blocks[1] as ImageBlock; + imageBlock.Should().NotBeNull(); + imageBlock!.ImageUrl.Should().Be("http://example.com/image.jpg"); + imageBlock.AltText.Should().Be("Image Text"); + + // ActionBlock assertions + var actionBlock = result.Blocks[2] as ActionBlock; + actionBlock.Should().NotBeNull(); + actionBlock!.Elements.Should().HaveCount(1); + var buttonElement = actionBlock.Elements[0] as ButtonElement; + buttonElement.Should().NotBeNull(); + buttonElement!.Text.Text.Should().Be("Button Text"); + buttonElement.Text.Type.Should().Be(TextObjectType.PlainText); + + // ContextBlock assertions + var contextBlock = result.Blocks[3] as ContextBlock; + contextBlock.Should().NotBeNull(); + contextBlock!.Elements.Should().HaveCount(2); + var imageElement = contextBlock.Elements[0] as ImageElement; + imageElement.Should().NotBeNull(); + imageElement!.AltText.Should().Be("Image Text"); + imageElement.ImageUrl.Should().Be("http://example.com/image.jpg"); + var textElement = contextBlock.Elements[1] as TextObject; + textElement.Should().NotBeNull(); + textElement!.Text.Should().Be("Text"); + textElement.Type.Should().Be(TextObjectType.PlainText); + + // FileBlock assertions + var fileBlock = result.Blocks[4] as FileBlock; + fileBlock.Should().NotBeNull(); + fileBlock!.ExternalId.Should().Be("external-unique-id"); + fileBlock.Source.Should().Be("source"); + + // HeaderBlock assertions + var headerBlock = result.Blocks[5] as HeaderBlock; + headerBlock.Should().NotBeNull(); + headerBlock!.Text.Text.Should().Be("Header Text"); + headerBlock.Text.Type.Should().Be(TextObjectType.PlainText); + + // InputBlock assertions + var inputBlock = result.Blocks[6] as InputBlock; + inputBlock.Should().NotBeNull(); + inputBlock!.Label.Text.Should().Be("Label"); + inputBlock.Label.Type.Should().Be(TextObjectType.PlainText); + var urlInputElement = inputBlock.Element as UrlInputElement; + urlInputElement.Should().NotBeNull(); + urlInputElement!.Placeholder!.Text.Should().Be("Text"); + urlInputElement.Placeholder.Type.Should().Be(TextObjectType.PlainText); + urlInputElement.InitialValue.Should().Be("InitialValue"); + + // RichTextBlock assertions + var richTextBlock = result.Blocks[7] as RichTextBlock; + richTextBlock.Should().NotBeNull(); + richTextBlock!.Elements.Should().HaveCount(1); + var richTextSection = richTextBlock.Elements[0] as RichTextSection; + richTextSection.Should().NotBeNull(); + richTextSection!.Elements.Should().HaveCount(1); + var channelElement = richTextSection.Elements[0] as ChannelElement; + channelElement.Should().NotBeNull(); + channelElement!.ChannelId.Should().Be("123"); + + // VideoBlock assertions + var videoBlock = result.Blocks[8] as VideoBlock; + videoBlock.Should().NotBeNull(); + videoBlock!.AltText.Should().Be("Alt Text"); + videoBlock.Title!.Text.Should().Be("Text"); + videoBlock.Title.Type.Should().Be(TextObjectType.PlainText); + videoBlock.ThumbnailUrl.Should().Be("http://example.com/image.jpg"); + videoBlock.VideoUrl.Should().Be("http://example.com/image.jpg"); + videoBlock.Description!.Text.Should().Be("Text"); + videoBlock.Description.Type.Should().Be(TextObjectType.PlainText); +} + + [Fact] + public void Build_With_No_Blocks_Throws_InvalidOperationException() + { + // Arrange + var builder = new SlackWebhookPayloadBuilder(); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("At least one block is required."); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/TextObjectBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/TextObjectBuilderTests.cs new file mode 100644 index 0000000..4cd6552 --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/TextObjectBuilderTests.cs @@ -0,0 +1,166 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class TextObjectBuilderTests +{ + [Fact] + public void Build_With_Required_Properties_Returns_Valid_TextObject() + { + // Arrange + var builder = new TextObjectBuilder() + .WithType(TextObjectType.PlainText) + .WithText("Hello, World!"); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Type.Should().Be(TextObjectType.PlainText); + result.Text.Should().Be("Hello, World!"); + result.Emoji.Should().BeNull(); + result.Verbatim.Should().BeNull(); + } + + [Fact] + public void Build_With_All_Properties_For_PlainText_Returns_ValidTextObject() + { + // Arrange + var builder = new TextObjectBuilder() + .WithType(TextObjectType.PlainText) + .WithText("Hello, World!") + .WithEmoji(true); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Type.Should().Be(TextObjectType.PlainText); + result.Text.Should().Be("Hello, World!"); + result.Emoji.Should().BeTrue(); + result.Verbatim.Should().BeNull(); + } + + [Fact] + public void Build_With_All_Properties_For_Markdown_Returns_ValidTextObject() + { + // Arrange + var builder = new TextObjectBuilder() + .WithType(TextObjectType.Markdown) + .WithText("*Hello, World!*") + .WithVerbatim(true); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Type.Should().Be(TextObjectType.Markdown); + result.Text.Should().Be("*Hello, World!*"); + result.Emoji.Should().BeNull(); + result.Verbatim.Should().BeTrue(); + } + + [Fact] + public void Build_Without_Type_Throws_InvalidOperationException() + { + // Arrange + var builder = new TextObjectBuilder() + .WithText("Hello, World!"); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Type is required for a TextObject."); + } + + [Fact] + public void Build_Without_Text_Throws_InvalidOperationException() + { + // Arrange + var builder = new TextObjectBuilder() + .WithType(TextObjectType.PlainText); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Text is required for a TextObject."); + } + + [Fact] + public void Build_With_Empty_Text_Throws_InvalidOperationException() + { + // Arrange + var builder = new TextObjectBuilder() + .WithType(TextObjectType.PlainText) + .WithText(""); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Text is required for a TextObject."); + } + + [Fact] + public void Build_With_Emoji_For_Markdown_Throws_InvalidOperationException() + { + // Arrange + var builder = new TextObjectBuilder() + .WithType(TextObjectType.Markdown) + .WithText("*Hello, World!*") + .WithEmoji(true); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Emoji can only be set when Type is PlainText."); + } + + [Fact] + public void Build_With_Verbatim_For_PlainText_Throws_InvalidOperationException() + { + // Arrange + var builder = new TextObjectBuilder() + .WithType(TextObjectType.PlainText) + .WithText("Hello, World!") + .WithVerbatim(true); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Verbatim can only be set when Type is Markdown."); + } + + [Theory] + [InlineData(TextObjectType.PlainText, true, null)] + [InlineData(TextObjectType.PlainText, false, null)] + [InlineData(TextObjectType.Markdown, null, true)] + [InlineData(TextObjectType.Markdown, null, false)] + public void Build_With_Valid_Combinations_Returns_Valid_TextObject(TextObjectType type, bool? emoji, bool? verbatim) + { + // Arrange + var builder = new TextObjectBuilder() + .WithType(type) + .WithText("Test Text"); + + if (emoji.HasValue) + builder.WithEmoji(emoji.Value); + + if (verbatim.HasValue) + builder.WithVerbatim(verbatim.Value); + + // Act + var result = builder.Build(); + + // Assert + result.Should().NotBeNull(); + result.Type.Should().Be(type); + result.Text.Should().Be("Test Text"); + result.Emoji.Should().Be(emoji); + result.Verbatim.Should().Be(verbatim); + } +} \ No newline at end of file diff --git a/src/Hooki.UnitTests/Slack/BuilderTests/VideoBlockBuilderTests.cs b/src/Hooki.UnitTests/Slack/BuilderTests/VideoBlockBuilderTests.cs new file mode 100644 index 0000000..732df07 --- /dev/null +++ b/src/Hooki.UnitTests/Slack/BuilderTests/VideoBlockBuilderTests.cs @@ -0,0 +1,170 @@ +using FluentAssertions; +using Hooki.Slack.Builders; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.UnitTests.Slack.BuilderTests; + +public class VideoBlockBuilderTests + { + [Fact] + public void Build_WithAllRequiredProperties_ReturnsValidVideoBlock() + { + // Arrange + var builder = new VideoBlockBuilder() + .WithAltText("Alt Text") + .WithDescription(d => d.WithText("Description").WithType(TextObjectType.PlainText)) + .WithThumbnailUrl("https://example.com/thumbnail.jpg") + .WithVideoUrl("https://example.com/video.mp4"); + + // Act + var result = builder.Build() as VideoBlock; + + // Assert + result.Should().NotBeNull(); + result!.AltText.Should().Be("Alt Text"); + result.Description.Should().NotBeNull(); + result.Description!.Text.Should().Be("Description"); + result.Description.Type.Should().Be(TextObjectType.PlainText); + result.ThumbnailUrl.Should().Be("https://example.com/thumbnail.jpg"); + result.VideoUrl.Should().Be("https://example.com/video.mp4"); + } + + [Fact] + public void Build_WithAllProperties_ReturnsValidVideoBlock() + { + // Arrange + var builder = new VideoBlockBuilder() + .WithAltText("Alt Text") + .WithAuthorName("Author") + .WithDescription(d => d.WithText("Description").WithType(TextObjectType.PlainText)) + .WithProviderIconUrl("https://example.com/icon.png") + .WithProviderName("Provider") + .WithTitle(t => t.WithText("Title").WithType(TextObjectType.PlainText)) + .WithTitleUrl("https://example.com/title") + .WithThumbnailUrl("https://example.com/thumbnail.jpg") + .WithVideoUrl("https://example.com/video.mp4"); + + // Act + var result = builder.Build() as VideoBlock; + + // Assert + result.Should().NotBeNull(); + result?.AltText.Should().Be("Alt Text"); + result?.AuthorName.Should().Be("Author"); + result?.Description.Should().NotBeNull(); + result?.Description!.Text.Should().Be("Description"); + result?.ProviderIconUrl.Should().Be("https://example.com/icon.png"); + result?.ProviderName.Should().Be("Provider"); + result?.Title.Should().NotBeNull(); + result?.Title?.Text.Should().Be("Title"); + result?.TitleUrl.Should().Be("https://example.com/title"); + result?.ThumbnailUrl.Should().Be("https://example.com/thumbnail.jpg"); + result?.VideoUrl.Should().Be("https://example.com/video.mp4"); + } + + [Fact] + public void Build_WithoutAltText_ThrowsInvalidOperationException() + { + // Arrange + var builder = new VideoBlockBuilder() + .WithDescription(d => d.WithText("Description").WithType(TextObjectType.PlainText)) + .WithThumbnailUrl("https://example.com/thumbnail.jpg") + .WithVideoUrl("https://example.com/video.mp4"); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("AltText is required for a VideoBlock."); + } + + [Fact] + public void Build_WithoutDescription_ThrowsInvalidOperationException() + { + // Arrange + var builder = new VideoBlockBuilder() + .WithAltText("Alt Text") + .WithThumbnailUrl("https://example.com/thumbnail.jpg") + .WithVideoUrl("https://example.com/video.mp4"); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Description is required for a VideoBlock."); + } + + [Fact] + public void Build_WithMarkdownDescription_ThrowsInvalidOperationException() + { + // Arrange + var builder = new VideoBlockBuilder() + .WithAltText("Alt Text") + .WithDescription(d => d.WithText("Description").WithType(TextObjectType.Markdown)) + .WithThumbnailUrl("https://example.com/thumbnail.jpg") + .WithVideoUrl("https://example.com/video.mp4"); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Description must be of type PlainText."); + } + + [Fact] + public void Build_WithMarkdownTitle_ThrowsInvalidOperationException() + { + // Arrange + var builder = new VideoBlockBuilder() + .WithAltText("Alt Text") + .WithDescription(d => d.WithText("Description").WithType(TextObjectType.PlainText)) + .WithTitle(t => t.WithText("Title").WithType(TextObjectType.Markdown)) + .WithThumbnailUrl("https://example.com/thumbnail.jpg") + .WithVideoUrl("https://example.com/video.mp4"); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("Title must be of type PlainText."); + } + + [Fact] + public void Build_WithoutThumbnailUrl_ThrowsInvalidOperationException() + { + // Arrange + var builder = new VideoBlockBuilder() + .WithAltText("Alt Text") + .WithDescription(d => d.WithText("Description").WithType(TextObjectType.PlainText)) + .WithVideoUrl("https://example.com/video.mp4"); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("ThumbnailUrl is required for a VideoBlock."); + } + + [Fact] + public void Build_WithoutVideoUrl_ThrowsInvalidOperationException() + { + // Arrange + var builder = new VideoBlockBuilder() + .WithAltText("Alt Text") + .WithDescription(d => d.WithText("Description").WithType(TextObjectType.PlainText)) + .WithThumbnailUrl("https://example.com/thumbnail.jpg"); + + // Act & Assert + builder.Invoking(b => b.Build()) + .Should().Throw() + .WithMessage("VideoUrl is required for a VideoBlock."); + } + + [Fact] + public void WithTitleUrl_WithNonHttpsUrl_ThrowsArgumentException() + { + // Arrange + var builder = new VideoBlockBuilder(); + + // Act & Assert + builder.Invoking(b => b.WithTitleUrl("http://example.com")) + .Should().Throw() + .WithMessage("Title URL must start with 'https://'*"); + } + } \ No newline at end of file diff --git a/src/Hooki/Discord/Builders/AllowedMentionBuilder.cs b/src/Hooki/Discord/Builders/AllowedMentionBuilder.cs new file mode 100644 index 0000000..1624e0d --- /dev/null +++ b/src/Hooki/Discord/Builders/AllowedMentionBuilder.cs @@ -0,0 +1,50 @@ +using Hooki.Discord.Enums; +using Hooki.Discord.Models.BuildingBlocks; + +namespace Hooki.Discord.Builders; + +public class AllowedMentionBuilder +{ + private List? _parse; + private List? _roles; + private List? _users; + private bool? _repliedUser; + + public AllowedMentionBuilder AddParse(AllowedMentionTypes type) + { + _parse ??= []; + _parse.Add(type); + return this; + } + + public AllowedMentionBuilder AddRole(string roleId) + { + _roles ??= []; + _roles.Add(roleId); + return this; + } + + public AllowedMentionBuilder AddUser(string userId) + { + _users ??= []; + _users.Add(userId); + return this; + } + + public AllowedMentionBuilder WithRepliedUser(bool repliedUser) + { + _repliedUser = repliedUser; + return this; + } + + public AllowedMention Build() + { + return new AllowedMention + { + Parse = _parse, + Roles = _roles, + Users = _users, + RepliedUser = _repliedUser + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Discord/Builders/AttachmentBuilder.cs b/src/Hooki/Discord/Builders/AttachmentBuilder.cs new file mode 100644 index 0000000..6a9ce45 --- /dev/null +++ b/src/Hooki/Discord/Builders/AttachmentBuilder.cs @@ -0,0 +1,145 @@ +using System.Text.RegularExpressions; +using Hooki.Discord.Models.BuildingBlocks; + +namespace Hooki.Discord.Builders; + +public partial class AttachmentBuilder +{ + private string? _id; + private string? _fileName; + private string? _title; + private string? _description; + private string? _contentType; + private int? _size; + private string? _url; + private string? _proxyUrl; + private int? _height; + private int? _width; + private bool? _ephemeral; + private float? _durationSecs; + private string? _waveform; + private int? _flags; + private byte[]? _content; + + [GeneratedRegex(@"^[\w-\.]+$")] + private static partial Regex MyRegex(); + + public AttachmentBuilder WithId(string id) + { + _id = id; + return this; + } + + public AttachmentBuilder WithFileName(string fileName) + { + if (!MyRegex().IsMatch(fileName)) + throw new ArgumentException("FileName must be ASCII alphanumeric with underscores, dashes, or dots."); + _fileName = fileName; + return this; + } + + public AttachmentBuilder WithTitle(string? title) + { + _title = title; + return this; + } + + public AttachmentBuilder WithDescription(string? description) + { + _description = description; + return this; + } + + public AttachmentBuilder WithContentType(string? contentType) + { + _contentType = contentType; + return this; + } + + public AttachmentBuilder WithSize(int? size) + { + _size = size; + return this; + } + + public AttachmentBuilder WithUrl(string? url) + { + _url = url; + return this; + } + + public AttachmentBuilder WithProxyUrl(string? proxyUrl) + { + _proxyUrl = proxyUrl; + return this; + } + + public AttachmentBuilder WithHeight(int? height) + { + _height = height; + return this; + } + + public AttachmentBuilder WithWidth(int? width) + { + _width = width; + return this; + } + + public AttachmentBuilder WithEphemeral(bool? ephemeral) + { + _ephemeral = ephemeral; + return this; + } + + public AttachmentBuilder WithDurationSecs(float? durationSecs) + { + _durationSecs = durationSecs; + return this; + } + + public AttachmentBuilder WithWaveform(string? waveform) + { + _waveform = waveform; + return this; + } + + public AttachmentBuilder WithFlags(int? flags) + { + _flags = flags; + return this; + } + + public AttachmentBuilder WithContent(byte[]? content) + { + _content = content; + return this; + } + + public Attachment Build() + { + if (string.IsNullOrWhiteSpace(_id)) + throw new InvalidOperationException("Id is required for Attachment."); + if (string.IsNullOrWhiteSpace(_fileName)) + throw new InvalidOperationException("FileName is required for Attachment."); + + return new Attachment + { + Id = _id, + FileName = _fileName, + Title = _title, + Description = _description, + ContentType = _contentType, + Size = _size, + Url = _url, + ProxyUrl = _proxyUrl, + Height = _height, + Width = _width, + Ephemeral = _ephemeral, + DurationSecs = _durationSecs, + Waveform = _waveform, + Flags = _flags, + Content = _content + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Discord/Builders/DiscordWebhookPayloadBuilder.cs b/src/Hooki/Discord/Builders/DiscordWebhookPayloadBuilder.cs new file mode 100644 index 0000000..c3a5a7c --- /dev/null +++ b/src/Hooki/Discord/Builders/DiscordWebhookPayloadBuilder.cs @@ -0,0 +1,139 @@ +using Hooki.Discord.Models; +using Hooki.Discord.Models.BuildingBlocks; + +namespace Hooki.Discord.Builders; + +public class DiscordWebhookPayloadBuilder +{ + private string? _content; + private string? _username; + private string? _avatarUrl; + private bool? _tts; + private List? _embeds; + private AllowedMention? _allowedMentions; + private List? _components; + private List? _files; + private string? _payloadJson; + private List? _attachments; + private int? _flags; + private string? _threadName; + private List? _appliedTags; + private PollCreateRequest? _poll; + + public DiscordWebhookPayloadBuilder WithContent(string content) + { + _content = content; + return this; + } + + public DiscordWebhookPayloadBuilder WithUsername(string username) + { + _username = username; + return this; + } + + public DiscordWebhookPayloadBuilder WithAvatarUrl(string avatarUrl) + { + _avatarUrl = avatarUrl; + return this; + } + + public DiscordWebhookPayloadBuilder WithTts(bool tts) + { + _tts = tts; + return this; + } + + public DiscordWebhookPayloadBuilder AddEmbed(Action embedAction) + { + var embedBuilder = new EmbedBuilder(); + embedAction(embedBuilder); + _embeds ??= new List(); + _embeds.Add(embedBuilder.Build()); + return this; + } + + public DiscordWebhookPayloadBuilder WithAllowedMentions(Action allowedMentionAction) + { + var allowedMentionBuilder = new AllowedMentionBuilder(); + allowedMentionAction(allowedMentionBuilder); + _allowedMentions = allowedMentionBuilder.Build(); + return this; + } + + public DiscordWebhookPayloadBuilder AddComponent(object component) + { + _components ??= []; + _components.Add(component); + return this; + } + + public DiscordWebhookPayloadBuilder AddFile(FileContent file) + { + _files ??= new List(); + _files.Add(file); + return this; + } + + public DiscordWebhookPayloadBuilder WithPayloadJson(string payloadJson) + { + _payloadJson = payloadJson; + return this; + } + + public DiscordWebhookPayloadBuilder AddAttachment(Attachment attachment) + { + _attachments ??= []; + _attachments.Add(attachment); + return this; + } + + public DiscordWebhookPayloadBuilder WithFlags(int flags) + { + _flags = flags; + return this; + } + + public DiscordWebhookPayloadBuilder WithThreadName(string threadName) + { + _threadName = threadName; + return this; + } + + public DiscordWebhookPayloadBuilder AddAppliedTag(string tag) + { + _appliedTags ??= new List(); + _appliedTags.Add(tag); + return this; + } + + public DiscordWebhookPayloadBuilder WithPoll(Action pollAction) + { + var pollCreateRequestBuilder = new PollCreateRequestBuilder(); + pollAction(pollCreateRequestBuilder); + + _poll = pollCreateRequestBuilder.Build(); + return this; + } + + public DiscordWebhookPayload Build() + { + return new DiscordWebhookPayload + { + Content = _content, + Username = _username, + AvatarUrl = _avatarUrl, + Tts = _tts, + Embeds = _embeds, + AllowedMentions = _allowedMentions, + Components = _components, + Files = _files, + PayloadJson = _payloadJson, + Attachments = _attachments, + Flags = _flags, + ThreadName = _threadName, + AppliedTags = _appliedTags, + Poll = _poll + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Discord/Builders/EmbedBuilder.cs b/src/Hooki/Discord/Builders/EmbedBuilder.cs new file mode 100644 index 0000000..a41e00a --- /dev/null +++ b/src/Hooki/Discord/Builders/EmbedBuilder.cs @@ -0,0 +1,95 @@ +using Hooki.Discord.Models.BuildingBlocks; + +namespace Hooki.Discord.Builders; + +public class EmbedBuilder +{ + private string? _title; + private string? _description; + private string? _url; + private DateTimeOffset? _timestamp; + private int? _color; + private EmbedFooter? _footer; + private EmbedImage? _image; + private EmbedThumbnail? _thumbnail; + private EmbedAuthor? _author; + private List? _fields; + + public EmbedBuilder WithTitle(string title) + { + _title = title; + return this; + } + + public EmbedBuilder WithDescription(string description) + { + _description = description; + return this; + } + + public EmbedBuilder WithUrl(string url) + { + _url = url; + return this; + } + + public EmbedBuilder WithTimestamp(DateTimeOffset timestamp) + { + _timestamp = timestamp; + return this; + } + + public EmbedBuilder WithColor(int color) + { + _color = color; + return this; + } + + public EmbedBuilder WithFooter(string text, string? iconUrl = null) + { + _footer = new EmbedFooter { Text = text, IconUrl = iconUrl }; + return this; + } + + public EmbedBuilder WithImage(string url) + { + _image = new EmbedImage { Url = url }; + return this; + } + + public EmbedBuilder WithThumbnail(string url) + { + _thumbnail = new EmbedThumbnail { Url = url }; + return this; + } + + public EmbedBuilder WithAuthor(string name, string? url = null, string? iconUrl = null) + { + _author = new EmbedAuthor { Name = name, Url = url, IconUrl = iconUrl }; + return this; + } + + public EmbedBuilder AddField(string name, string value, bool? inline = null) + { + _fields ??= []; + _fields.Add(new EmbedField { Name = name, Value = value, Inline = inline }); + return this; + } + + public Embed Build() + { + return new Embed + { + Title = _title, + Description = _description, + Url = _url, + Timestamp = _timestamp, + Color = _color, + Footer = _footer, + Image = _image, + Thumbnail = _thumbnail, + Author = _author, + Fields = _fields + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Discord/Builders/FileContentBuilder.cs b/src/Hooki/Discord/Builders/FileContentBuilder.cs new file mode 100644 index 0000000..4b3e7fe --- /dev/null +++ b/src/Hooki/Discord/Builders/FileContentBuilder.cs @@ -0,0 +1,61 @@ +using System.Text.RegularExpressions; +using Hooki.Discord.Models.BuildingBlocks; + +namespace Hooki.Discord.Builders; + +public partial class FileContentBuilder +{ + private string? _snowflakeId; + private string? _fileName; + private byte[]? _fileContents; + private string? _contentType; + + [GeneratedRegex(@"^[\w-\.]+$")] + private static partial Regex FileNameRegex(); + + public FileContentBuilder WithSnowflakeId(string snowflakeId) + { + _snowflakeId = snowflakeId; + return this; + } + + public FileContentBuilder WithFileName(string fileName) + { + if (!FileNameRegex().IsMatch(fileName)) + throw new ArgumentException("FileName must be ASCII alphanumeric with underscores, dashes, or dots."); + _fileName = fileName; + return this; + } + + public FileContentBuilder WithFileContents(byte[] fileContents) + { + _fileContents = fileContents; + return this; + } + + public FileContentBuilder WithContentType(string contentType) + { + _contentType = contentType; + return this; + } + + public FileContent Build() + { + if (string.IsNullOrWhiteSpace(_snowflakeId)) + throw new InvalidOperationException("SnowflakeId is required for FileContent."); + if (string.IsNullOrWhiteSpace(_fileName)) + throw new InvalidOperationException("FileName is required for FileContent."); + if (_fileContents == null || _fileContents.Length == 0) + throw new InvalidOperationException("FileContents are required for FileContent."); + if (string.IsNullOrWhiteSpace(_contentType)) + throw new InvalidOperationException("ContentType is required for FileContent."); + + return new FileContent + { + SnowflakeId = _snowflakeId, + FileName = _fileName, + FileContents = _fileContents, + ContentType = _contentType + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Discord/Builders/PollCreateRequestBuilder.cs b/src/Hooki/Discord/Builders/PollCreateRequestBuilder.cs new file mode 100644 index 0000000..5b0d127 --- /dev/null +++ b/src/Hooki/Discord/Builders/PollCreateRequestBuilder.cs @@ -0,0 +1,157 @@ +using Hooki.Discord.Models.BuildingBlocks; + +namespace Hooki.Discord.Builders; + +public class PollCreateRequestBuilder +{ + private PollMedia? _question; + private readonly List _answers = new(); + private int? _duration; + private bool? _allowMultiSelect; + private int? _layoutType; + + public PollCreateRequestBuilder WithQuestion(Action buildAction) + { + var builder = new PollMediaBuilder(); + buildAction(builder); + _question = builder.Build(); + return this; + } + + public PollCreateRequestBuilder AddAnswer(Action buildAction) + { + var builder = new PollAnswerBuilder(); + buildAction(builder); + _answers.Add(builder.Build()); + return this; + } + + public PollCreateRequestBuilder WithDuration(int duration) + { + _duration = duration; + return this; + } + + public PollCreateRequestBuilder AllowMultiSelect(bool allow = true) + { + _allowMultiSelect = allow; + return this; + } + + public PollCreateRequestBuilder WithLayoutType(int layoutType) + { + _layoutType = layoutType; + return this; + } + + public PollCreateRequest Build() + { + if (_question == null) + throw new InvalidOperationException("Question is required."); + if (_answers.Count == 0) + throw new InvalidOperationException("At least one answer is required."); + + return new PollCreateRequest + { + Question = _question, + Answers = _answers, + Duration = _duration, + AllowMultiSelect = _allowMultiSelect, + LayoutType = _layoutType + }; + } +} + +public class PollMediaBuilder +{ + private string? _text; + private Emoji? _emoji; + + public PollMediaBuilder WithText(string text) + { + _text = text; + return this; + } + + public PollMediaBuilder WithEmoji(Action buildAction) + { + var builder = new EmojiBuilder(); + buildAction(builder); + _emoji = builder.Build(); + return this; + } + + public PollMedia Build() + { + if (string.IsNullOrWhiteSpace(_text)) + throw new InvalidOperationException("Text is required for PollMedia."); + + return new PollMedia + { + Text = _text, + Emoji = _emoji + }; + } +} + +public class PollAnswerBuilder +{ + private int? _answerId; + private PollMedia? _pollMedia; + + public PollAnswerBuilder WithAnswerId(int answerId) + { + _answerId = answerId; + return this; + } + + public PollAnswerBuilder WithPollMedia(Action buildAction) + { + var builder = new PollMediaBuilder(); + buildAction(builder); + _pollMedia = builder.Build(); + return this; + } + + public PollAnswer Build() + { + if (_pollMedia == null) + throw new InvalidOperationException("PollMedia is required for PollAnswer."); + + return new PollAnswer + { + AnswerId = _answerId, + PollMedia = _pollMedia + }; + } +} + +public class EmojiBuilder +{ + private string? _id; + private string? _name; + + public EmojiBuilder WithId(string id) + { + _id = id; + return this; + } + + public EmojiBuilder WithName(string name) + { + _name = name; + return this; + } + + public Emoji Build() + { + if (string.IsNullOrWhiteSpace(_id) && string.IsNullOrWhiteSpace(_name)) + throw new InvalidOperationException("Either Id or Name must be provided for Emoji."); + + return new Emoji + { + Id = _id, + Name = _name + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/Attachment.cs b/src/Hooki/Discord/Models/BuildingBlocks/Attachment.cs index 8cdeffd..3b85ace 100644 --- a/src/Hooki/Discord/Models/BuildingBlocks/Attachment.cs +++ b/src/Hooki/Discord/Models/BuildingBlocks/Attachment.cs @@ -4,11 +4,17 @@ namespace Hooki.Discord.Models.BuildingBlocks; public class Attachment { + /// + /// Id is a snowflake. Please refer to discord documentation: https://discord.com/developers/docs/reference#snowflakes + /// [JsonPropertyName("id")] public required string Id { get; set; } + /// + /// Must be ASCII alphanumeric with underscores, dashes, or dots + /// [JsonPropertyName("filename")] - public string? FileName { get; set; } + public required string FileName { get; set; } [JsonPropertyName("title")] public string? Title { get; set; } @@ -54,4 +60,11 @@ public class Attachment [JsonPropertyName("flags")] public int? Flags { get; set; } + + /// + /// The content of the file to be uploaded. This property is ignored during JSON serialization + /// and should only be used when preparing the multipart form the HTTP POST request + /// + [JsonIgnore] + public byte[]? Content { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/FileContent.cs b/src/Hooki/Discord/Models/BuildingBlocks/FileContent.cs new file mode 100644 index 0000000..6db6f97 --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/FileContent.cs @@ -0,0 +1,11 @@ +namespace Hooki.Discord.Models.BuildingBlocks; + +public class FileContent +{ + public required string SnowflakeId { get; set; } + public required string FileName { get; set; } + + public required byte[] FileContents { get; set; } + + public required string ContentType { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/ActionRow.cs b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/ActionRow.cs new file mode 100644 index 0000000..3609586 --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/ActionRow.cs @@ -0,0 +1,6 @@ +namespace Hooki.MicrosoftTeams.Models.MessageComponents; + +public class ActionRow +{ + +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/Button.cs b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/Button.cs new file mode 100644 index 0000000..47f5925 --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/Button.cs @@ -0,0 +1,6 @@ +namespace Hooki.MicrosoftTeams.Models.MessageComponents; + +public class Button +{ + +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/ChannelSelect.cs b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/ChannelSelect.cs new file mode 100644 index 0000000..b4288cf --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/ChannelSelect.cs @@ -0,0 +1,6 @@ +namespace Hooki.MicrosoftTeams.Models.MessageComponents; + +public class ChannelSelect +{ + +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/MentionableSelect.cs b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/MentionableSelect.cs new file mode 100644 index 0000000..d4b1cb3 --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/MentionableSelect.cs @@ -0,0 +1,6 @@ +namespace Hooki.MicrosoftTeams.Models.MessageComponents; + +public class MentionableSelect +{ + +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/RoleSelect.cs b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/RoleSelect.cs new file mode 100644 index 0000000..f8d489e --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/RoleSelect.cs @@ -0,0 +1,6 @@ +namespace Hooki.MicrosoftTeams.Models.MessageComponents; + +public class RoleSelect +{ + +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/StringSelect.cs b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/StringSelect.cs new file mode 100644 index 0000000..444ff5c --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/StringSelect.cs @@ -0,0 +1,6 @@ +namespace Hooki.MicrosoftTeams.Models.MessageComponents; + +public class StringSelect +{ + +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/TextInput.cs b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/TextInput.cs new file mode 100644 index 0000000..d8a27f3 --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/TextInput.cs @@ -0,0 +1,6 @@ +namespace Hooki.MicrosoftTeams.Models.MessageComponents; + +public class TextInput +{ + +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/UserSelect.cs b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/UserSelect.cs new file mode 100644 index 0000000..e6d4c47 --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/MessageComponents/UserSelect.cs @@ -0,0 +1,6 @@ +namespace Hooki.MicrosoftTeams.Models.MessageComponents; + +public class UserSelect +{ + +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/BuildingBlocks/PollCreateRequest.cs b/src/Hooki/Discord/Models/BuildingBlocks/PollCreateRequest.cs new file mode 100644 index 0000000..710759f --- /dev/null +++ b/src/Hooki/Discord/Models/BuildingBlocks/PollCreateRequest.cs @@ -0,0 +1,178 @@ +using System.Text.Json.Serialization; + +namespace Hooki.Discord.Models.BuildingBlocks; + +/// +/// Please refer to Discord's documentation for more details: https://discord.com/developers/docs/resources/poll#poll-create-request-object +/// +public class PollCreateRequest +{ + /// + /// Only Text is supported in PollMedia for questions + /// + [JsonPropertyName("question")] public required PollMedia Question { get; set; } + + [JsonPropertyName("answers")] public required List Answers { get; set; } + + /// + /// Defaults to 24 hours + /// Maximum value is 32 days + /// + [JsonPropertyName("duration")] public int? Duration { get; set; } + + [JsonPropertyName("allow_multiselect")] public bool? AllowMultiSelect { get; set; } + + /// + /// Refer to Discord's documentation for more details: https://discord.com/developers/docs/resources/poll#layout-type + /// + [JsonPropertyName("layout_type")] public int? LayoutType { get; set; } +} + +/// +/// Please refer to Discord's documentation for more details: https://discord.com/developers/docs/resources/poll#poll-media-object +/// +public class PollMedia +{ + [JsonPropertyName("text")] public required string Text { get; set; } + + [JsonPropertyName("emoji")] public Emoji? Emoji { get; set; } +} + +/// +/// Please refer to Discord's documentation for more details: https://discord.com/developers/docs/resources/poll#poll-answer-object +/// +public class PollAnswer +{ + [JsonPropertyName("answer_id")] public int? AnswerId { get; set; } + + [JsonPropertyName("poll_media")] public required PollMedia PollMedia { get; set; } +} + +/// +/// Please refer to Discord's documentation for more details: https://discord.com/developers/docs/resources/emoji#emoji-object +/// +public class Emoji +{ + /// + /// Provide Id when you're sending a custom emoji + /// + [JsonPropertyName("id")] public string? Id { get; set; } + + /// + /// Provide Name when you're sending a default emoji and optionally when you're sending a custom emoji + /// Name have a unicode emoji value like "โญ๏ธ" + /// You can use Emojipedia to find unicode emojis: https://emojipedia.org/ + /// + [JsonPropertyName("name")] public string? Name { get; set; } + + /// + /// When provided, Roles should contain an array of Role object ids + /// + [JsonPropertyName("roles")] public string[]? Roles { get; set; } + + //ToDo: Implement user object for type safety + [JsonPropertyName("user")] public User? User { get; set; } + + [JsonPropertyName("require_colons")] public bool? RequireColons { get; set; } + + [JsonPropertyName("managed")] public bool? Managed { get; set; } + + [JsonPropertyName("animated")] public bool? Animated { get; set; } + + [JsonPropertyName("available")] public bool? Available { get; set; } +} + +/// +/// Please refer to Discord's documentation for more details: https://discord.com/developers/docs/resources/user#user-object +/// +public class User +{ + [JsonPropertyName("id")] public required string Id { get; set; } + + [JsonPropertyName("username")] public required string Username { get; set; } + + /// + /// The User's Discord tag + /// + [JsonPropertyName("discriminator")] public required string Discriminator { get; set; } + + /// + /// The User's display name, if it is set. For bots, this is the application name + /// + [JsonPropertyName("global_name")] public string? GlobalName { get; set; } + + /// + /// The User's avatar hash + /// + [JsonPropertyName("avatar")] public string? Avatar { get; set; } + + /// + /// Whether the user belongs to an OAuth2 application + /// + [JsonPropertyName("bot")] public bool? Bot { get; set; } + + /// + /// Whether the User is an official Discord System user + /// + [JsonPropertyName("system")] public bool? System { get; set; } + + [JsonPropertyName("mfa_enabled")] public bool? MfaEnabled { get; set; } + + /// + /// The User's banner hash + /// + [JsonPropertyName("banner")] public bool? Banner { get; set; } + + /// + /// The User's banner color encoded as an integer representation of hexadecimal color code + /// + [JsonPropertyName("accent_color")] public int? AccentColor { get; set; } + + /// + /// The User's chosen language option + /// Refer to Discord's documentation to see the supported Locales: https://discord.com/developers/docs/reference#locales + /// + [JsonPropertyName("locale")] public string? Locale { get; set; } + + [JsonPropertyName("verified")] public bool? Verified { get; set; } + + /// + /// The User's email + /// + [JsonPropertyName("email")] public string? Email { get; set; } + + /// + /// Refer to Discord's documentation to see all the possible User flags: https://discord.com/developers/docs/resources/user#user-object-user-flags + /// + [JsonPropertyName("flags")] public int? Flags { get; set; } + + /// + /// The type of Nitro subscription on a User's account + /// Refer to Discord's documentation to see all possible premium types: https://discord.com/developers/docs/resources/user#user-object-premium-types + /// + [JsonPropertyName("premium_type")] public int? PremiumType { get; set; } + + /// + /// Refer to Discord's documentation to see all the possible User flags: https://discord.com/developers/docs/resources/user#user-object-user-flags + /// + [JsonPropertyName("public_flags")] public int? PublicFlags { get; set; } + + [JsonPropertyName("avatar_decoration_data")] public AvatarDecorationData? AvatarDecorationData { get; set; } +} + +/// +/// Please refer to Discord's documentation for more details: https://discord.com/developers/docs/resources/user#avatar-decoration-data-object +/// +public class AvatarDecorationData +{ + /// + /// The Avatar decoration hash + /// Refer to Discord's documentation for more details: https://discord.com/developers/docs/reference#image-formatting + /// + [JsonPropertyName("asset")] public required string Asset { get; set; } + + /// + /// Id of the Avatar decoration's SKU + /// + [JsonPropertyName("sku_id")] public required string SkuId { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Discord/Models/DiscordWebhookPayload.cs b/src/Hooki/Discord/Models/DiscordWebhookPayload.cs index 63ca443..5e66173 100644 --- a/src/Hooki/Discord/Models/DiscordWebhookPayload.cs +++ b/src/Hooki/Discord/Models/DiscordWebhookPayload.cs @@ -1,5 +1,8 @@ +using System.Net.Http.Headers; +using System.Text.Json; using System.Text.Json.Serialization; using Hooki.Discord.Models.BuildingBlocks; +using Hooki.Utilities; namespace Hooki.Discord.Models; @@ -20,9 +23,19 @@ public class DiscordWebhookPayload [JsonPropertyName("allowed_mentions")] public AllowedMention? AllowedMentions { get; set; } + /// + /// We're not supporting message components at the moment. + /// Please refer to our README to see our roadmap. + /// In the meantime, you can use anonymous objects and refer to Discord's documentation for more details: https://discord.com/developers/docs/interactions/message-components#component-object + /// [JsonPropertyName("components")] public List? Components { get; set; } - [JsonPropertyName("files")] public List? Files { get; set; } + /// + /// Files is not serialized JSON with the payload + /// You can either use DiscordWebhookPayload.MultipartContent or you can implement the MultipartContent yourself + /// Please refer to the discord documentation for more details: https://discord.com/developers/docs/reference#uploading-files + /// + [JsonIgnore] public List? Files { get; set; } [JsonPropertyName("payload_json")] public string? PayloadJson { get; set; } @@ -30,7 +43,80 @@ public class DiscordWebhookPayload [JsonPropertyName("flags")] public int? Flags { get; set; } + /// + /// If ThreadName is provided, a thread with that name will be created in the channel if it doesn't already exist + /// Requires the webhook channel to be a forum or media channel + /// [JsonPropertyName("thread_name")] public string? ThreadName { get; set; } [JsonPropertyName("applied_tags")] public List? AppliedTags { get; set; } + + /// + /// Not supported at the moment. Please check our roadmap + /// + [JsonPropertyName("poll")] public PollCreateRequest? Poll { get; set; } + + private MultipartFormDataContent? _cachedMultipartContent; + private bool _isMultipartContentDirty = true; + + /// + /// Gets the MultipartFormDataContent for this webhook payload + /// MultiPartContent is required when you're sending attachments or files + /// This property lazily creates and caches the content, rebuilding it only when necessary + /// Refer to Discord's documentation for more details: https://discord.com/developers/docs/reference#uploading-files + /// + [JsonIgnore] + public MultipartFormDataContent? MultipartContent + { + get + { + if (_cachedMultipartContent is null || _isMultipartContentDirty) + { + _cachedMultipartContent = BuildMultipartContent(); + _isMultipartContentDirty = false; + } + + return _cachedMultipartContent; + } + } + + private MultipartFormDataContent BuildMultipartContent() + { + var content = new MultipartFormDataContent(); + + var payloadJson = JsonSerializer.Serialize(this, HookiJsonSerializerOptions.Default); + + PayloadJson = payloadJson; + + // Add files + if (Files != null) + { + foreach (var file in Files) + { + var fileContent = new ByteArrayContent(file.FileContents); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType); + + content.Add(fileContent, $"files[{file.SnowflakeId}]", file.FileName); + } + } + + // Add attachments + if (Attachments != null) + { + foreach (var attachment in Attachments) + { + if (attachment.Content != null) + { + var fileContent = new ByteArrayContent(attachment.Content); + if (!string.IsNullOrEmpty(attachment.ContentType)) + { + fileContent.Headers.ContentType = new MediaTypeHeaderValue(attachment.ContentType); + } + content.Add(fileContent, $"files[{attachment.Id}]", attachment.FileName); + } + } + } + + return content; + } } \ No newline at end of file diff --git a/src/Hooki/Hooki.sln b/src/Hooki/Hooki.sln index cafec6a..bc9ebe7 100644 --- a/src/Hooki/Hooki.sln +++ b/src/Hooki/Hooki.sln @@ -2,6 +2,10 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hooki", ".\Hooki.csproj", "{4E79DB1B-84E2-4364-9407-1329BAF14F8C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hooki.UnitTests", "..\Hooki.UnitTests\Hooki.UnitTests.csproj", "{56B54DE5-873F-4C6D-A085-1552079A8B62}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hooki.IntegrationTests", "..\Hooki.IntegrationTests\Hooki.IntegrationTests.csproj", "{4F088F93-039B-4D46-90EE-147FB8ED7E07}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +16,13 @@ Global {4E79DB1B-84E2-4364-9407-1329BAF14F8C}.Debug|Any CPU.Build.0 = Debug|Any CPU {4E79DB1B-84E2-4364-9407-1329BAF14F8C}.Release|Any CPU.ActiveCfg = Release|Any CPU {4E79DB1B-84E2-4364-9407-1329BAF14F8C}.Release|Any CPU.Build.0 = Release|Any CPU + {56B54DE5-873F-4C6D-A085-1552079A8B62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56B54DE5-873F-4C6D-A085-1552079A8B62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56B54DE5-873F-4C6D-A085-1552079A8B62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56B54DE5-873F-4C6D-A085-1552079A8B62}.Release|Any CPU.Build.0 = Release|Any CPU + {4F088F93-039B-4D46-90EE-147FB8ED7E07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F088F93-039B-4D46-90EE-147FB8ED7E07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F088F93-039B-4D46-90EE-147FB8ED7E07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F088F93-039B-4D46-90EE-147FB8ED7E07}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection -EndGlobal \ No newline at end of file +EndGlobal diff --git a/src/Hooki/MicrosoftTeams/Builders/ActionBuilderBase.cs b/src/Hooki/MicrosoftTeams/Builders/ActionBuilderBase.cs new file mode 100644 index 0000000..ecada4c --- /dev/null +++ b/src/Hooki/MicrosoftTeams/Builders/ActionBuilderBase.cs @@ -0,0 +1,61 @@ +using Hooki.MicrosoftTeams.Enums; +using Hooki.MicrosoftTeams.Models.Actions; +using Hooki.MicrosoftTeams.Models.BuildingBlocks; +using Hooki.MicrosoftTeams.Models.Inputs; + +namespace Hooki.MicrosoftTeams.Builders; + +public abstract class ActionBuilderBase where TBuilder : ActionBuilderBase +{ + protected abstract List PotentialActions { get; } + + public TBuilder AddOpenUriAction(string name, string uri) + { + PotentialActions.Add(new OpenUriAction + { + Name = name, + Targets = [new Target { OperatingSystem = OperatingSystemType.Default, Uri = uri }] + }); + return (TBuilder)this; + } + + public TBuilder AddHttpPostAction(string name, string target, string body, string bodyContentType, List
headers) + { + PotentialActions.Add(new HttpPostAction + { + Name = name, + Target = target, + Body = body, + BodyContentType = bodyContentType, + Headers = headers + }); + return (TBuilder)this; + } + + public TBuilder AddActionCardAction(string name, List? inputs, List actions) + { + PotentialActions.Add(new ActionCardAction + { + Name = name, + Inputs = inputs, + Actions = actions + }); + return (TBuilder)this; + } + + public TBuilder AddInvokeAddInCommandAction( + string name, + string addInId, + string desktopCommandId, + object? initializationContext) + { + PotentialActions.Add(new InvokeAddInCommandAction + { + Name = name, + AddInId = addInId, + DesktopCommandId = desktopCommandId, + InitializationContext = initializationContext + }); + return (TBuilder)this; + } +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Builders/FactBuilder.cs b/src/Hooki/MicrosoftTeams/Builders/FactBuilder.cs new file mode 100644 index 0000000..279b2d4 --- /dev/null +++ b/src/Hooki/MicrosoftTeams/Builders/FactBuilder.cs @@ -0,0 +1,30 @@ +using Hooki.MicrosoftTeams.Models.BuildingBlocks; + +namespace Hooki.MicrosoftTeams.Builders; + +public class FactBuilder +{ + private string? _name; + private string? _value; + + public FactBuilder WithName(string name) + { + _name = name; + return this; + } + + public FactBuilder WithValue(string value) + { + _value = value; + return this; + } + + public Fact Build() + { + return new Fact + { + Name = _name ?? throw new InvalidOperationException("Name is required"), + Value = _value ?? throw new InvalidOperationException("Value is required") + }; + } +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Builders/HeaderBuilder.cs b/src/Hooki/MicrosoftTeams/Builders/HeaderBuilder.cs new file mode 100644 index 0000000..80b140c --- /dev/null +++ b/src/Hooki/MicrosoftTeams/Builders/HeaderBuilder.cs @@ -0,0 +1,40 @@ +using Hooki.MicrosoftTeams.Models.BuildingBlocks; + +namespace Hooki.MicrosoftTeams.Builders; + +public class HeaderBuilder +{ + private string? _name; + private string? _value; + + public HeaderBuilder WithName(string name) + { + _name = name; + return this; + } + + public HeaderBuilder WithValue(string value) + { + _value = value; + return this; + } + + public Header Build() + { + if (string.IsNullOrEmpty(_name)) + { + throw new InvalidOperationException("Name is required for Header."); + } + + if (string.IsNullOrEmpty(_value)) + { + throw new InvalidOperationException("Value is required for Header."); + } + + return new Header + { + Name = _name, + Value = _value + }; + } +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Builders/ImageBlockBuilder.cs b/src/Hooki/MicrosoftTeams/Builders/ImageBlockBuilder.cs new file mode 100644 index 0000000..7ddacf0 --- /dev/null +++ b/src/Hooki/MicrosoftTeams/Builders/ImageBlockBuilder.cs @@ -0,0 +1,30 @@ +using Hooki.MicrosoftTeams.Models.BuildingBlocks; + +namespace Hooki.MicrosoftTeams.Builders; + +public class ImageBlockBuilder +{ + private string? _imageUrl; + private string? _title; + + public ImageBlockBuilder WithImageUrl(string imageUrl) + { + _imageUrl = imageUrl; + return this; + } + + public ImageBlockBuilder WithTitle(string title) + { + _title = title; + return this; + } + + public Image Build() + { + return new Image + { + ImageUrl = _imageUrl ?? throw new InvalidOperationException("ImageUrl is required"), + Title = _title + }; + } +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Builders/MessageCardBuilder.cs b/src/Hooki/MicrosoftTeams/Builders/MessageCardBuilder.cs new file mode 100644 index 0000000..bd6d19d --- /dev/null +++ b/src/Hooki/MicrosoftTeams/Builders/MessageCardBuilder.cs @@ -0,0 +1,102 @@ +using Hooki.MicrosoftTeams.Models; +using Hooki.MicrosoftTeams.Models.Actions; +using Hooki.MicrosoftTeams.Models.BuildingBlocks; + +namespace Hooki.MicrosoftTeams.Builders; + +public class MessageCardBuilder : ActionBuilderBase +{ + private string? _correlationId; + private List? _expectedActors; + private string? _originator; + private string? _summary; + private string? _themeColor; + private bool? _hideOriginalBody; + private string? _title; + private string? _text; + private List
? _sections; + + private readonly List _potentialActions = []; + protected override List PotentialActions => _potentialActions; + + public MessageCardBuilder WithCorrelationId(string correlationId) + { + _correlationId = correlationId; + return this; + } + + public MessageCardBuilder WithOriginator(string originator) + { + _originator = originator; + return this; + } + + public MessageCardBuilder WithTitle(string title) + { + _title = title; + return this; + } + + public MessageCardBuilder WithText(string text) + { + _text = text; + return this; + } + + public MessageCardBuilder WithThemeColor(string color) + { + _themeColor = color; + return this; + } + + public MessageCardBuilder WithSummary(string summary) + { + _summary = summary; + return this; + } + + public MessageCardBuilder AddSection(Action sectionBuilder) + { + var section = new SectionBuilder(); + sectionBuilder(section); + + _sections ??= []; + _sections.Add(section.Build()); + return this; + } + + public MessageCardBuilder AddExpectedActor(string expectedActor) + { + _expectedActors ??= []; + _expectedActors.Add(expectedActor); + return this; + } + + public MessageCardBuilder WithHiddenOriginalBody(bool hideOriginalBody) + { + _hideOriginalBody = hideOriginalBody; + return this; + } + + public MessageCard Build() + { + if (string.IsNullOrEmpty(_text) && string.IsNullOrEmpty(_summary)) + { + throw new InvalidOperationException("Either Text or Summary must be provided."); + } + + return new MessageCard + { + CorrelationId = _correlationId, + ExpectedActors = _expectedActors, + Originator = _originator, + Summary = _summary, + ThemeColor = _themeColor, + HideOriginalBody = _hideOriginalBody, + Title = _title, + Text = _text, + Sections = _sections, + PotentialActions = _potentialActions.Count > 0 ? _potentialActions : null + }; + } +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Builders/SectionBuilder.cs b/src/Hooki/MicrosoftTeams/Builders/SectionBuilder.cs new file mode 100644 index 0000000..dc93f60 --- /dev/null +++ b/src/Hooki/MicrosoftTeams/Builders/SectionBuilder.cs @@ -0,0 +1,108 @@ +using Hooki.MicrosoftTeams.Models.Actions; +using Hooki.MicrosoftTeams.Models.BuildingBlocks; + +namespace Hooki.MicrosoftTeams.Builders; + +public class SectionBuilder : ActionBuilderBase +{ + private string? _title; + private bool? _startGroup; + private string? _activityImage; + private string? _activityTitle; + private string? _activitySubtitle; + private string? _activityText; + private Image? _heroImage; + private string? _text; + private List? _facts; + private List? _images; + + private readonly List _potentialActions = []; + protected override List PotentialActions => _potentialActions; + + public SectionBuilder WithTitle(string title) + { + _title = title; + return this; + } + + public SectionBuilder WithStartGroup(bool startGroup) + { + _startGroup = startGroup; + return this; + } + + public SectionBuilder WithActivityImage(string imageUrl) + { + _activityImage = imageUrl; + return this; + } + + public SectionBuilder WithActivityTitle(string title) + { + _activityTitle = title; + return this; + } + + public SectionBuilder WithActivitySubtitle(string subtitle) + { + _activitySubtitle = subtitle; + return this; + } + + public SectionBuilder WithActivityText(string text) + { + _activityText = text; + return this; + } + + public SectionBuilder WithHeroImage(Action imageBuilderAction) + { + var imageBuilder = new ImageBlockBuilder(); + imageBuilderAction(imageBuilder); + _heroImage = imageBuilder.Build(); + return this; + } + + public SectionBuilder WithText(string text) + { + _text = text; + return this; + } + + public SectionBuilder AddImage(Action imageBuilderAction) + { + var imageBuilder = new ImageBlockBuilder(); + imageBuilderAction(imageBuilder); + _images ??= new List(); + _images.Add(imageBuilder.Build()); + return this; + } + + public SectionBuilder AddFact(Action factBuilderAction) + { + var factBuilder = new FactBuilder(); + factBuilderAction(factBuilder); + _facts ??= new List(); + _facts.Add(factBuilder.Build()); + return this; + } + + + public Section Build() + { + return new Section + { + Title = _title, + StartGroup = _startGroup, + ActivityImage = _activityImage, + ActivityTitle = _activityTitle, + ActivitySubtitle = _activitySubtitle, + ActivityText = _activityText, + HeroImage = _heroImage, + Text = _text, + Facts = _facts, + Images = _images, + PotentialActions = _potentialActions.Count > 0 ? _potentialActions : null + }; + } +} diff --git a/src/Hooki/MicrosoftTeams/Builders/TargetBuilder.cs b/src/Hooki/MicrosoftTeams/Builders/TargetBuilder.cs new file mode 100644 index 0000000..b5b40f9 --- /dev/null +++ b/src/Hooki/MicrosoftTeams/Builders/TargetBuilder.cs @@ -0,0 +1,36 @@ +using Hooki.MicrosoftTeams.Enums; +using Hooki.MicrosoftTeams.Models.BuildingBlocks; + +namespace Hooki.MicrosoftTeams.Builders; + +public class TargetBuilder +{ + private OperatingSystemType _operatingSystem; + private string? _uri; + + public TargetBuilder WithOperatingSystem(OperatingSystemType operatingSystem) + { + _operatingSystem = operatingSystem; + return this; + } + + public TargetBuilder WithUri(string uri) + { + _uri = uri; + return this; + } + + public Target Build() + { + if (string.IsNullOrEmpty(_uri)) + { + throw new InvalidOperationException("Uri is required for Target."); + } + + return new Target + { + OperatingSystem = _operatingSystem, + Uri = _uri + }; + } +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Enums/OperatingSystemType.cs b/src/Hooki/MicrosoftTeams/Enums/OperatingSystemType.cs new file mode 100644 index 0000000..9d2db9d --- /dev/null +++ b/src/Hooki/MicrosoftTeams/Enums/OperatingSystemType.cs @@ -0,0 +1,21 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.MicrosoftTeams.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum OperatingSystemType +{ + [EnumMember(Value = "default")] + Default, + + [EnumMember(Value = "iOS")] + IOS, + + [EnumMember(Value = "android")] + Android, + + [EnumMember(Value = "windows")] + Windows +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/JsonConverters/ActionBaseConverter.cs b/src/Hooki/MicrosoftTeams/JsonConverters/ActionBaseConverter.cs new file mode 100644 index 0000000..854f0ee --- /dev/null +++ b/src/Hooki/MicrosoftTeams/JsonConverters/ActionBaseConverter.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Hooki.MicrosoftTeams.Enums; +using Hooki.MicrosoftTeams.Models.Actions; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.MicrosoftTeams.JsonConverters; + +public class ActionBaseConverter : JsonConverter +{ + public override ActionBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException("Deserialization is not implemented for this converter."); + } + + public override void Write(Utf8JsonWriter writer, ActionBase value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + // Write the "type" property as a string + writer.WriteString("@type", GetActionTypeJsonValue(value.Type)); + + // Serialize other properties + foreach (var property in value.GetType().GetProperties()) + { + if (property.Name == nameof(ActionBase.Type)) continue; + + var propertyValue = property.GetValue(value); + + if (propertyValue == null) continue; + + var propertyName = property.GetCustomAttribute()?.Name + ?? options.PropertyNamingPolicy?.ConvertName(property.Name) + ?? property.Name; + + + writer.WritePropertyName(propertyName); + JsonSerializer.Serialize(writer, propertyValue, property.PropertyType, options); + } + + writer.WriteEndObject(); + } + + private static string GetActionTypeJsonValue(ActionType actionType) + { + return actionType switch + { + ActionType.ActionCard => "ActionCard", + ActionType.HttpPost => "HttpPOST", + ActionType.OpenUri => "OpenUri", + ActionType.InvokeAddInCommand => "InvokeAddInCommand", + _ => throw new ArgumentOutOfRangeException(nameof(actionType), actionType, null) + }; + } +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/JsonConverters/InputBaseJsonConverter.cs b/src/Hooki/MicrosoftTeams/JsonConverters/InputBaseJsonConverter.cs new file mode 100644 index 0000000..b0caef8 --- /dev/null +++ b/src/Hooki/MicrosoftTeams/JsonConverters/InputBaseJsonConverter.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Hooki.MicrosoftTeams.Enums; +using Hooki.MicrosoftTeams.Models.Inputs; + +namespace Hooki.MicrosoftTeams.JsonConverters; + +public class InputBaseJsonConverter : JsonConverter +{ + public override InputBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected start of object"); + } + + using var jsonDocument = JsonDocument.ParseValue(ref reader); + var root = jsonDocument.RootElement; + + if (!root.TryGetProperty("@type", out var typeProperty)) + { + throw new JsonException("Cannot find @type property"); + } + + var type = typeProperty.GetString(); + return type switch + { + nameof(InputType.TextInput) => JsonSerializer.Deserialize(root.GetRawText(), options)!, + nameof(InputType.DateInput) => JsonSerializer.Deserialize(root.GetRawText(), options)!, + nameof(InputType.MultiChoiceInput) => JsonSerializer.Deserialize(root.GetRawText(), options)!, + _ => throw new JsonException($"Unknown input type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, InputBase value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + // Write the discriminator property + writer.WriteString("@type", value.Type.ToString()); + + // Use reflection to get all properties of the concrete type + var properties = value.GetType().GetProperties(); + foreach (var prop in properties) + { + // Skip the Type property as we've already written it + if (prop.Name == nameof(InputBase.Type)) continue; + + var propValue = prop.GetValue(value); + if (propValue != null) + { + writer.WritePropertyName(prop.Name); + JsonSerializer.Serialize(writer, propValue, prop.PropertyType, options); + } + } + + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Actions/ActionBase.cs b/src/Hooki/MicrosoftTeams/Models/Actions/ActionBase.cs index b2d3bc7..57630c4 100644 --- a/src/Hooki/MicrosoftTeams/Models/Actions/ActionBase.cs +++ b/src/Hooki/MicrosoftTeams/Models/Actions/ActionBase.cs @@ -1,15 +1,13 @@ using System.Text.Json.Serialization; using Hooki.MicrosoftTeams.Enums; +using Hooki.MicrosoftTeams.JsonConverters; namespace Hooki.MicrosoftTeams.Models.Actions; -[JsonDerivedType(typeof(OpenUriAction), typeDiscriminator: nameof(ActionType.OpenUri))] -[JsonDerivedType(typeof(HttpPostAction), typeDiscriminator: nameof(ActionType.HttpPost))] -[JsonDerivedType(typeof(ActionCardAction), typeDiscriminator: nameof(ActionType.ActionCard))] -[JsonDerivedType(typeof(InvokeAddInCommandAction), typeDiscriminator: nameof(ActionType.InvokeAddInCommand))] +[JsonConverter(typeof(ActionBaseConverter))] public abstract class ActionBase { [JsonPropertyName("@type")] public abstract ActionType Type { get; } - [JsonPropertyName("name")] public string Name { get; set; } = default!; + [JsonPropertyName("name")] public required string Name { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Actions/ActionCardAction.cs b/src/Hooki/MicrosoftTeams/Models/Actions/ActionCardAction.cs index 732bbf0..3dec2ea 100644 --- a/src/Hooki/MicrosoftTeams/Models/Actions/ActionCardAction.cs +++ b/src/Hooki/MicrosoftTeams/Models/Actions/ActionCardAction.cs @@ -1,14 +1,22 @@ using System.Text.Json.Serialization; using Hooki.MicrosoftTeams.Enums; +using Hooki.MicrosoftTeams.JsonConverters; using Hooki.MicrosoftTeams.Models.Inputs; namespace Hooki.MicrosoftTeams.Models.Actions; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#actioncard-action +/// public class ActionCardAction : ActionBase { public override ActionType Type => ActionType.ActionCard; + + [JsonPropertyName("inputs")] public List? Inputs { get; set; } - [JsonPropertyName("inputs")] public List Inputs { get; set; } = []; - - [JsonPropertyName("actions")] public List Actions { get; set; } = []; + /// + /// Actions be of type OpenUri or HttpPOST. + /// The actions property of an ActionCard action cannot contain another ActionCard action. + /// + [JsonPropertyName("actions")] public required List? Actions { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Actions/HttpPostAction.cs b/src/Hooki/MicrosoftTeams/Models/Actions/HttpPostAction.cs index 5aa53eb..ebc2006 100644 --- a/src/Hooki/MicrosoftTeams/Models/Actions/HttpPostAction.cs +++ b/src/Hooki/MicrosoftTeams/Models/Actions/HttpPostAction.cs @@ -4,15 +4,22 @@ namespace Hooki.MicrosoftTeams.Models.Actions; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#httppost-action +/// public class HttpPostAction : ActionBase { public override ActionType Type => ActionType.HttpPost; + + [JsonPropertyName("target")] public required string Target { get; set; } - [JsonPropertyName("target")] public string Target { get; set; } = default!; + [JsonPropertyName("headers")] public List
? Headers { get; set; } - [JsonPropertyName("headers")] public List
Headers { get; set; } = []; - - [JsonPropertyName("body")] public string? Body { get; set; } + [JsonPropertyName("body")] public required string Body { get; set; } + /// + /// Valid values are application/json and application/x-www-form-urlencoded. + /// If not specified, application/json is assumed + /// [JsonPropertyName("bodyContentType")] public string? BodyContentType { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Actions/InvokeAddInCommandAction.cs b/src/Hooki/MicrosoftTeams/Models/Actions/InvokeAddInCommandAction.cs index 9b17e49..279a1ca 100644 --- a/src/Hooki/MicrosoftTeams/Models/Actions/InvokeAddInCommandAction.cs +++ b/src/Hooki/MicrosoftTeams/Models/Actions/InvokeAddInCommandAction.cs @@ -3,13 +3,16 @@ namespace Hooki.MicrosoftTeams.Models.Actions; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#invokeaddincommand-action +/// public class InvokeAddInCommandAction : ActionBase { public override ActionType Type => ActionType.InvokeAddInCommand; - [JsonPropertyName("addInId")] public string AddInId { get; set; } = default!; + [JsonPropertyName("addInId")] public required string AddInId { get; set; } - [JsonPropertyName("desktopCommandId")] public string DesktopCommandId { get; set; } = default!; + [JsonPropertyName("desktopCommandId")] public required string DesktopCommandId { get; set; } [JsonPropertyName("initializationContext")] public object? InitializationContext { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Actions/OpenUriAction.cs b/src/Hooki/MicrosoftTeams/Models/Actions/OpenUriAction.cs index b32d8e6..be27848 100644 --- a/src/Hooki/MicrosoftTeams/Models/Actions/OpenUriAction.cs +++ b/src/Hooki/MicrosoftTeams/Models/Actions/OpenUriAction.cs @@ -4,9 +4,12 @@ namespace Hooki.MicrosoftTeams.Models.Actions; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#openuri-action +/// public class OpenUriAction : ActionBase { public override ActionType Type => ActionType.OpenUri; - [JsonPropertyName("targets")] public List Targets { get; set; } = []; + [JsonPropertyName("targets")] public required List Targets { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Action.cs b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Action.cs deleted file mode 100644 index 3911e6b..0000000 --- a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Action.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json.Serialization; -using Hooki.MicrosoftTeams.Models.Inputs; - -namespace Hooki.MicrosoftTeams.Models.BuildingBlocks; - -public class Action -{ - [JsonPropertyName("@type")] public string Type { get; set; } = default!; - - [JsonPropertyName("name")] public string Name { get; set; } = default!; - - [JsonPropertyName("targets")] public List Targets { get; set; } = []; - - [JsonPropertyName("target")] public string Target { get; set; } = default!; - - [JsonPropertyName("headers")] public List
Headers { get; set; } = []; - - [JsonPropertyName("body")] public string Body { get; set; } = default!; - - [JsonPropertyName("bodyContentType")] public string BodyContentType { get; set; } = default!; - - [JsonPropertyName("inputs")] public List Inputs { get; set; } = []; - - [JsonPropertyName("actions")] public List Actions { get; set; } = []; - - [JsonPropertyName("addInId")] public string AddInId { get; set; } = default!; - - [JsonPropertyName("desktopCommandId")] public string DesktopCommandId { get; set; } = default!; - - [JsonPropertyName("initializationContext")] public object? InitializationContext { get; set; } -} \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Fact.cs b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Fact.cs index 6325111..ababe75 100644 --- a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Fact.cs +++ b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Fact.cs @@ -2,9 +2,12 @@ namespace Hooki.MicrosoftTeams.Models.BuildingBlocks; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#openuri-action +/// public class Fact { - [JsonPropertyName("name")] public string Name { get; set; } = default!; + [JsonPropertyName("name")] public required string Name { get; set; } - [JsonPropertyName("value")] public string Value { get; set; } = default!; + [JsonPropertyName("value")] public required string Value { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Header.cs b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Header.cs index 80fc214..8940133 100644 --- a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Header.cs +++ b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Header.cs @@ -2,9 +2,12 @@ namespace Hooki.MicrosoftTeams.Models.BuildingBlocks; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#openuri-action +/// public class Header { - [JsonPropertyName("name")] public string Name { get; set; } = default!; + [JsonPropertyName("name")] public required string Name { get; set; } - [JsonPropertyName("value")] public string Value { get; set; } = default!; + [JsonPropertyName("value")] public required string Value { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Image.cs b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Image.cs index 9a03ba1..f2539f2 100644 --- a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Image.cs +++ b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Image.cs @@ -2,9 +2,12 @@ namespace Hooki.MicrosoftTeams.Models.BuildingBlocks; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#openuri-action +/// public class Image { - [JsonPropertyName("image")] public string ImageUrl { get; set; } = default!; + [JsonPropertyName("image")] public required string ImageUrl { get; set; } - [JsonPropertyName("title")] public string Title { get; set; } = default!; + [JsonPropertyName("title")] public string? Title { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Section.cs b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Section.cs index 67e0134..dbd16fa 100644 --- a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Section.cs +++ b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Section.cs @@ -1,35 +1,32 @@ using System.Text.Json.Serialization; +using Hooki.MicrosoftTeams.Models.Actions; namespace Hooki.MicrosoftTeams.Models.BuildingBlocks; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#openuri-action +/// public class Section { - [JsonPropertyName("title")] public string Title { get; set; } = default!; + [JsonPropertyName("title")] public string? Title { get; set; } - [JsonPropertyName("startGroup")] - public bool? StartGroup { get; set; } = default!; + [JsonPropertyName("startGroup")] public bool? StartGroup { get; set; } - [JsonPropertyName("activityImage")] - public string ActivityImage { get; set; } = default!; + [JsonPropertyName("activityImage")] public string? ActivityImage { get; set; } - [JsonPropertyName("activityTitle")] - public string ActivityTitle { get; set; } = default!; + [JsonPropertyName("activityTitle")] public string? ActivityTitle { get; set; } - [JsonPropertyName("activitySubtitle")] - public string ActivitySubtitle { get; set; } = default!; + [JsonPropertyName("activitySubtitle")] public string? ActivitySubtitle { get; set; } - [JsonPropertyName("activityText")] - public string ActivityText { get; set; } = default!; + [JsonPropertyName("activityText")] public string? ActivityText { get; set; } - [JsonPropertyName("heroImage")] - public Image? HeroImage { get; set; } + [JsonPropertyName("heroImage")] public Image? HeroImage { get; set; } - [JsonPropertyName("text")] - public string Text { get; set; } = default!; + [JsonPropertyName("text")] public string? Text { get; set; } - [JsonPropertyName("facts")] public List Facts { get; set; } = []; + [JsonPropertyName("facts")] public List? Facts { get; set; } - [JsonPropertyName("images")] public List Images { get; set; } = []; + [JsonPropertyName("images")] public List? Images { get; set; } - [JsonPropertyName("potentialAction")] public List PotentialActions { get; set; } = []; + [JsonPropertyName("potentialAction")] public List? PotentialActions { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Target.cs b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Target.cs index cb56e0c..c2eafa4 100644 --- a/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Target.cs +++ b/src/Hooki/MicrosoftTeams/Models/BuildingBlocks/Target.cs @@ -1,10 +1,14 @@ using System.Text.Json.Serialization; +using Hooki.MicrosoftTeams.Enums; namespace Hooki.MicrosoftTeams.Models.BuildingBlocks; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#openuri-action +/// public class Target { - [JsonPropertyName("os")] public string OperatingSystem { get; set; } = default!; + [JsonPropertyName("os")] public required OperatingSystemType OperatingSystem { get; set; } = OperatingSystemType.Default; - [JsonPropertyName("uri")] public string Uri { get; set; } = default!; + [JsonPropertyName("uri")] public required string Uri { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Inputs/Choice.cs b/src/Hooki/MicrosoftTeams/Models/Inputs/Choice.cs index be65e1e..95ebe5c 100644 --- a/src/Hooki/MicrosoftTeams/Models/Inputs/Choice.cs +++ b/src/Hooki/MicrosoftTeams/Models/Inputs/Choice.cs @@ -2,9 +2,12 @@ namespace Hooki.MicrosoftTeams.Models.Inputs; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#multichoiceinput +/// public class Choice { - [JsonPropertyName("display")] public string Display { get; set; } = default!; + [JsonPropertyName("display")] public required string Display { get; set; } - [JsonPropertyName("value")] public string Value { get; set; } = default!; + [JsonPropertyName("value")] public required string Value { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Inputs/DateInput.cs b/src/Hooki/MicrosoftTeams/Models/Inputs/DateInput.cs index 16c5872..04e9c33 100644 --- a/src/Hooki/MicrosoftTeams/Models/Inputs/DateInput.cs +++ b/src/Hooki/MicrosoftTeams/Models/Inputs/DateInput.cs @@ -3,6 +3,9 @@ namespace Hooki.MicrosoftTeams.Models.Inputs; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#dateinput +/// public class DateInput : InputBase { public override InputType Type => InputType.DateInput; diff --git a/src/Hooki/MicrosoftTeams/Models/Inputs/InputBase.cs b/src/Hooki/MicrosoftTeams/Models/Inputs/InputBase.cs index 4024b1c..284c8cc 100644 --- a/src/Hooki/MicrosoftTeams/Models/Inputs/InputBase.cs +++ b/src/Hooki/MicrosoftTeams/Models/Inputs/InputBase.cs @@ -1,17 +1,19 @@ using System.Text.Json.Serialization; using Hooki.MicrosoftTeams.Enums; +using Hooki.MicrosoftTeams.JsonConverters; namespace Hooki.MicrosoftTeams.Models.Inputs; +[JsonConverter(typeof(InputBaseJsonConverter))] public abstract class InputBase { [JsonPropertyName("@type")] public abstract InputType Type { get; } - [JsonPropertyName("id")] public string Id { get; set; } = default!; + [JsonPropertyName("id")] public required string Id { get; set; } [JsonPropertyName("isRequired")] public bool? IsRequired { get; set; } - [JsonPropertyName("title")] public string Title { get; set; } = default!; + [JsonPropertyName("title")] public required string Title { get; set; } - [JsonPropertyName("value")] public string Value { get; set; } = default!; + [JsonPropertyName("value")] public string? Value { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Inputs/MultiChoiceInput.cs b/src/Hooki/MicrosoftTeams/Models/Inputs/MultiChoiceInput.cs index 429374f..8627ab2 100644 --- a/src/Hooki/MicrosoftTeams/Models/Inputs/MultiChoiceInput.cs +++ b/src/Hooki/MicrosoftTeams/Models/Inputs/MultiChoiceInput.cs @@ -3,13 +3,16 @@ namespace Hooki.MicrosoftTeams.Models.Inputs; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#multichoiceinput +/// public class MultiChoiceInput : InputBase { public override InputType Type => InputType.MultiChoiceInput; - [JsonPropertyName("choices")] public List Choices { get; set; } = []; + [JsonPropertyName("choices")] public required List Choices { get; set; } [JsonPropertyName("isMultiSelect")] public bool? IsMultiSelect { get; set; } - [JsonPropertyName("style")] public string Style { get; set; } = default!; + [JsonPropertyName("style")] public string? Style { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/Inputs/TextInput.cs b/src/Hooki/MicrosoftTeams/Models/Inputs/TextInput.cs index 0795dca..b6ff32c 100644 --- a/src/Hooki/MicrosoftTeams/Models/Inputs/TextInput.cs +++ b/src/Hooki/MicrosoftTeams/Models/Inputs/TextInput.cs @@ -3,13 +3,14 @@ namespace Hooki.MicrosoftTeams.Models.Inputs; +/// +/// Refer to Microsoft Team's documentation for more details: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#textinput +/// public class TextInput : InputBase { public override InputType Type => InputType.TextInput; - [JsonPropertyName("isMultiline")] - public bool? IsMultiline { get; set; } + [JsonPropertyName("isMultiline")] public bool? IsMultiline { get; set; } - [JsonPropertyName("maxLength")] - public int? MaxLength { get; set; } + [JsonPropertyName("maxLength")] public int? MaxLength { get; set; } } \ No newline at end of file diff --git a/src/Hooki/MicrosoftTeams/Models/MessageCard.cs b/src/Hooki/MicrosoftTeams/Models/MessageCard.cs index db1c0de..30c0ff0 100644 --- a/src/Hooki/MicrosoftTeams/Models/MessageCard.cs +++ b/src/Hooki/MicrosoftTeams/Models/MessageCard.cs @@ -9,37 +9,33 @@ namespace Hooki.MicrosoftTeams.Models; /// public class MessageCard { - [JsonPropertyName("@type")] - public string Type { get; set; } = "MessageCard"; + [JsonPropertyName("@type")] public static string Type => "MessageCard"; - [JsonPropertyName("@context")] - public string Context { get; set; } = "https://schema.org/extensions"; + [JsonPropertyName("@context")] public static string Context => "https://schema.org/extensions"; - [JsonPropertyName("correlationId")] public string CorrelationId { get; set; } = default!; + [JsonPropertyName("correlationId")] public string? CorrelationId { get; set; } - [JsonPropertyName("expectedActors")] - public List ExpectedActors { get; set; } = default!; + [JsonPropertyName("expectedActors")] public List? ExpectedActors { get; set; } = null; - [JsonPropertyName("originator")] - public string Originator { get; set; } = default!; + [JsonPropertyName("originator")] public string? Originator { get; set; } - [JsonPropertyName("summary")] - public string Summary { get; set; } = default!; + /// + /// Required when Text has not been provided + /// + [JsonPropertyName("summary")] public string? Summary { get; set; } - [JsonPropertyName("themeColor")] - public string ThemeColor { get; set; } = default!; + [JsonPropertyName("themeColor")] public string? ThemeColor { get; set; } - [JsonPropertyName("hideOriginalBody")] - public bool? HideOriginalBody { get; set; } + [JsonPropertyName("hideOriginalBody")] public bool? HideOriginalBody { get; set; } - [JsonPropertyName("title")] - public string Title { get; set; } = default!; + [JsonPropertyName("title")] public string? Title { get; set; } - [JsonPropertyName("text")] - public string Text { get; set; } = default!; + /// + /// Required when a summary has not been provided + /// + [JsonPropertyName("text")] public string? Text { get; set; } - [JsonPropertyName("sections")] - public List
Sections { get; set; } = default!; - - [JsonPropertyName("potentialAction")] public List PotentialActions { get; set; } = []; + [JsonPropertyName("sections")] public List
? Sections { get; set; } + + [JsonPropertyName("potentialAction")] public List? PotentialActions { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/ActionBlockBuilder.cs b/src/Hooki/Slack/Builders/ActionBlockBuilder.cs new file mode 100644 index 0000000..bff6635 --- /dev/null +++ b/src/Hooki/Slack/Builders/ActionBlockBuilder.cs @@ -0,0 +1,33 @@ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class ActionBlockBuilder : IBlockBuilder +{ + private readonly List _elements = new(); + private string? _blockId; + + public ActionBlockBuilder AddElement(Func elementFactory) where T : IActionBlockElement + { + _elements.Add(elementFactory()); + return this; + } + + public ActionBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public BlockBase Build() + { + if (_elements is null || _elements.Count == 0) + throw new InvalidOperationException("Elements are required"); + + return new ActionBlock + { + BlockId = _blockId, + Elements = _elements + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/BlockElementBaseBuilder.cs b/src/Hooki/Slack/Builders/BlockElementBaseBuilder.cs new file mode 100644 index 0000000..16a2ebd --- /dev/null +++ b/src/Hooki/Slack/Builders/BlockElementBaseBuilder.cs @@ -0,0 +1,23 @@ +using Hooki.Slack.Models.BlockElements; + +namespace Hooki.Slack.Builders; + + +public class BlockElementBaseBuilder +{ + private string? _actionId; + + public BlockElementBaseBuilder WithActionId(string actionId) + { + _actionId = actionId; + return this; + } + + public virtual BlockElementBase Build() + { + return new BlockElementBase + { + ActionId = _actionId + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/ButtonBlockElementBuilder.cs b/src/Hooki/Slack/Builders/ButtonBlockElementBuilder.cs new file mode 100644 index 0000000..b9ee403 --- /dev/null +++ b/src/Hooki/Slack/Builders/ButtonBlockElementBuilder.cs @@ -0,0 +1,71 @@ +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class ButtonElementBuilder : BlockElementBaseBuilder +{ + private TextObject? _text; + private string? _url; + private string? _value; + private string? _style; + private ConfirmationDialogObject? _confirm; + private string? _accessibilityLabel; + + public ButtonElementBuilder WithText(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _text = builder.Build(); + return this; + } + + public ButtonElementBuilder WithUrl(string url) + { + _url = url; + return this; + } + + public ButtonElementBuilder WithValue(string value) + { + _value = value; + return this; + } + + public ButtonElementBuilder WithStyle(string style) + { + _style = style; + return this; + } + + public ButtonElementBuilder WithConfirm(Action buildAction) + { + var builder = new ConfirmationDialogObjectBuilder(); + buildAction(builder); + _confirm = builder.Build(); + return this; + } + + public ButtonElementBuilder WithAccessibilityLabel(string accessibilityLabel) + { + _accessibilityLabel = accessibilityLabel; + return this; + } + + public override BlockElementBase Build() + { + if (_text == null) + throw new InvalidOperationException("Text is required for a ButtonElement."); + + return new ButtonElement + { + ActionId = base.Build().ActionId, + Text = _text, + Url = _url, + Value = _value, + Style = _style, + Confirm = _confirm, + AccessibilityLabel = _accessibilityLabel + }; + } +} diff --git a/src/Hooki/Slack/Builders/ConfirmationDialogObjectBuilder.cs b/src/Hooki/Slack/Builders/ConfirmationDialogObjectBuilder.cs new file mode 100644 index 0000000..c12078a --- /dev/null +++ b/src/Hooki/Slack/Builders/ConfirmationDialogObjectBuilder.cs @@ -0,0 +1,71 @@ +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class ConfirmationDialogObjectBuilder +{ + private TextObject? _title; + private TextObject? _text; + private TextObject? _confirm; + private TextObject? _deny; + private string? _style; + + public ConfirmationDialogObjectBuilder WithTitle(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _title = builder.Build(); + return this; + } + + public ConfirmationDialogObjectBuilder WithText(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _text = builder.Build(); + return this; + } + + public ConfirmationDialogObjectBuilder WithConfirm(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _confirm = builder.Build(); + return this; + } + + public ConfirmationDialogObjectBuilder WithDeny(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _deny = builder.Build(); + return this; + } + + public ConfirmationDialogObjectBuilder WithStyle(string style) + { + _style = style; + return this; + } + + public ConfirmationDialogObject Build() + { + if (_title == null) + throw new InvalidOperationException("Title is required for a ConfirmationDialogObject."); + if (_text == null) + throw new InvalidOperationException("Text is required for a ConfirmationDialogObject."); + if (_confirm == null) + throw new InvalidOperationException("Confirm is required for a ConfirmationDialogObject."); + if (_deny == null) + throw new InvalidOperationException("Deny is required for a ConfirmationDialogObject."); + + return new ConfirmationDialogObject + { + Title = _title, + Text = _text, + Confirm = _confirm, + Deny = _deny, + Style = _style + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/ContextBlockBuilder.cs b/src/Hooki/Slack/Builders/ContextBlockBuilder.cs new file mode 100644 index 0000000..9ddc25f --- /dev/null +++ b/src/Hooki/Slack/Builders/ContextBlockBuilder.cs @@ -0,0 +1,33 @@ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class ContextBlockBuilder : IBlockBuilder +{ + private readonly List _elements = new(); + private string? _blockId; + + public ContextBlockBuilder AddElement(Func elementFactory) where T : IContextBlockElement + { + _elements.Add(elementFactory()); + return this; + } + + public ContextBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public BlockBase Build() + { + if (_elements.Count == 0) + throw new InvalidOperationException("At least one element is required for an ActionBlock."); + + return new ContextBlock + { + BlockId = _blockId, + Elements = _elements + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/DividerBlockBuilder.cs b/src/Hooki/Slack/Builders/DividerBlockBuilder.cs new file mode 100644 index 0000000..432d53e --- /dev/null +++ b/src/Hooki/Slack/Builders/DividerBlockBuilder.cs @@ -0,0 +1,22 @@ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class DividerBlockBuilder : IBlockBuilder +{ + private string? _blockId; + + public DividerBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public BlockBase Build() + { + return new DividerBlock + { + BlockId = _blockId + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/FileBlockBuilder.cs b/src/Hooki/Slack/Builders/FileBlockBuilder.cs new file mode 100644 index 0000000..95485bb --- /dev/null +++ b/src/Hooki/Slack/Builders/FileBlockBuilder.cs @@ -0,0 +1,38 @@ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class FileBlockBuilder : IBlockBuilder +{ + private string _externalId = default!; + private string _source = default!; + private string? _blockId; + + public FileBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public FileBlockBuilder WithExternalId(string externalId) + { + _externalId = externalId; + return this; + } + + public FileBlockBuilder WithSource(string source) + { + _source = source; + return this; + } + + public BlockBase Build() + { + return new FileBlock + { + BlockId = _blockId, + ExternalId = _externalId, + Source = _source + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/HeaderBlockBuilder.cs b/src/Hooki/Slack/Builders/HeaderBlockBuilder.cs new file mode 100644 index 0000000..c6ca866 --- /dev/null +++ b/src/Hooki/Slack/Builders/HeaderBlockBuilder.cs @@ -0,0 +1,34 @@ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class HeaderBlockBuilder : IBlockBuilder +{ + private TextObject? _text; + private string? _blockId; + + public HeaderBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public HeaderBlockBuilder WithText(TextObject text) + { + _text = text; + return this; + } + + public BlockBase Build() + { + if (_text is null) + throw new InvalidOperationException("Text is required"); + + return new HeaderBlock + { + BlockId = _blockId, + Text = _text + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/IBlockBuilder.cs b/src/Hooki/Slack/Builders/IBlockBuilder.cs new file mode 100644 index 0000000..b5e0489 --- /dev/null +++ b/src/Hooki/Slack/Builders/IBlockBuilder.cs @@ -0,0 +1,8 @@ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public interface IBlockBuilder +{ + BlockBase Build(); +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/ImageBlockBuilder.cs b/src/Hooki/Slack/Builders/ImageBlockBuilder.cs new file mode 100644 index 0000000..98489be --- /dev/null +++ b/src/Hooki/Slack/Builders/ImageBlockBuilder.cs @@ -0,0 +1,61 @@ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class ImageBlockBuilder : IBlockBuilder +{ + private string? _altText; + private string? _imageUrl; + private SlackFileObject? _slackFile; + private TextObject? _title; + private string? _blockId; + + public ImageBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public ImageBlockBuilder WithAltText(string altText) + { + _altText = altText; + return this; + } + + public ImageBlockBuilder WithImageUrl(string imageUrl) + { + _imageUrl = imageUrl; + return this; + } + + public ImageBlockBuilder WithSlackFile(SlackFileObject slackFile) + { + _slackFile = slackFile; + return this; + } + + public ImageBlockBuilder WithTitle(TextObject title) + { + _title = title; + return this; + } + + public BlockBase Build() + { + if (_altText is null) + throw new InvalidOperationException("AltText is required"); + + if (_imageUrl is null && _slackFile is null) + throw new InvalidOperationException("Either ImageUrl or SlackUrl need to be provided"); + + return new ImageBlock + { + BlockId = _blockId, + AltText = _altText, + ImageUrl = _imageUrl, + SlackFile = _slackFile, + Title = _title, + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/ImageBlockElementBuilder.cs b/src/Hooki/Slack/Builders/ImageBlockElementBuilder.cs new file mode 100644 index 0000000..5c1398c --- /dev/null +++ b/src/Hooki/Slack/Builders/ImageBlockElementBuilder.cs @@ -0,0 +1,52 @@ +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class ImageBlockElementBuilder: BlockElementBaseBuilder +{ + private string? _altText; + private string? _imageUrl; + private SlackFileObject? _slackFile; + + public ImageBlockElementBuilder WithAltText(string altText) + { + _altText = altText; + return this; + } + + public ImageBlockElementBuilder WithImageUrl(string imageUrl) + { + if (imageUrl.Length > 3000) + throw new ArgumentException("ImageUrl must not exceed 3000 characters.", nameof(imageUrl)); + + _imageUrl = imageUrl; + return this; + } + + public ImageBlockElementBuilder WithSlackFile(SlackFileObject slackFile) + { + _slackFile = slackFile; + return this; + } + + public override BlockElementBase Build() + { + if (string.IsNullOrWhiteSpace(_altText)) + throw new InvalidOperationException("AltText is required for an ImageElement."); + + if (_imageUrl == null && _slackFile == null) + throw new InvalidOperationException("Either ImageUrl or SlackFile must be provided for an ImageElement."); + + if (_imageUrl != null && _slackFile != null) + throw new InvalidOperationException("Only one of ImageUrl or SlackFile can be provided for an ImageElement."); + + return new ImageElement + { + ActionId = base.Build().ActionId, + AltText = _altText, + ImageUrl = _imageUrl, + SlackFile = _slackFile + }; + } +} diff --git a/src/Hooki/Slack/Builders/InputBlockBuilder.cs b/src/Hooki/Slack/Builders/InputBlockBuilder.cs new file mode 100644 index 0000000..09e81eb --- /dev/null +++ b/src/Hooki/Slack/Builders/InputBlockBuilder.cs @@ -0,0 +1,69 @@ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class InputBlockBuilder : IBlockBuilder +{ + private TextObject? _label; + private IInputBlockElement? _element; + private bool? _dispatchAction; + private TextObject? _hint; + private bool? _optional; + private string? _blockId; + + public InputBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public InputBlockBuilder WithLabel(TextObject label) + { + _label = label; + return this; + } + + public InputBlockBuilder WithElement(Func elementFactory) where T : IInputBlockElement + { + _element = elementFactory(); + return this; + } + + public InputBlockBuilder WithDispatchAction(bool dispatchAction) + { + _dispatchAction = dispatchAction; + return this; + } + + public InputBlockBuilder WithHint(TextObject hint) + { + _hint = hint; + return this; + } + + public InputBlockBuilder WithOptional(bool optional) + { + _optional = optional; + return this; + } + + public BlockBase Build() + { + if (_label is null) + throw new InvalidOperationException("Label must have a value"); + + if (_element is null) + throw new InvalidOperationException("Element must have a value"); + + return new InputBlock + { + BlockId = _blockId, + Label = _label, + Element = _element, + DispatchAction = _dispatchAction, + Hint = _hint, + Optional = _optional + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/MultiSelectMenuBlockElementBuilder.cs b/src/Hooki/Slack/Builders/MultiSelectMenuBlockElementBuilder.cs new file mode 100644 index 0000000..a9a43c2 --- /dev/null +++ b/src/Hooki/Slack/Builders/MultiSelectMenuBlockElementBuilder.cs @@ -0,0 +1,85 @@ +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class MultiSelectMenuBlockElementBuilder +{ + private TextObject? _placeholder; + private readonly List _options = []; + private readonly List _initialOptions = []; + private readonly List _optionGroups = []; + private ConfirmationDialogObject? _confirm; + private bool? _focusOnLoad; + private int? _maxSelectedItems; + private string? _actionId; + + public MultiSelectMenuBlockElementBuilder WithActionId(string actionId) + { + _actionId = actionId; + return this; + } + + public MultiSelectMenuBlockElementBuilder WithPlaceholder(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _placeholder = builder.Build(); + return this; + } + + public MultiSelectMenuBlockElementBuilder AddOption(OptionObject option) + { + _options.Add(option); + return this; + } + + public MultiSelectMenuBlockElementBuilder AddInitialOption(OptionObject option) + { + _initialOptions.Add(option); + return this; + } + + public MultiSelectMenuBlockElementBuilder AddOptionGroup(OptionGroupObject optionGroup) + { + _optionGroups.Add(optionGroup); + return this; + } + + public MultiSelectMenuBlockElementBuilder WithConfirm(ConfirmationDialogObject confirmation) + { + _confirm = confirmation; + return this; + } + + public MultiSelectMenuBlockElementBuilder WithFocusOnLoad(bool focusOnLoad) + { + _focusOnLoad = focusOnLoad; + return this; + } + + public MultiSelectMenuBlockElementBuilder WithMaxSelectedItems(int maxSelectedItems) + { + _maxSelectedItems = maxSelectedItems; + return this; + } + + public IActionBlockElement Build() + { + if (_options.Count == 0 && _optionGroups.Count == 0) + throw new InvalidOperationException("Either options or option groups must be provided for a MultiSelectMenuElement."); + + return new MultiSelectMenuElement + { + ActionId = _actionId, + Placeholder = _placeholder, + Options = (_options.Count > 0 ? _options.ToArray() : null) ?? Array.Empty(), + InitialOptions = _initialOptions.Count > 0 ? _initialOptions.ToArray() : null, + OptionGroups = _optionGroups.Count > 0 ? _optionGroups.ToArray() : null, + Confirm = _confirm, + FocusOnLoad = _focusOnLoad, + MaxSelectedItems = _maxSelectedItems + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/RichTextBlockBuilder.cs b/src/Hooki/Slack/Builders/RichTextBlockBuilder.cs new file mode 100644 index 0000000..6bc264a --- /dev/null +++ b/src/Hooki/Slack/Builders/RichTextBlockBuilder.cs @@ -0,0 +1,33 @@ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class RichTextBlockBuilder : IBlockBuilder +{ + private readonly List _elements = new(); + private string? _blockId; + + public RichTextBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public RichTextBlockBuilder AddElement(Func elementFactory) where T : IRichTextBlockElement + { + _elements.Add(elementFactory()); + return this; + } + + public BlockBase Build() + { + if (_elements.Count == 0) + throw new InvalidOperationException("At least one element is required for a RichTextBlock."); + + return new RichTextBlock + { + BlockId = _blockId, + Elements = _elements + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/SectionBlockBuilder.cs b/src/Hooki/Slack/Builders/SectionBlockBuilder.cs new file mode 100644 index 0000000..76245aa --- /dev/null +++ b/src/Hooki/Slack/Builders/SectionBlockBuilder.cs @@ -0,0 +1,63 @@ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class SectionBlockBuilder : IBlockBuilder +{ + private TextObject? _text; + private List? _fields; + private ISectionBlockElement? _accessory; + private bool? _expand; + private string? _blockId; + + public SectionBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public SectionBlockBuilder WithText(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _text = builder.Build(); + return this; + } + + public SectionBlockBuilder AddField(Action buildAction) + { + _fields ??= new List(); + var builder = new TextObjectBuilder(); + buildAction(builder); + _fields.Add(builder.Build()); + return this; + } + + public SectionBlockBuilder WithAccessory(Func accessoryFactory) where T : ISectionBlockElement + { + _accessory = accessoryFactory(); + return this; + } + + public SectionBlockBuilder WithExpand(bool expand) + { + _expand = expand; + return this; + } + + public BlockBase Build() + { + if (_text == null && (_fields == null || _fields.Count == 0)) + throw new InvalidOperationException("Either text or at least one field is required for a SectionBlock."); + + return new SectionBlock + { + BlockId = _blockId, + Text = _text, + Fields = _fields?.ToArray(), + Accessory = _accessory, + Expand = _expand + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/SlackWebhookPayloadBuilder.cs b/src/Hooki/Slack/Builders/SlackWebhookPayloadBuilder.cs new file mode 100644 index 0000000..4f1c8d3 --- /dev/null +++ b/src/Hooki/Slack/Builders/SlackWebhookPayloadBuilder.cs @@ -0,0 +1,78 @@ +using Hooki.Slack.Models; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class SlackWebhookPayloadBuilder +{ + private readonly List _blocks = new(); + + public SlackWebhookPayloadBuilder AddActionBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddContextBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddDividerBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddFileBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddHeaderBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddImageBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddInputBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddRichTextBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddSectionBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + public SlackWebhookPayloadBuilder AddVideoBlock(Action buildAction) + { + return AddBlock(buildAction); + } + + private SlackWebhookPayloadBuilder AddBlock(Action buildAction) where T : IBlockBuilder, new() + { + var builder = new T(); + buildAction(builder); + _blocks.Add(builder.Build()); + return this; + } + + public SlackWebhookPayload Build() + { + if (_blocks.Count == 0) + throw new InvalidOperationException("At least one block is required."); + + return new SlackWebhookPayload + { + Blocks = _blocks + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/TextObjectBuilder.cs b/src/Hooki/Slack/Builders/TextObjectBuilder.cs new file mode 100644 index 0000000..08bdc9a --- /dev/null +++ b/src/Hooki/Slack/Builders/TextObjectBuilder.cs @@ -0,0 +1,59 @@ +using Hooki.Slack.Enums; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class TextObjectBuilder +{ + private TextObjectType? _type; + private string? _text; + private bool? _emoji; + private bool? _verbatim; + + public TextObjectBuilder WithType(TextObjectType type) + { + _type = type; + return this; + } + + public TextObjectBuilder WithText(string text) + { + _text = text; + return this; + } + + public TextObjectBuilder WithEmoji(bool emoji) + { + _emoji = emoji; + return this; + } + + public TextObjectBuilder WithVerbatim(bool verbatim) + { + _verbatim = verbatim; + return this; + } + + public TextObject Build() + { + if (_type == null) + throw new InvalidOperationException("Type is required for a TextObject."); + + if (string.IsNullOrWhiteSpace(_text)) + throw new InvalidOperationException("Text is required for a TextObject."); + + if (_type == TextObjectType.Markdown && _emoji.HasValue) + throw new InvalidOperationException("Emoji can only be set when Type is PlainText."); + + if (_type == TextObjectType.PlainText && _verbatim.HasValue) + throw new InvalidOperationException("Verbatim can only be set when Type is Markdown."); + + return new TextObject + { + Type = _type.Value, + Text = _text, + Emoji = _emoji, + Verbatim = _verbatim + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Builders/VideoBlockBuilder.cs b/src/Hooki/Slack/Builders/VideoBlockBuilder.cs new file mode 100644 index 0000000..cd1e070 --- /dev/null +++ b/src/Hooki/Slack/Builders/VideoBlockBuilder.cs @@ -0,0 +1,121 @@ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; +using Hooki.Slack.Enums; + +namespace Hooki.Slack.Builders; + +public class VideoBlockBuilder : IBlockBuilder +{ + private string? _altText; + private string? _authorName; + private TextObject? _description; + private string? _providerIconUrl; + private string? _providerName; + private TextObject? _title; + private string? _titleUrl; + private string? _thumbnailUrl; + private string? _videoUrl; + private string? _blockId; + + public VideoBlockBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public VideoBlockBuilder WithAltText(string altText) + { + _altText = altText; + return this; + } + + public VideoBlockBuilder WithAuthorName(string authorName) + { + _authorName = authorName; + return this; + } + + public VideoBlockBuilder WithDescription(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _description = builder.Build(); + return this; + } + + public VideoBlockBuilder WithProviderIconUrl(string providerIconUrl) + { + _providerIconUrl = providerIconUrl; + return this; + } + + public VideoBlockBuilder WithProviderName(string providerName) + { + _providerName = providerName; + return this; + } + + public VideoBlockBuilder WithTitle(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _title = builder.Build(); + return this; + } + + public VideoBlockBuilder WithTitleUrl(string titleUrl) + { + if (!titleUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Title URL must start with 'https://'", nameof(titleUrl)); + + _titleUrl = titleUrl; + return this; + } + + public VideoBlockBuilder WithThumbnailUrl(string thumbnailUrl) + { + _thumbnailUrl = thumbnailUrl; + return this; + } + + public VideoBlockBuilder WithVideoUrl(string videoUrl) + { + _videoUrl = videoUrl; + return this; + } + + public BlockBase Build() + { + if (string.IsNullOrWhiteSpace(_altText)) + throw new InvalidOperationException("AltText is required for a VideoBlock."); + + if (_description == null) + throw new InvalidOperationException("Description is required for a VideoBlock."); + + if (_description.Type != TextObjectType.PlainText) + throw new InvalidOperationException("Description must be of type PlainText."); + + if (_title != null && _title.Type != TextObjectType.PlainText) + throw new InvalidOperationException("Title must be of type PlainText."); + + if (string.IsNullOrWhiteSpace(_thumbnailUrl)) + throw new InvalidOperationException("ThumbnailUrl is required for a VideoBlock."); + + if (string.IsNullOrWhiteSpace(_videoUrl)) + throw new InvalidOperationException("VideoUrl is required for a VideoBlock."); + + return new VideoBlock + { + BlockId = _blockId, + AltText = _altText, + AuthorName = _authorName, + Description = _description, + ProviderIconUrl = _providerIconUrl, + ProviderName = _providerName, + Title = _title, + TitleUrl = _titleUrl, + ThumbnailUrl = _thumbnailUrl, + VideoUrl = _videoUrl + }; + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Enums/RichTextObjectTypes.cs b/src/Hooki/Slack/Enums/RichTextBlockElementType.cs similarity index 94% rename from src/Hooki/Slack/Enums/RichTextObjectTypes.cs rename to src/Hooki/Slack/Enums/RichTextBlockElementType.cs index 01546c4..4f055df 100644 --- a/src/Hooki/Slack/Enums/RichTextObjectTypes.cs +++ b/src/Hooki/Slack/Enums/RichTextBlockElementType.cs @@ -5,7 +5,7 @@ namespace Hooki.Slack.Enums; //ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs [JsonConverter(typeof(JsonStringEnumMemberConverter))] -public enum RichTextObjectTypes +public enum RichTextBlockElementType { [EnumMember(Value = "rich_text_section")] RichTextSection, diff --git a/src/Hooki/Slack/Enums/RichTextElementType.cs b/src/Hooki/Slack/Enums/RichTextElementType.cs new file mode 100644 index 0000000..19a511c --- /dev/null +++ b/src/Hooki/Slack/Enums/RichTextElementType.cs @@ -0,0 +1,33 @@ +using System.Runtime.Serialization; + +namespace Hooki.Slack.Enums; + +public enum RichTextElementType +{ + [EnumMember(Value = "broadcast")] + Broadcast, + + [EnumMember(Value = "color")] + Color, + + [EnumMember(Value = "channel")] + Channel, + + [EnumMember(Value = "date")] + Date, + + [EnumMember(Value = "emoji")] + Emoji, + + [EnumMember(Value = "link")] + Link, + + [EnumMember(Value = "text")] + Text, + + [EnumMember(Value = "user")] + User, + + [EnumMember(Value = "usergroup")] + UserGroup, +} \ No newline at end of file diff --git a/src/Hooki/Slack/Enums/RichTextListStyleType.cs b/src/Hooki/Slack/Enums/RichTextListStyleType.cs new file mode 100644 index 0000000..5cd3d81 --- /dev/null +++ b/src/Hooki/Slack/Enums/RichTextListStyleType.cs @@ -0,0 +1,15 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum RichTextListStyleType +{ + [EnumMember(Value = "bullet")] + Bullet, + [EnumMember(Value = "ordered")] + Ordered +} + diff --git a/src/Hooki/Slack/Enums/TextObjectTypes.cs b/src/Hooki/Slack/Enums/TextObjectType.cs similarity index 94% rename from src/Hooki/Slack/Enums/TextObjectTypes.cs rename to src/Hooki/Slack/Enums/TextObjectType.cs index f5690c8..b1c82c6 100644 --- a/src/Hooki/Slack/Enums/TextObjectTypes.cs +++ b/src/Hooki/Slack/Enums/TextObjectType.cs @@ -5,7 +5,7 @@ namespace Hooki.Slack.Enums; //ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs [JsonConverter(typeof(JsonStringEnumMemberConverter))] -public enum TextObjectTypes +public enum TextObjectType { [EnumMember(Value = "plain_text")] PlainText, diff --git a/src/Hooki/Slack/Enums/WorkflowButtonElementStyle.cs b/src/Hooki/Slack/Enums/WorkflowButtonElementStyle.cs new file mode 100644 index 0000000..a2e2300 --- /dev/null +++ b/src/Hooki/Slack/Enums/WorkflowButtonElementStyle.cs @@ -0,0 +1,21 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum WorkflowButtonElementStyle +{ + /// + /// Green styling used for affirmation or confirmation actions + /// + [EnumMember(Value = "primary")] + Primary, + + /// + /// Red styling used for destructive actions + /// + [EnumMember(Value = "danger")] + Danger +} \ No newline at end of file diff --git a/src/Hooki/Slack/JsonConverters/ActionBlockElementConverter.cs b/src/Hooki/Slack/JsonConverters/ActionBlockElementConverter.cs new file mode 100644 index 0000000..dd6cf99 --- /dev/null +++ b/src/Hooki/Slack/JsonConverters/ActionBlockElementConverter.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.JsonConverters; + +public class ActionBlockElementConverter : JsonConverter> +{ + public override List? Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + return default; + + List actionBlocks = default!; + + foreach (var jsonObject in JsonSerializer.Deserialize>(ref reader, options)!) + { + IActionBlockElement? item = jsonObject["Type"]?.GetValue() switch + { + "button" => jsonObject.Deserialize(options), + "checkboxes" => jsonObject.Deserialize(options), + "datepicker" => jsonObject.Deserialize(options), + "datetimepicker" => jsonObject.Deserialize(options), + "multi_static_select" => jsonObject.Deserialize(options), + "overflow" => jsonObject.Deserialize(options), + "radio_buttons" => jsonObject.Deserialize(options), + "rich_text_input" => jsonObject.Deserialize(options), + "static_select" => jsonObject.Deserialize(options), + "timepicker" => jsonObject.Deserialize(options), + "workflow_button" => jsonObject.Deserialize(options), + _ => null + }; + + if (item is null) continue; + + actionBlocks ??= new(); + actionBlocks.Add(item); + } + + return actionBlocks; + } + + public override void Write(Utf8JsonWriter writer, List values, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var value in values) + { + switch (value) + { + case ButtonElement button: + JsonSerializer.Serialize(writer, button, options); + break; + case CheckboxElement checkbox: + JsonSerializer.Serialize(writer, checkbox, options); + break; + case DatePickerElement datePicker: + JsonSerializer.Serialize(writer, datePicker, options); + break; + case DateTimePickerElement dateTimePicker: + JsonSerializer.Serialize(writer, dateTimePicker, options); + break; + case MultiSelectMenuElement multiSelect: + JsonSerializer.Serialize(writer, multiSelect, options); + break; + case OverflowMenuElement overflow: + JsonSerializer.Serialize(writer, overflow, options); + break; + case RadioButtonGroupElement radioButton: + JsonSerializer.Serialize(writer, radioButton, options); + break; + case RichTextInputElement richTextInput: + JsonSerializer.Serialize(writer, richTextInput, options); + break; + case SelectMenuElement select: + JsonSerializer.Serialize(writer, select, options); + break; + case TimePickerElement timePicker: + JsonSerializer.Serialize(writer, timePicker, options); + break; + case WorkflowButtonElement workflowButton: + JsonSerializer.Serialize(writer, workflowButton, options); + break; + default: + throw new JsonException($"Invalid action block element type: {value.GetType()}"); + } + } + writer.WriteEndArray(); + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/JsonConverters/BlockBaseConverter.cs b/src/Hooki/Slack/JsonConverters/BlockBaseConverter.cs index 737b162..7b13eda 100644 --- a/src/Hooki/Slack/JsonConverters/BlockBaseConverter.cs +++ b/src/Hooki/Slack/JsonConverters/BlockBaseConverter.cs @@ -8,54 +8,74 @@ namespace Hooki.Slack.JsonConverters; public class BlockBaseConverter : JsonConverter { - public override BlockBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override BlockBase? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - throw new NotImplementedException("Deserialization is not implemented for this converter."); - } - - public override void Write(Utf8JsonWriter writer, BlockBase value, JsonSerializerOptions options) - { - writer.WriteStartObject(); + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("JSON object expected."); + } - // Write the "type" property as a string - writer.WriteString("type", GetBlockTypeJsonValue(value.Type)); + using var jsonDoc = JsonDocument.ParseValue(ref reader); + var root = jsonDoc.RootElement; - // Serialize other properties - foreach (var property in value.GetType().GetProperties()) + if (!root.TryGetProperty("type", out var typeProperty)) { - if (property.Name == nameof(BlockBase.Type)) continue; - - var propertyValue = property.GetValue(value); - - if (propertyValue == null) continue; - - var propertyName = property.GetCustomAttribute()?.Name - ?? options.PropertyNamingPolicy?.ConvertName(property.Name) - ?? property.Name; - - - writer.WritePropertyName(propertyName); - JsonSerializer.Serialize(writer, propertyValue, property.PropertyType, options); + throw new JsonException("Missing 'type' property"); } - writer.WriteEndObject(); + var typeString = typeProperty.GetString(); + return typeString switch + { + "actions" => JsonSerializer.Deserialize(root.GetRawText(), options), + "context" => JsonSerializer.Deserialize(root.GetRawText(), options), + "divider" => JsonSerializer.Deserialize(root.GetRawText(), options), + "file" => JsonSerializer.Deserialize(root.GetRawText(), options), + "header" => JsonSerializer.Deserialize(root.GetRawText(), options), + "image" => JsonSerializer.Deserialize(root.GetRawText(), options), + "input" => JsonSerializer.Deserialize(root.GetRawText(), options), + "rich_text" => JsonSerializer.Deserialize(root.GetRawText(), options), + "section" => JsonSerializer.Deserialize(root.GetRawText(), options), + "video" => JsonSerializer.Deserialize(root.GetRawText(), options), + _ => throw new JsonException($"Unknown block type: {typeString}") + }; } - - private static string GetBlockTypeJsonValue(BlockType blockType) + + public override void Write(Utf8JsonWriter writer, BlockBase value, JsonSerializerOptions options) { - return blockType switch + switch (value.Type) { - BlockType.ActionBlock => "actions", - BlockType.ContextBlock => "context", - BlockType.DividerBlock => "divider", - BlockType.FileBlock => "file", - BlockType.HeaderBlock => "header", - BlockType.ImageBlock => "image", - BlockType.InputBlock => "input", - BlockType.RichTextBlock => "rich_text", - BlockType.SectionBlock => "section", - BlockType.VideoBlock => "video", - _ => throw new ArgumentOutOfRangeException(nameof(blockType), blockType, null) - }; + case BlockType.ActionBlock: + JsonSerializer.Serialize(writer, value as ActionBlock, options); + break; + case BlockType.ContextBlock: + JsonSerializer.Serialize(writer, value as ContextBlock, options); + break; + case BlockType.DividerBlock: + JsonSerializer.Serialize(writer, value as DividerBlock, options); + break; + case BlockType.FileBlock: + JsonSerializer.Serialize(writer, value as FileBlock, options); + break; + case BlockType.HeaderBlock: + JsonSerializer.Serialize(writer, value as HeaderBlock, options); + break; + case BlockType.ImageBlock: + JsonSerializer.Serialize(writer, value as ImageBlock, options); + break; + case BlockType.InputBlock: + JsonSerializer.Serialize(writer, value as InputBlock, options); + break; + case BlockType.RichTextBlock: + JsonSerializer.Serialize(writer, value as RichTextBlock, options); + break; + case BlockType.SectionBlock: + JsonSerializer.Serialize(writer, value as SectionBlock, options); + break; + case BlockType.VideoBlock: + JsonSerializer.Serialize(writer, value as VideoBlock, options); + break; + default: + throw new ArgumentOutOfRangeException(); + } } } \ No newline at end of file diff --git a/src/Hooki/Slack/JsonConverters/BlockElementBaseConverter.cs b/src/Hooki/Slack/JsonConverters/BlockElementBaseConverter.cs deleted file mode 100644 index 8539d0e..0000000 --- a/src/Hooki/Slack/JsonConverters/BlockElementBaseConverter.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Serialization; -using Hooki.Slack.Enums; -using Hooki.Slack.Models.BlockElements; -using Hooki.Slack.Models.Blocks; - -namespace Hooki.Slack.JsonConverters; - -public class BlockElementBaseConverter : JsonConverter -{ - public override BlockElementBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException("Deserialization is not implemented for this converter."); - } - - public override void Write(Utf8JsonWriter writer, BlockElementBase value, JsonSerializerOptions options) - { - writer.WriteStartObject(); - - // Write the "type" property as a string - writer.WriteString("type", GetBlockElementTypeJsonValue(value.Type)); - - // Serialize other properties - foreach (var property in value.GetType().GetProperties()) - { - if (property.Name == nameof(BlockBase.Type)) continue; - - var propertyValue = property.GetValue(value); - - if (propertyValue == null) continue; - - var propertyName = property.GetCustomAttribute()?.Name - ?? options.PropertyNamingPolicy?.ConvertName(property.Name) - ?? property.Name; - - writer.WritePropertyName(propertyName); - JsonSerializer.Serialize(writer, propertyValue, property.PropertyType, options); - } - - writer.WriteEndObject(); - } - - private static string GetBlockElementTypeJsonValue(BlockElementType blockElementType) - { - return blockElementType switch - { - BlockElementType.Button => nameof(BlockElementType.Button).ToLower(), - BlockElementType.Checkboxes => nameof(BlockElementType.Checkboxes).ToLower(), - BlockElementType.DatePicker => nameof(BlockElementType.DatePicker).ToLower(), - BlockElementType.DatetimePicker => nameof(BlockElementType.DatetimePicker).ToLower(), - BlockElementType.EmailInput => "email_text_input", - BlockElementType.FileInput => "file_input", - BlockElementType.Image => nameof(BlockElementType.Image).ToLower(), - BlockElementType.MultiSelectMenu => "multi_static_select", - BlockElementType.NumberInput => "number_input", - BlockElementType.PlainTextInput => "plain_text_input", - BlockElementType.RadioButtonGroup => "radio_buttons", - BlockElementType.RichTextInput => "rich_text_input", - BlockElementType.SelectMenu => "static_select", - BlockElementType.TimePicker => "timepicker", - BlockElementType.UrlInput => "url_text_input", - BlockElementType.WorkflowButton => "workflow_button", - BlockElementType.OverflowMenu => "overflow", - _ => throw new ArgumentOutOfRangeException(nameof(blockElementType), blockElementType, null) - }; - } -} \ No newline at end of file diff --git a/src/Hooki/Slack/JsonConverters/ContextBlockElementConverter.cs b/src/Hooki/Slack/JsonConverters/ContextBlockElementConverter.cs new file mode 100644 index 0000000..52df87a --- /dev/null +++ b/src/Hooki/Slack/JsonConverters/ContextBlockElementConverter.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.JsonConverters; + +public class ContextBlockElementConverter : JsonConverter> +{ + public override List? Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + return default; + + List contextBlocks = default!; + + foreach (var jsonObject in JsonSerializer.Deserialize>(ref reader, options)!) + { + IContextBlockElement? item = jsonObject["Type"]?.GetValue() switch + { + "image" => jsonObject.Deserialize(options), + "plain_text" or "mrkdwn" => jsonObject.Deserialize(options), + _ => null + }; + + if (item is null) continue; + + contextBlocks ??= new(); + contextBlocks.Add(item); + } + + return contextBlocks; + } + + public override void Write(Utf8JsonWriter writer, List values, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var value in values) + { + switch (value) + { + case ImageElement image: + JsonSerializer.Serialize(writer, image, options); + break; + case TextObject text: + JsonSerializer.Serialize(writer, text, options); + break; + default: + throw new JsonException($"Invalid action block element type: {value.GetType()}"); + } + } + writer.WriteEndArray(); + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/JsonConverters/InputBlockElementConverter.cs b/src/Hooki/Slack/JsonConverters/InputBlockElementConverter.cs new file mode 100644 index 0000000..a8eb2e9 --- /dev/null +++ b/src/Hooki/Slack/JsonConverters/InputBlockElementConverter.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.JsonConverters; + +public class InputBlockElementConverter : JsonConverter +{ + public override IInputBlockElement? Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + return default; + + var jsonObject = JsonSerializer.Deserialize(ref reader, options); + + IInputBlockElement? item = jsonObject?["Type"]?.GetValue() switch + { + "checkboxes" => jsonObject.Deserialize(options), + "datepicker" => jsonObject.Deserialize(options), + "datetimepicker" => jsonObject.Deserialize(options), + "email_text_input" => jsonObject.Deserialize(options), + "file_input" => jsonObject.Deserialize(options), + "multi_static_select" => jsonObject.Deserialize(options), + "number_input" => jsonObject.Deserialize(options), + "plain_text_input" => jsonObject.Deserialize(options), + "radio_buttons" => jsonObject.Deserialize(options), + "rich_text_input" => jsonObject.Deserialize(options), + "static_select" => jsonObject.Deserialize(options), + "timepicker" => jsonObject.Deserialize(options), + "url_text_input" => jsonObject.Deserialize(options), + _ => null + }; + + return item; + } + + public override void Write(Utf8JsonWriter writer, IInputBlockElement value, JsonSerializerOptions options) + { + switch (value) + { + case CheckboxElement checkbox: + JsonSerializer.Serialize(writer, checkbox, options); + break; + case DatePickerElement datePicker: + JsonSerializer.Serialize(writer, datePicker, options); + break; + case DateTimePickerElement dateTimePicker: + JsonSerializer.Serialize(writer, dateTimePicker, options); + break; + case EmailInputElement emailInput: + JsonSerializer.Serialize(writer, emailInput, options); + break; + case FileInputElement fileInput: + JsonSerializer.Serialize(writer, fileInput, options); + break; + case MultiSelectMenuElement multiSelectMenu: + JsonSerializer.Serialize(writer, multiSelectMenu, options); + break; + case NumberInputElement numberInput: + JsonSerializer.Serialize(writer, numberInput, options); + break; + case PlainTextInputElement plainText: + JsonSerializer.Serialize(writer, plainText, options); + break; + case RadioButtonGroupElement radioButton: + JsonSerializer.Serialize(writer, radioButton, options); + break; + case RichTextInputElement richText: + JsonSerializer.Serialize(writer, richText, options); + break; + case SelectMenuElement selectMenu: + JsonSerializer.Serialize(writer, selectMenu, options); + break; + case TimePickerElement timePicker: + JsonSerializer.Serialize(writer, timePicker, options); + break; + case UrlInputElement urlInput: + JsonSerializer.Serialize(writer, urlInput, options); + break; + default: + throw new JsonException($"Invalid action block element type: {value.GetType()}"); + } + } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/BlockElements/BlockElementBase.cs b/src/Hooki/Slack/Models/BlockElements/BlockElementBase.cs index 34ec93c..940f5cb 100644 --- a/src/Hooki/Slack/Models/BlockElements/BlockElementBase.cs +++ b/src/Hooki/Slack/Models/BlockElements/BlockElementBase.cs @@ -1,15 +1,8 @@ using System.Text.Json.Serialization; -using Hooki.Slack.Enums; -using Hooki.Slack.JsonConverters; namespace Hooki.Slack.Models.BlockElements; -[JsonConverter(typeof(BlockElementBaseConverter))] -public abstract class BlockElementBase +public class BlockElementBase { - [JsonPropertyName("type")] - public abstract BlockElementType Type { get; } - - [JsonPropertyName("action_id")] - public string? ActionId { get; set; } + [JsonPropertyName("action_id")] public string? ActionId { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/BlockElements/ButtonElement.cs b/src/Hooki/Slack/Models/BlockElements/ButtonElement.cs index 9506ebc..e152f86 100644 --- a/src/Hooki/Slack/Models/BlockElements/ButtonElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/ButtonElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#button /// -public class ButtonElement : BlockElementBase +public class ButtonElement : BlockElementBase, IActionBlockElement, ISectionBlockElement { - public override BlockElementType Type => BlockElementType.Button; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.Button; [JsonPropertyName("text")] public required TextObject Text { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/CheckboxElement.cs b/src/Hooki/Slack/Models/BlockElements/CheckboxElement.cs index 79c76b1..ad220b7 100644 --- a/src/Hooki/Slack/Models/BlockElements/CheckboxElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/CheckboxElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,10 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#checkboxes /// -public class CheckboxElement : BlockElementBase + +public class CheckboxElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement { - public override BlockElementType Type => BlockElementType.Checkboxes; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.Checkboxes; [JsonPropertyName("options")] public required List Options { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/DatePickerElement.cs b/src/Hooki/Slack/Models/BlockElements/DatePickerElement.cs index 18fab1f..4104634 100644 --- a/src/Hooki/Slack/Models/BlockElements/DatePickerElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/DatePickerElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#datepicker /// -public class DatePickerElement : BlockElementBase +public class DatePickerElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement { - public override BlockElementType Type => BlockElementType.DatePicker; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.DatePicker; /// /// Format YYYY-MM-DD diff --git a/src/Hooki/Slack/Models/BlockElements/DateTimePickerElement.cs b/src/Hooki/Slack/Models/BlockElements/DateTimePickerElement.cs index a4baaae..3630025 100644 --- a/src/Hooki/Slack/Models/BlockElements/DateTimePickerElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/DateTimePickerElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#datetimepicker /// -public class DateTimePickerElement : BlockElementBase +public class DateTimePickerElement : BlockElementBase, IActionBlockElement, IInputBlockElement { - public override BlockElementType Type => BlockElementType.DatetimePicker; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.DatetimePicker; /// /// Form as UNIX timestamp in seconds. Here is an example: 1628633820 diff --git a/src/Hooki/Slack/Models/BlockElements/EmailInputElement.cs b/src/Hooki/Slack/Models/BlockElements/EmailInputElement.cs index e6394f1..b34b1cb 100644 --- a/src/Hooki/Slack/Models/BlockElements/EmailInputElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/EmailInputElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#email /// -public class EmailInputElement : BlockElementBase +public class EmailInputElement : BlockElementBase, IInputBlockElement { - public override BlockElementType Type => BlockElementType.EmailInput; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.EmailInput; [JsonPropertyName("initial_value")] public string? InitialValue { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/FileInputElement.cs b/src/Hooki/Slack/Models/BlockElements/FileInputElement.cs index b875c60..3448518 100644 --- a/src/Hooki/Slack/Models/BlockElements/FileInputElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/FileInputElement.cs @@ -1,14 +1,15 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#file_input /// -public class FileInputElement : BlockElementBase +public class FileInputElement : BlockElementBase, IInputBlockElement { - public override BlockElementType Type => BlockElementType.FileInput; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.FileInput; [JsonPropertyName("filetypes")] public List? FileTypes { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/ImageElement.cs b/src/Hooki/Slack/Models/BlockElements/ImageElement.cs index 83aebc8..b80ef16 100644 --- a/src/Hooki/Slack/Models/BlockElements/ImageElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/ImageElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,13 +8,21 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#image /// -public class ImageElement : BlockElementBase +public class ImageElement : BlockElementBase, IContextBlockElement, ISectionBlockElement { - public override BlockElementType Type { get; } = BlockElementType.Image; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.Image; [JsonPropertyName("alt_text")] public required string AltText { get; set; } + /// + /// You must provide either an ImageUrl or SlackFile + /// Maximum length is 3000 characters + /// [JsonPropertyName("image_url")] public string? ImageUrl { get; set; } - + + /// + /// You must provide either an SlackFile or ImageUrl + /// Refer to Discord's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#slack_file + /// [JsonPropertyName("slack_file")] public SlackFileObject? SlackFile { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/BlockElements/MultiSelectMenuElement.cs b/src/Hooki/Slack/Models/BlockElements/MultiSelectMenuElement.cs index b9dc04c..671a982 100644 --- a/src/Hooki/Slack/Models/BlockElements/MultiSelectMenuElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/MultiSelectMenuElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#multi_select /// -public class MultiSelectMenuElement : BlockElementBase +public class MultiSelectMenuElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement { - public override BlockElementType Type => BlockElementType.MultiSelectMenu; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.MultiSelectMenu; [JsonPropertyName("placeholder")] public TextObject? Placeholder { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/NumberInputElement.cs b/src/Hooki/Slack/Models/BlockElements/NumberInputElement.cs index 529a983..f3ca068 100644 --- a/src/Hooki/Slack/Models/BlockElements/NumberInputElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/NumberInputElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#number /// -public class NumberInputElement : BlockElementBase +public class NumberInputElement : BlockElementBase, IInputBlockElement { - public override BlockElementType Type =>BlockElementType.NumberInput; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.NumberInput; [JsonPropertyName("is_decimal_allowed")] public required bool IsDecimalAllowed { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/OverflowMenuElement.cs b/src/Hooki/Slack/Models/BlockElements/OverflowMenuElement.cs index 3817ae6..c6390f5 100644 --- a/src/Hooki/Slack/Models/BlockElements/OverflowMenuElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/OverflowMenuElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#overflow /// -public class OverflowMenuElement : BlockElementBase +public class OverflowMenuElement : BlockElementBase, IActionBlockElement, ISectionBlockElement { - public override BlockElementType Type => BlockElementType.OverflowMenu; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.OverflowMenu; [JsonPropertyName("options")] public required List Options { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/PlainTextInputElement.cs b/src/Hooki/Slack/Models/BlockElements/PlainTextInputElement.cs index 3e85f5e..19365b8 100644 --- a/src/Hooki/Slack/Models/BlockElements/PlainTextInputElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/PlainTextInputElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#input /// -public class PlainTextInputElement : BlockElementBase +public class PlainTextInputElement : BlockElementBase, IInputBlockElement { - public override BlockElementType Type => BlockElementType.PlainTextInput; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.PlainTextInput; [JsonPropertyName("initial_value")] public string? InitialValue { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/RadioButtonGroupElement.cs b/src/Hooki/Slack/Models/BlockElements/RadioButtonGroupElement.cs index cc66805..6816112 100644 --- a/src/Hooki/Slack/Models/BlockElements/RadioButtonGroupElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/RadioButtonGroupElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#radio /// -public class RadioButtonGroupElement : BlockElementBase +public class RadioButtonGroupElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement { - public override BlockElementType Type => BlockElementType.RadioButtonGroup; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.RadioButtonGroup; [JsonPropertyName("options")] public required OptionObject[] Options { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/RichTextInputElement.cs b/src/Hooki/Slack/Models/BlockElements/RichTextInputElement.cs index 361e0c5..e3fe5f4 100644 --- a/src/Hooki/Slack/Models/BlockElements/RichTextInputElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/RichTextInputElement.cs @@ -8,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#rich_text_input /// -public class RichTextInputElement : BlockElementBase +public class RichTextInputElement : BlockElementBase, IActionBlockElement, IInputBlockElement { - public override BlockElementType Type => BlockElementType.RichTextInput; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.RichTextInput; [JsonPropertyName("initial_value")] public RichTextBlock? InitialValue { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/SelectMenuElement.cs b/src/Hooki/Slack/Models/BlockElements/SelectMenuElement.cs index 69024a9..c11c769 100644 --- a/src/Hooki/Slack/Models/BlockElements/SelectMenuElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/SelectMenuElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#static_select /// -public class SelectMenuElement : BlockElementBase +public class SelectMenuElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement { - public override BlockElementType Type => BlockElementType.SelectMenu; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.SelectMenu; [JsonPropertyName("options")] public required OptionObject[] Options { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/TimePickerElement.cs b/src/Hooki/Slack/Models/BlockElements/TimePickerElement.cs index 35e8aab..1f351a0 100644 --- a/src/Hooki/Slack/Models/BlockElements/TimePickerElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/TimePickerElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#timepicker /// -public class TimePickerElement : BlockElementBase +public class TimePickerElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement { - public override BlockElementType Type =>BlockElementType.TimePicker; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.TimePicker; /// /// Format: HH:mm diff --git a/src/Hooki/Slack/Models/BlockElements/UrlInputElement.cs b/src/Hooki/Slack/Models/BlockElements/UrlInputElement.cs index ecdaa36..fe83262 100644 --- a/src/Hooki/Slack/Models/BlockElements/UrlInputElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/UrlInputElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#url /// -public class UrlInputElement : BlockElementBase +public class UrlInputElement : BlockElementBase, IInputBlockElement { - public override BlockElementType Type => BlockElementType.UrlInput; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.UrlInput; [JsonPropertyName("initial_value")] public string? InitialValue { get; set; } diff --git a/src/Hooki/Slack/Models/BlockElements/WorkflowButtonElement.cs b/src/Hooki/Slack/Models/BlockElements/WorkflowButtonElement.cs index e59b162..94bf3d5 100644 --- a/src/Hooki/Slack/Models/BlockElements/WorkflowButtonElement.cs +++ b/src/Hooki/Slack/Models/BlockElements/WorkflowButtonElement.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.BlockElements; @@ -7,9 +8,9 @@ namespace Hooki.Slack.Models.BlockElements; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#workflow_button /// -public class WorkflowButtonElement : BlockElementBase +public class WorkflowButtonElement : BlockElementBase, IActionBlockElement, ISectionBlockElement { - public override BlockElementType Type => BlockElementType.WorkflowButton; + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.WorkflowButton; [JsonPropertyName("text")] public required TextObject Text { get; set; } @@ -18,22 +19,7 @@ public class WorkflowButtonElement : BlockElementBase /// /// If you don't provide a value, default button style will be used /// - [JsonPropertyName("style")] public WorkFlowButtonElementStyles? Style { get; set; } + [JsonPropertyName("style")] public WorkflowButtonElementStyle? Style { get; set; } [JsonPropertyName("accessibility_label")] public string? AccessibilityLabel { get; set; } -} - -public enum WorkFlowButtonElementStyles -{ - /// - /// Green styling used for affirmation or confirmation actions - /// - [JsonPropertyName("primary")] - Primary, - - /// - /// Red styling used for destructive actions - /// - [JsonPropertyName("danger")] - Danger } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/ActionBlock.cs b/src/Hooki/Slack/Models/Blocks/ActionBlock.cs index f000086..fc90deb 100644 --- a/src/Hooki/Slack/Models/Blocks/ActionBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/ActionBlock.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; -using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.JsonConverters; namespace Hooki.Slack.Models.Blocks; @@ -9,8 +9,10 @@ namespace Hooki.Slack.Models.Blocks; /// public class ActionBlock : BlockBase { - public override BlockType Type => BlockType.ActionBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.ActionBlock; + + [JsonConverter(typeof(ActionBlockElementConverter))] [JsonPropertyName("elements")] public required List Elements { get; set; } +} + +public interface IActionBlockElement { } - [JsonPropertyName("elements")] - public required List Elements { get; set; } -} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/BlockBase.cs b/src/Hooki/Slack/Models/Blocks/BlockBase.cs index e31ecb7..7fc2059 100644 --- a/src/Hooki/Slack/Models/Blocks/BlockBase.cs +++ b/src/Hooki/Slack/Models/Blocks/BlockBase.cs @@ -7,7 +7,7 @@ namespace Hooki.Slack.Models.Blocks; [JsonConverter(typeof(BlockBaseConverter))] public abstract class BlockBase { - [JsonPropertyName("type")] public abstract BlockType Type { get; } - [JsonPropertyName("block_id")] public string? BlockId { get; set; } + + [JsonIgnore] public abstract BlockType Type { get; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/ContextBlock.cs b/src/Hooki/Slack/Models/Blocks/ContextBlock.cs index 03613a8..9c91cc1 100644 --- a/src/Hooki/Slack/Models/Blocks/ContextBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/ContextBlock.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.JsonConverters; namespace Hooki.Slack.Models.Blocks; @@ -8,8 +9,9 @@ namespace Hooki.Slack.Models.Blocks; /// public class ContextBlock : BlockBase { - public override BlockType Type => BlockType.ActionBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.ContextBlock; + + [JsonConverter(typeof(ContextBlockElementConverter))] [JsonPropertyName("elements")] public required List Elements { get; set; } +} - //ToDo: Add type safety for the compatible block elements - [JsonPropertyName("elements")] public required object[] Elements { get; set; } -} \ No newline at end of file +public interface IContextBlockElement { } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/DividerBlock.cs b/src/Hooki/Slack/Models/Blocks/DividerBlock.cs index 36c264f..6a7a1a8 100644 --- a/src/Hooki/Slack/Models/Blocks/DividerBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/DividerBlock.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Hooki.Slack.Enums; namespace Hooki.Slack.Models.Blocks; @@ -7,5 +8,5 @@ namespace Hooki.Slack.Models.Blocks; /// public class DividerBlock : BlockBase { - public override BlockType Type => BlockType.DividerBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.DividerBlock; } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/FileBlock.cs b/src/Hooki/Slack/Models/Blocks/FileBlock.cs index b7d31f2..561e49b 100644 --- a/src/Hooki/Slack/Models/Blocks/FileBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/FileBlock.cs @@ -8,7 +8,7 @@ namespace Hooki.Slack.Models.Blocks; /// public class FileBlock : BlockBase { - public override BlockType Type => BlockType.FileBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.FileBlock; [JsonPropertyName("external_id")] public required string ExternalId { get; set; } diff --git a/src/Hooki/Slack/Models/Blocks/HeaderBlock.cs b/src/Hooki/Slack/Models/Blocks/HeaderBlock.cs index fef62ef..7d4ac4d 100644 --- a/src/Hooki/Slack/Models/Blocks/HeaderBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/HeaderBlock.cs @@ -9,8 +9,7 @@ namespace Hooki.Slack.Models.Blocks; /// public class HeaderBlock : BlockBase { - public override BlockType Type => BlockType.HeaderBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.HeaderBlock; - [JsonPropertyName("text")] - public required TextObject Text { get; set; } + [JsonPropertyName("text")] public required TextObject Text { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/ImageBlock.cs b/src/Hooki/Slack/Models/Blocks/ImageBlock.cs index 4102b0b..745d86d 100644 --- a/src/Hooki/Slack/Models/Blocks/ImageBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/ImageBlock.cs @@ -9,9 +9,9 @@ namespace Hooki.Slack.Models.Blocks; /// public class ImageBlock : BlockBase { - public override BlockType Type => BlockType.ImageBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.ImageBlock; - [JsonPropertyName("alt_text")] public required TextObject AltText { get; set; } + [JsonPropertyName("alt_text")] public required string AltText { get; set; } /// /// Must provide either ImageUrl or SlackFile @@ -26,5 +26,5 @@ public class ImageBlock : BlockBase /// /// When provided, TextObject must be of type "PlainText" /// - [JsonPropertyName("title")] public required TextObject? Title { get; set; } + [JsonPropertyName("title")] public TextObject? Title { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/InputBlock.cs b/src/Hooki/Slack/Models/Blocks/InputBlock.cs index 5a7d2c6..e015ac3 100644 --- a/src/Hooki/Slack/Models/Blocks/InputBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/InputBlock.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.JsonConverters; using Hooki.Slack.Models.CompositionObjects; namespace Hooki.Slack.Models.Blocks; @@ -9,14 +10,14 @@ namespace Hooki.Slack.Models.Blocks; /// public class InputBlock : BlockBase { - public override BlockType Type => BlockType.InputBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.InputBlock; /// /// TextObject must have type of "PlainText" /// [JsonPropertyName("label")] public required TextObject Label { get; set; } - [JsonPropertyName("element")] public required TextObject Element { get; set; } + [JsonConverter(typeof(InputBlockElementConverter))] [JsonPropertyName("element")] public required IInputBlockElement Element { get; set; } [JsonPropertyName("dispatch_action")] public bool? DispatchAction { get; set; } @@ -26,4 +27,6 @@ public class InputBlock : BlockBase [JsonPropertyName("hint")] public TextObject? Hint { get; set; } [JsonPropertyName("optional")] public bool? Optional { get; set; } -} \ No newline at end of file +} + +public interface IInputBlockElement { } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/RichTextBlock.cs b/src/Hooki/Slack/Models/Blocks/RichTextBlock.cs index 0f5716f..fbd512c 100644 --- a/src/Hooki/Slack/Models/Blocks/RichTextBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/RichTextBlock.cs @@ -8,86 +8,63 @@ namespace Hooki.Slack.Models.Blocks; /// public class RichTextBlock : BlockBase { - public override BlockType Type => BlockType.RichTextBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.RichTextBlock; - [JsonPropertyName("elements")] - public required object[] Elements { get; set; } + [JsonPropertyName("elements")] public required List Elements { get; set; } } +public interface IRichTextBlockElement { } + +public interface IRichTextElement { } + /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text_section /// -public class RichTextSection +public class RichTextSection : IRichTextBlockElement { - [JsonPropertyName("type")] - public const RichTextObjectTypes Type = RichTextObjectTypes.RichTextSection; + [JsonPropertyName("type")] public const RichTextBlockElementType Type = RichTextBlockElementType.RichTextSection; - //ToDo: Implement Rich element types for type safety and validation: https://api.slack.com/reference/block-kit/blocks#broadcast-element-type - [JsonPropertyName("elements")] - public required object[] Elements { get; set; } + [JsonPropertyName("elements")] public required IRichTextElement[] Elements { get; set; } } /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text_list /// -public class RichTextList +public class RichTextList : IRichTextBlockElement { - [JsonPropertyName("type")] - public const RichTextObjectTypes Type = RichTextObjectTypes.RichTextList; + [JsonPropertyName("type")] public const RichTextBlockElementType Type = RichTextBlockElementType.RichTextList; - [JsonPropertyName("style")] - public required RichTextListStyleTypes Style { get; set; } + [JsonPropertyName("style")] public required RichTextListStyleType Style { get; set; } - //ToDo: Implement Rich element types for type safety and validation: https://api.slack.com/reference/block-kit/blocks#broadcast-element-type - [JsonPropertyName("elements")] - public required object[] Elements { get; set; } + [JsonPropertyName("elements")] public required IRichTextElement[] Elements { get; set; } - [JsonPropertyName("indent")] - public int? Indent { get; set; } + [JsonPropertyName("indent")] public int? Indent { get; set; } - [JsonPropertyName("offset")] - public int? Offset { get; set; } + [JsonPropertyName("offset")] public int? Offset { get; set; } - [JsonPropertyName("border")] - public int? Border { get; set; } -} - -public enum RichTextListStyleTypes -{ - [JsonPropertyName("bullet")] - Bullet, - [JsonPropertyName("ordered")] - Ordered + [JsonPropertyName("border")] public int? Border { get; set; } } /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text_preformatted /// -public class RichTextPreformatted +public class RichTextPreformatted : IRichTextBlockElement { - [JsonPropertyName("type")] - public const RichTextObjectTypes Type = RichTextObjectTypes.RichTextPreformatted; + [JsonPropertyName("type")] public const RichTextBlockElementType Type = RichTextBlockElementType.RichTextPreformatted; - //ToDo: Implement Rich element types for type safety and validation: https://api.slack.com/reference/block-kit/blocks#broadcast-element-type - [JsonPropertyName("elements")] - public required object[] Elements { get; set; } + [JsonPropertyName("elements")] public required IRichTextElement[] Elements { get; set; } - [JsonPropertyName("border")] - public int? Border { get; set; } + [JsonPropertyName("border")] public int? Border { get; set; } } /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text_quote /// -public class RichTextQuote +public class RichTextQuote : IRichTextBlockElement { - [JsonPropertyName("type")] - public const RichTextObjectTypes Type = RichTextObjectTypes.RichTextQuote; + [JsonPropertyName("type")] public const RichTextBlockElementType Type = RichTextBlockElementType.RichTextQuote; - //ToDo: Implement Rich element types for type safety and validation: https://api.slack.com/reference/block-kit/blocks#broadcast-element-type - [JsonPropertyName("elements")] - public required object[] Elements { get; set; } + [JsonPropertyName("elements")] public required IRichTextElement[] Elements { get; set; } - [JsonPropertyName("border")] - public int? Border { get; set; } + [JsonPropertyName("border")] public int? Border { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/SectionBlock.cs b/src/Hooki/Slack/Models/Blocks/SectionBlock.cs index 6c652b3..3758a82 100644 --- a/src/Hooki/Slack/Models/Blocks/SectionBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/SectionBlock.cs @@ -9,7 +9,7 @@ namespace Hooki.Slack.Models.Blocks; /// public class SectionBlock : BlockBase { - public override BlockType Type => BlockType.SectionBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.SectionBlock; /// /// TextObject must have type of "PlainText" @@ -19,11 +19,13 @@ public class SectionBlock : BlockBase /// /// This is a maybe field + /// Required if Text isn't provided /// [JsonPropertyName("fields")] public TextObject[]? Fields { get; set; } - //ToDo: Add type safety for the compatible block elements - [JsonPropertyName("accessory")] public object? Accessory { get; set; } + [JsonPropertyName("accessory")] public ISectionBlockElement? Accessory { get; set; } [JsonPropertyName("expand")] public bool? Expand { get; set; } -} \ No newline at end of file +} + +public interface ISectionBlockElement { } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/Blocks/VideoBlock.cs b/src/Hooki/Slack/Models/Blocks/VideoBlock.cs index 8abf336..95400b1 100644 --- a/src/Hooki/Slack/Models/Blocks/VideoBlock.cs +++ b/src/Hooki/Slack/Models/Blocks/VideoBlock.cs @@ -9,7 +9,7 @@ namespace Hooki.Slack.Models.Blocks; /// public class VideoBlock : BlockBase { - public override BlockType Type => BlockType.SectionBlock; + [JsonPropertyName("type")] public override BlockType Type => BlockType.VideoBlock; [JsonPropertyName("alt_text")] public required string AltText { get; set; } diff --git a/src/Hooki/Slack/Models/CompositionObjects/DispatchActionConfigurationObject.cs b/src/Hooki/Slack/Models/CompositionObjects/DispatchActionConfigurationObject.cs index 70e8449..06567fc 100644 --- a/src/Hooki/Slack/Models/CompositionObjects/DispatchActionConfigurationObject.cs +++ b/src/Hooki/Slack/Models/CompositionObjects/DispatchActionConfigurationObject.cs @@ -7,6 +7,5 @@ namespace Hooki.Slack.Models.CompositionObjects; /// public class DispatchActionConfigurationObject { - [JsonPropertyName("trigger_actions_on")] - public string[]? TriggerActionsOn { get; set; } + [JsonPropertyName("trigger_actions_on")] public string[]? TriggerActionsOn { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/CompositionObjects/OptionGroupObject.cs b/src/Hooki/Slack/Models/CompositionObjects/OptionGroupObject.cs index b4875d7..d9d02ac 100644 --- a/src/Hooki/Slack/Models/CompositionObjects/OptionGroupObject.cs +++ b/src/Hooki/Slack/Models/CompositionObjects/OptionGroupObject.cs @@ -10,9 +10,7 @@ public class OptionGroupObject /// /// TextObject type should be "PlainText" /// - [JsonPropertyName("label")] - public required TextObject Label { get; set; } + [JsonPropertyName("label")] public required TextObject Label { get; set; } - [JsonPropertyName("options")] - public required OptionObject[] Options { get; set; } + [JsonPropertyName("options")] public required OptionObject[] Options { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Slack/Models/CompositionObjects/TextObject.cs b/src/Hooki/Slack/Models/CompositionObjects/TextObject.cs index 54f052b..0cd4793 100644 --- a/src/Hooki/Slack/Models/CompositionObjects/TextObject.cs +++ b/src/Hooki/Slack/Models/CompositionObjects/TextObject.cs @@ -1,24 +1,22 @@ using System.Text.Json.Serialization; using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; namespace Hooki.Slack.Models.CompositionObjects; /// /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#text /// -public class TextObject +public class TextObject : IContextBlockElement { - [JsonPropertyName("type")] - public TextObjectTypes Type { get; set; } + [JsonPropertyName("type")] public required TextObjectType Type { get; set; } - [JsonPropertyName("text")] - public required string Text { get; set; } + [JsonPropertyName("text")] public required string Text { get; set; } /// /// This field is only usable when Type is plain_text /// - [JsonPropertyName("emoji")] - public bool? Emoji { get; set; } + [JsonPropertyName("emoji")] public bool? Emoji { get; set; } /// /// This field is only usable when Type is mrkdwn diff --git a/src/Hooki/Slack/Models/CompositionObjects/TriggerObject.cs b/src/Hooki/Slack/Models/CompositionObjects/TriggerObject.cs index e8580c1..d4b7abd 100644 --- a/src/Hooki/Slack/Models/CompositionObjects/TriggerObject.cs +++ b/src/Hooki/Slack/Models/CompositionObjects/TriggerObject.cs @@ -10,11 +10,9 @@ public class TriggerObject /// /// Url must be a valid link trigger url. Refer to Slack's documentation for more details: https://api.slack.com/automation/triggers/link /// - [JsonPropertyName("url")] - public required string Url { get; set; } + [JsonPropertyName("url")] public required string Url { get; set; } - [JsonPropertyName("customizable_input_parameters")] - public CustomizableInputParameter[]? CustomizableInputParameters { get; set; } + [JsonPropertyName("customizable_input_parameters")] public CustomizableInputParameter[]? CustomizableInputParameters { get; set; } } /// diff --git a/src/Hooki/Slack/Models/RichTextElements/AdvancedTextStyle.cs b/src/Hooki/Slack/Models/RichTextElements/AdvancedTextStyle.cs new file mode 100644 index 0000000..e3a2b9e --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/AdvancedTextStyle.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.RichTextElements; + +public class AdvancedTextStyle +{ + [JsonPropertyName("bold")] public bool? Bold { get; set; } + + [JsonPropertyName("italic")] public bool? Italic { get; set; } + + [JsonPropertyName("strike")] public bool? Strike { get; set; } + + [JsonPropertyName("highlight")] public bool? Highlight { get; set; } + + [JsonPropertyName("client_highlight")] public bool? ClientHighlight { get; set; } + + [JsonPropertyName("unlink")] public bool? Unlink { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/BasicTextStyle.cs b/src/Hooki/Slack/Models/RichTextElements/BasicTextStyle.cs new file mode 100644 index 0000000..2e2999e --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/BasicTextStyle.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.RichTextElements; + +public class BasicTextStyle +{ + [JsonPropertyName("Bold")] public bool? Bold { get; set; } + + [JsonPropertyName("italic")] public bool? Italic { get; set; } + + [JsonPropertyName("strike")] public bool? Strike { get; set; } + + [JsonPropertyName("code")] public bool? Code { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/BroadcastElement.cs b/src/Hooki/Slack/Models/RichTextElements/BroadcastElement.cs new file mode 100644 index 0000000..531aa3d --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/BroadcastElement.cs @@ -0,0 +1,30 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class BroadcastElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Broadcast; + + [JsonPropertyName("range")] public required BroadcastRangeType Range { get; set; } +} + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum BroadcastRangeType +{ + [EnumMember(Value = "here")] + Here, + + [EnumMember(Value = "channel")] + Channel, + + [EnumMember(Value = "everyone")] + Everyone +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/ChannelElement.cs b/src/Hooki/Slack/Models/RichTextElements/ChannelElement.cs new file mode 100644 index 0000000..12eba5a --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/ChannelElement.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class ChannelElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Channel; + + [JsonPropertyName("channel_id")] public required string ChannelId { get; set; } + + [JsonPropertyName("style")] public AdvancedTextStyle? Style { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/ColorElement.cs b/src/Hooki/Slack/Models/RichTextElements/ColorElement.cs new file mode 100644 index 0000000..d91068d --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/ColorElement.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class ColorElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Color; + + /// + /// The hex value for the color + /// + [JsonPropertyName("value")] public required string Value { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/DateElement.cs b/src/Hooki/Slack/Models/RichTextElements/DateElement.cs new file mode 100644 index 0000000..34c5795 --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/DateElement.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class DateElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Date; + + /// + /// A Unix timestamp for the date to be displayed in seconds + /// + [JsonPropertyName("timestamp")] public required int Timestamp { get; set; } + + /// + /// A template string containing curly-brace-enclosed tokens to substitute your provided timestamp + /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#color-element-type + /// + [JsonPropertyName("format")] public required string Format { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/EmojiElement.cs b/src/Hooki/Slack/Models/RichTextElements/EmojiElement.cs new file mode 100644 index 0000000..c772057 --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/EmojiElement.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class EmojiElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Emoji; + + /// + /// The name of the emoji; i.e. "wave" or "wave::skin-tone-2" + /// + [JsonPropertyName("name")] public required string Name { get; set; } + + /// + /// Represents the unicode code point of the emoji, where applicable + /// + [JsonPropertyName("unicode")] public string? Unicode { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/LinkElement.cs b/src/Hooki/Slack/Models/RichTextElements/LinkElement.cs new file mode 100644 index 0000000..9c702a1 --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/LinkElement.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class LinkElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Link; + + [JsonPropertyName("url")] public required string Url { get; set; } + + [JsonPropertyName("text")] public string? Text { get; set; } + + [JsonPropertyName("unsafe")] public bool? Unsafe { get; set; } + + [JsonPropertyName("style")] public required BasicTextStyle Style { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/TextElement.cs b/src/Hooki/Slack/Models/RichTextElements/TextElement.cs new file mode 100644 index 0000000..aa02c91 --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/TextElement.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class TextElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Text; + + [JsonPropertyName("text")] public required string Text { get; set; } + + [JsonPropertyName("style")] public BasicTextStyle? Style { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/UserElement.cs b/src/Hooki/Slack/Models/RichTextElements/UserElement.cs new file mode 100644 index 0000000..ca4d624 --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/UserElement.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class UserElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.User; + + [JsonPropertyName("user_id")] public required string UserId { get; set; } + + [JsonPropertyName("style")] public AdvancedTextStyle? Style { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/RichTextElements/UserGroupElement.cs b/src/Hooki/Slack/Models/RichTextElements/UserGroupElement.cs new file mode 100644 index 0000000..b64f75b --- /dev/null +++ b/src/Hooki/Slack/Models/RichTextElements/UserGroupElement.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class UserGroupElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.UserGroup; + + [JsonPropertyName("usergroup_id")] public required string UserGroupId { get; set; } + + [JsonPropertyName("style")] public AdvancedTextStyle? Style { get; set; } +} \ No newline at end of file diff --git a/src/Hooki/Slack/Models/SlackWebhookPayload.cs b/src/Hooki/Slack/Models/SlackWebhookPayload.cs index 169d414..265d8e7 100644 --- a/src/Hooki/Slack/Models/SlackWebhookPayload.cs +++ b/src/Hooki/Slack/Models/SlackWebhookPayload.cs @@ -5,6 +5,5 @@ namespace Hooki.Slack.Models; public class SlackWebhookPayload { - [JsonPropertyName("blocks")] - public required List Blocks { get; set; } + [JsonPropertyName("blocks")] public required List Blocks { get; set; } } \ No newline at end of file diff --git a/src/Hooki/Utilities/HookiJsonSerializerOptions.cs b/src/Hooki/Utilities/HookiJsonSerializerOptions.cs new file mode 100644 index 0000000..3066d2a --- /dev/null +++ b/src/Hooki/Utilities/HookiJsonSerializerOptions.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using Hooki.Slack.JsonConverters; + +namespace Hooki.Utilities; + +public static class HookiJsonSerializerOptions +{ + private static readonly Lazy DefaultOptions = new(CreateDefaultOptions); + + private static System.Text.Json.JsonSerializerOptions CreateDefaultOptions() + { + return new System.Text.Json.JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + } + + /// + /// Gets the default JSON serializer options used throughout the Hooki library. + /// This instance is created only once and shared across all usages. + /// + public static System.Text.Json.JsonSerializerOptions Default => DefaultOptions.Value; + + /// + /// Creates a new instance of JsonSerializerOptions with the default Hooki settings. + /// Use this method if you need to modify the options for a specific use case. + /// + /// A new instance of JsonSerializerOptions with default Hooki settings. + public static System.Text.Json.JsonSerializerOptions CreateDefault() => new(Default); +} \ No newline at end of file diff --git a/src/Hooki/Utilities/JsonHelper.cs b/src/Hooki/Utilities/JsonHelper.cs new file mode 100644 index 0000000..e105a5e --- /dev/null +++ b/src/Hooki/Utilities/JsonHelper.cs @@ -0,0 +1,55 @@ +using System.Text.Json; + +namespace Hooki.Utilities; + +/// +/// Provides utility methods for JSON serialization and deserialization using Hooki's standard options. +/// +public static class JsonHelper +{ + /// + /// Serializes an object to a JSON string using Hooki's default JSON options. + /// + /// The type of the object to serialize. + /// The object to serialize. + /// A JSON string representation of the object. + public static string Serialize(T obj) + { + return JsonSerializer.Serialize(obj, HookiJsonSerializerOptions.Default); + } + + /// + /// Serializes an object to a JSON string using the provided JSON options. + /// + /// The type of the object to serialize. + /// The object to serialize. + /// The JSON serializer options to use. If null, Hooki's default options will be used. + /// A JSON string representation of the object. + public static string Serialize(T obj, JsonSerializerOptions? options) + { + return JsonSerializer.Serialize(obj, options ?? HookiJsonSerializerOptions.Default); + } + + /// + /// Deserializes a JSON string to an object using Hooki's default JSON options. + /// + /// The type to deserialize the JSON to. + /// The JSON string to deserialize. + /// The deserialized object, or default(T) if deserialization fails. + public static T? Deserialize(string json) + { + return JsonSerializer.Deserialize(json, HookiJsonSerializerOptions.Default); + } + + /// + /// Deserializes a JSON string to an object using the provided JSON options. + /// + /// The type to deserialize the JSON to. + /// The JSON string to deserialize. + /// The JSON serializer options to use. If null, Hooki's default options will be used. + /// The deserialized object, or default(T) if deserialization fails. + public static T? Deserialize(string json, JsonSerializerOptions? options) + { + return JsonSerializer.Deserialize(json, options ?? HookiJsonSerializerOptions.Default); + } +} \ No newline at end of file diff --git a/src/Hooki/repopack-output.txt b/src/Hooki/repopack-output.txt new file mode 100644 index 0000000..a7d90ab --- /dev/null +++ b/src/Hooki/repopack-output.txt @@ -0,0 +1,3049 @@ +This file is a merged representation of the entire codebase, combining all repository files into a single document. +Generated by Repopack on: 2024-10-06T14:55:50.706Z + +================================================================ +File Summary +================================================================ + +Purpose: +-------- +This file contains a packed representation of the entire repository's contents. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + +File Format: +------------ +The content is organized as follows: +1. This summary section +2. Repository information +3. Repository structure +4. Multiple file entries, each consisting of: + a. A separator line (================) + b. The file path (File: path/to/file) + c. Another separator line + d. The full contents of the file + e. A blank line + +Usage Guidelines: +----------------- +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + +Notes: +------ +- Some files may have been excluded based on .gitignore rules and Repopack's + configuration. +- Binary files are not included in this packed representation. Please refer to + the Repository Structure section for a complete list of file paths, including + binary files. + +Additional Info: +---------------- + +For more information about Repopack, visit: https://github.com/yamadashy/repopack + +================================================================ +Repository Structure +================================================================ +Builders/ + BlockBuilders/ + ActionBlockBuilder.cs + BlockBaseBuilder.cs + ContextBlockBuilder.cs + DividerBlockBuilder.cs + FileBlockBuilder.cs + HeaderBlockBuilder.cs + ImageBlockBuilder.cs + InputBlockBuilder.cs + RichTextBlockBuilder.cs + SectionBlockBuilder.cs + SlackWebhookPayloadBuilder.cs + VideoBlockBuilder.cs + BlockElementBuilders/ + BlockElementBaseBuilder.cs + ButtonBlockElementBuilder.cs + ImageBlockElementBuilder.cs + MultiSelectMenuBlockElementBuilder.cs + ConfirmationDialogObjectBuilder.cs + TextObjectBuilder.cs +Enums/ + BlockElementType.cs + BlockType.cs + RichTextBlockElementType.cs + RichTextElementType.cs + RichTextListStyleType.cs + TextObjectType.cs + ViewObjectType.cs + WorkflowButtonElementStyle.cs +JsonConverters/ + ActionBlockConverter.cs +Models/ + BlockElements/ + BlockElementBase.cs + ButtonElement.cs + CheckboxElement.cs + DatePickerElement.cs + DateTimePickerElement.cs + EmailInputElement.cs + FileInputElement.cs + ImageElement.cs + MultiSelectMenuElement.cs + NumberInputElement.cs + OverflowMenuElement.cs + PlainTextInputElement.cs + RadioButtonGroupElement.cs + RichTextInputElement.cs + SelectMenuElement.cs + TimePickerElement.cs + UrlInputElement.cs + WorkflowButtonElement.cs + Blocks/ + ActionBlock.cs + BlockBase.cs + ContextBlock.cs + DividerBlock.cs + FileBlock.cs + HeaderBlock.cs + ImageBlock.cs + InputBlock.cs + RichTextBlock.cs + SectionBlock.cs + VideoBlock.cs + CompositionObjects/ + ConfirmationDialogObject.cs + ConversationFilterObject.cs + DispatchActionConfigurationObject.cs + OptionGroupObject.cs + OptionObject.cs + SlackFileObject.cs + TextObject.cs + TriggerObject.cs + WorkflowObject.cs + RichTextElements/ + AdvancedTextStyle.cs + BasicTextStyle.cs + BroadcastElement.cs + ChannelElement.cs + ColorElement.cs + DateElement.cs + EmojiElement.cs + LinkElement.cs + TextElement.cs + UserElement.cs + UserGroupElement.cs + ViewObjects/ + HomeTab.cs + Modal.cs + SlackWebhookPayload.cs + +================================================================ +Repository Files +================================================================ + +================ +File: Builders/BlockBuilders/ActionBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class ActionBlockBuilder : BlockBaseBuilder +{ + private readonly List _elements = new(); + + public ActionBlockBuilder AddElement(Func elementFactory) where T : IActionBlockElement + { + _elements.Add(elementFactory()); + return this; + } + + public override ActionBlock Build() + { + if (_elements is null) + throw new InvalidOperationException("Elements are required"); + + return new ActionBlock + { + BlockId = base.Build().BlockId, + Elements = _elements + }; + } +} + +================ +File: Builders/BlockBuilders/BlockBaseBuilder.cs +================ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class BlockBaseBuilder +{ + private string? _blockId; + + public BlockBaseBuilder WithBlockId(string blockId) + { + _blockId = blockId; + return this; + } + + public virtual BlockBase Build() + { + return new BlockBase + { + BlockId = _blockId + }; + } +} + +================ +File: Builders/BlockBuilders/ContextBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class ContextBlockBuilder : BlockBaseBuilder +{ + private readonly List _elements = new(); + + public ContextBlockBuilder AddElement(Func elementFactory) where T : IContextBlockElement + { + _elements.Add(elementFactory()); + return this; + } + + public override BlockBase Build() + { + if (_elements.Count == 0) + throw new InvalidOperationException("At least one element is required for an ActionBlock."); + + return new ContextBlock + { + BlockId = base.Build().BlockId, + Elements = _elements + }; + } +} + +================ +File: Builders/BlockBuilders/DividerBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class DividerBlockBuilder : BlockBaseBuilder +{ + public override BlockBase Build() + { + return new DividerBlock + { + BlockId = base.Build().BlockId + }; + } +} + +================ +File: Builders/BlockBuilders/FileBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class FileBlockBuilder : BlockBaseBuilder +{ + private string _externalId = default!; + private string _source = default!; + + public FileBlockBuilder WithExternalId(string externalId) + { + _externalId = externalId; + return this; + } + + public FileBlockBuilder WithSource(string source) + { + _source = source; + return this; + } + + public override BlockBase Build() + { + return new FileBlock + { + BlockId = base.Build().BlockId, + ExternalId = _externalId, + Source = _source + }; + } +} + +================ +File: Builders/BlockBuilders/HeaderBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class HeaderBlockBuilder : BlockBaseBuilder +{ + private TextObject? _text; + + public HeaderBlockBuilder WithText(TextObject text) + { + _text = text; + return this; + } + + public override HeaderBlock Build() + { + if (_text is null) + throw new InvalidOperationException("Text is required"); + + return new HeaderBlock + { + BlockId = base.Build().BlockId, + Text = _text + }; + } +} + +================ +File: Builders/BlockBuilders/ImageBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class ImageBlockBuilder : BlockBaseBuilder +{ + private TextObject? _altText; + private string? _imageUrl; + private SlackFileObject? _slackFile; + private TextObject? _title; + + public ImageBlockBuilder WithAltText(TextObject altText) + { + _altText = altText; + return this; + } + + public ImageBlockBuilder WithImageUrl(string imageUrl) + { + _imageUrl = imageUrl; + return this; + } + + public ImageBlockBuilder WithSlackFile(SlackFileObject slackFile) + { + _slackFile = slackFile; + return this; + } + + public ImageBlockBuilder WithTitle(TextObject title) + { + _title = title; + return this; + } + + public override ImageBlock Build() + { + if (_altText is null) + throw new InvalidOperationException("AltText is required"); + + if (_imageUrl is null && _slackFile is null) + throw new InvalidOperationException("Either ImageUrl or SlackUrl need to be provided"); + + return new ImageBlock + { + BlockId = base.Build().BlockId, + AltText = _altText, + ImageUrl = _imageUrl, + SlackFile = _slackFile, + Title = _title, + }; + } +} + +================ +File: Builders/BlockBuilders/InputBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class InputBlockBuilder : BlockBaseBuilder +{ + private TextObject? _label; + private IInputBlockElement? _element; + private bool? _dispatchAction; + private TextObject? _hint; + private bool? _optional; + + public InputBlockBuilder WithLabel(TextObject label) + { + _label = label; + return this; + } + + public InputBlockBuilder WithElement(Func elementFactory) where T : IInputBlockElement + { + _element = elementFactory(); + return this; + } + + public InputBlockBuilder WithDispatchAction(bool dispatchAction) + { + _dispatchAction = dispatchAction; + return this; + } + + public InputBlockBuilder WithHint(TextObject hint) + { + _hint = hint; + return this; + } + + public InputBlockBuilder WithOptional(bool optional) + { + _optional = optional; + return this; + } + + public override InputBlock Build() + { + if (_label is null) + throw new InvalidOperationException("Label must have a value"); + + if (_element is null) + throw new InvalidOperationException("Element must have a value"); + + return new InputBlock + { + BlockId = base.Build().BlockId, + Label = _label, + Element = _element, + DispatchAction = _dispatchAction, + Hint = _hint, + Optional = _optional + }; + } +} + +================ +File: Builders/BlockBuilders/RichTextBlockBuilder.cs +================ +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class RichTextBlockBuilder : BlockBaseBuilder +{ + private readonly List _elements = new(); + + public RichTextBlockBuilder AddElement(Func elementFactory) where T : IRichTextBlockElement + { + _elements.Add(elementFactory()); + return this; + } + + public override BlockBase Build() + { + if (_elements.Count == 0) + throw new InvalidOperationException("At least one element is required for a RichTextBlock."); + + return new RichTextBlock + { + BlockId = base.Build().BlockId, + Elements = _elements + }; + } +} + +public class RichTextElementsBuilder +{ + private readonly List _elements = new(); + + public RichTextElementsBuilder AddSection(Action buildAction) + { + var builder = new RichTextSectionBuilder(); + buildAction(builder); + _elements.Add(builder.Build()); + return this; + } + + public RichTextElementsBuilder AddList(Action buildAction) + { + var builder = new RichTextListBuilder(); + buildAction(builder); + _elements.Add(builder.Build()); + return this; + } + + public RichTextElementsBuilder AddPreformatted(Action buildAction) + { + var builder = new RichTextPreformattedBuilder(); + buildAction(builder); + _elements.Add(builder.Build()); + return this; + } + + public RichTextElementsBuilder AddQuote(Action buildAction) + { + var builder = new RichTextQuoteBuilder(); + buildAction(builder); + _elements.Add(builder.Build()); + return this; + } + + public IEnumerable Build() => _elements; +} + +public class RichTextSectionBuilder +{ + private readonly List _elements = new(); + + public RichTextSectionBuilder AddElement(IRichTextElement element) + { + _elements.Add(element); + return this; + } + + public RichTextSection Build() + { + if (_elements.Count == 0) + throw new InvalidOperationException("At least one element is required for a RichTextSection."); + + return new RichTextSection + { + Elements = _elements.ToArray() + }; + } +} + +public class RichTextListBuilder +{ + private RichTextListStyleType _style; + private readonly List _elements = new(); + private int? _indent; + private int? _offset; + private int? _border; + + public RichTextListBuilder WithStyle(RichTextListStyleType style) + { + _style = style; + return this; + } + + public RichTextListBuilder AddElement(IRichTextElement element) + { + _elements.Add(element); + return this; + } + + public RichTextListBuilder WithIndent(int indent) + { + _indent = indent; + return this; + } + + public RichTextListBuilder WithOffset(int offset) + { + _offset = offset; + return this; + } + + public RichTextListBuilder WithBorder(int border) + { + _border = border; + return this; + } + + public RichTextList Build() + { + if (_elements.Count == 0) + throw new InvalidOperationException("At least one element is required for a RichTextList."); + + return new RichTextList + { + Style = _style, + Elements = _elements.ToArray(), + Indent = _indent, + Offset = _offset, + Border = _border + }; + } +} + +public class RichTextPreformattedBuilder +{ + private readonly List _elements = new(); + private int? _border; + + public RichTextPreformattedBuilder AddElement(IRichTextElement element) + { + _elements.Add(element); + return this; + } + + public RichTextPreformattedBuilder WithBorder(int border) + { + _border = border; + return this; + } + + public RichTextPreformatted Build() + { + if (_elements.Count == 0) + throw new InvalidOperationException("At least one element is required for a RichTextPreformatted."); + + return new RichTextPreformatted + { + Elements = _elements.ToArray(), + Border = _border + }; + } +} + +public class RichTextQuoteBuilder +{ + private readonly List _elements = new(); + private int? _border; + + public RichTextQuoteBuilder AddElement(IRichTextElement element) + { + _elements.Add(element); + return this; + } + + public RichTextQuoteBuilder WithBorder(int border) + { + _border = border; + return this; + } + + public RichTextQuote Build() + { + if (_elements.Count == 0) + throw new InvalidOperationException("At least one element is required for a RichTextQuote."); + + return new RichTextQuote + { + Elements = _elements.ToArray(), + Border = _border + }; + } +} + +================ +File: Builders/BlockBuilders/SectionBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class SectionBlockBuilder : BlockBaseBuilder +{ + private TextObject? _text; + private List? _fields; + private ISectionBlockElement? _accessory; + private bool? _expand; + + public SectionBlockBuilder WithText(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _text = builder.Build(); + return this; + } + + public SectionBlockBuilder AddField(Action buildAction) + { + _fields ??= new List(); + var builder = new TextObjectBuilder(); + buildAction(builder); + _fields.Add(builder.Build()); + return this; + } + + public SectionBlockBuilder WithAccessory(Func accessoryFactory) where T : ISectionBlockElement + { + _accessory = accessoryFactory(); + return this; + } + + public SectionBlockBuilder WithExpand(bool expand) + { + _expand = expand; + return this; + } + + public override BlockBase Build() + { + if (_text == null && (_fields == null || _fields.Count == 0)) + throw new InvalidOperationException("Either text or at least one field is required for a SectionBlock."); + + return new SectionBlock + { + BlockId = base.Build().BlockId, + Text = _text, + Fields = _fields?.ToArray(), + Accessory = _accessory, + Expand = _expand + }; + } +} + +================ +File: Builders/BlockBuilders/SlackWebhookPayloadBuilder.cs +================ +using Hooki.Slack.Models; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Builders; + +public class SlackWebhookPayloadBuilder +{ + private readonly List _blocks = new(); + + public SlackWebhookPayloadBuilder AddBlock(Action buildAction) where T : BlockBaseBuilder, new() + { + var builder = new T(); + buildAction(builder); + _blocks.Add(builder.Build()); + return this; + } + + public SlackWebhookPayload Build() + { + if (_blocks.Count == 0) + throw new InvalidOperationException("At least one block is required."); + + return new SlackWebhookPayload + { + Blocks = _blocks + }; + } +} + +================ +File: Builders/BlockBuilders/VideoBlockBuilder.cs +================ +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; +using Hooki.Slack.Enums; + +namespace Hooki.Slack.Builders; + +public class VideoBlockBuilder : BlockBaseBuilder +{ + private string? _altText; + private string? _authorName; + private TextObject? _description; + private string? _providerIconUrl; + private string? _providerName; + private TextObject? _title; + private string? _titleUrl; + private string? _thumbnailUrl; + private string? _videoUrl; + + public VideoBlockBuilder WithAltText(string altText) + { + _altText = altText; + return this; + } + + public VideoBlockBuilder WithAuthorName(string authorName) + { + _authorName = authorName; + return this; + } + + public VideoBlockBuilder WithDescription(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _description = builder.Build(); + return this; + } + + public VideoBlockBuilder WithProviderIconUrl(string providerIconUrl) + { + _providerIconUrl = providerIconUrl; + return this; + } + + public VideoBlockBuilder WithProviderName(string providerName) + { + _providerName = providerName; + return this; + } + + public VideoBlockBuilder WithTitle(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _title = builder.Build(); + return this; + } + + public VideoBlockBuilder WithTitleUrl(string titleUrl) + { + if (!titleUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Title URL must start with 'https://'", nameof(titleUrl)); + + _titleUrl = titleUrl; + return this; + } + + public VideoBlockBuilder WithThumbnailUrl(string thumbnailUrl) + { + _thumbnailUrl = thumbnailUrl; + return this; + } + + public VideoBlockBuilder WithVideoUrl(string videoUrl) + { + _videoUrl = videoUrl; + return this; + } + + public override BlockBase Build() + { + if (string.IsNullOrWhiteSpace(_altText)) + throw new InvalidOperationException("AltText is required for a VideoBlock."); + + if (_description == null) + throw new InvalidOperationException("Description is required for a VideoBlock."); + + if (_description.Type != TextObjectType.PlainText) + throw new InvalidOperationException("Description must be of type PlainText."); + + if (_title != null && _title.Type != TextObjectType.PlainText) + throw new InvalidOperationException("Title must be of type PlainText."); + + if (string.IsNullOrWhiteSpace(_thumbnailUrl)) + throw new InvalidOperationException("ThumbnailUrl is required for a VideoBlock."); + + if (string.IsNullOrWhiteSpace(_videoUrl)) + throw new InvalidOperationException("VideoUrl is required for a VideoBlock."); + + return new VideoBlock + { + BlockId = base.Build().BlockId, + AltText = _altText, + AuthorName = _authorName, + Description = _description, + ProviderIconUrl = _providerIconUrl, + ProviderName = _providerName, + Title = _title, + TitleUrl = _titleUrl, + ThumbnailUrl = _thumbnailUrl, + VideoUrl = _videoUrl + }; + } +} + +================ +File: Builders/BlockElementBuilders/BlockElementBaseBuilder.cs +================ +using Hooki.Slack.Models.BlockElements; + +namespace Hooki.Slack.Builders.BlockElementBuilders; + + +public class BlockElementBaseBuilder +{ + private string? _actionId; + + public BlockElementBaseBuilder WithActionId(string actionId) + { + _actionId = actionId; + return this; + } + + public virtual BlockElementBase Build() + { + return new BlockElementBase + { + ActionId = _actionId + }; + } +} + +================ +File: Builders/BlockElementBuilders/ButtonBlockElementBuilder.cs +================ +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders.BlockElementBuilders; + +public class ButtonElementBuilder : BlockElementBaseBuilder +{ + private TextObject? _text; + private string? _url; + private string? _value; + private string? _style; + private ConfirmationDialogObject? _confirm; + private string? _accessibilityLabel; + + public ButtonElementBuilder WithText(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _text = builder.Build(); + return this; + } + + public ButtonElementBuilder WithUrl(string url) + { + _url = url; + return this; + } + + public ButtonElementBuilder WithValue(string value) + { + _value = value; + return this; + } + + public ButtonElementBuilder WithStyle(string style) + { + _style = style; + return this; + } + + public ButtonElementBuilder WithConfirm(Action buildAction) + { + var builder = new ConfirmationDialogObjectBuilder(); + buildAction(builder); + _confirm = builder.Build(); + return this; + } + + public ButtonElementBuilder WithAccessibilityLabel(string accessibilityLabel) + { + _accessibilityLabel = accessibilityLabel; + return this; + } + + public override BlockElementBase Build() + { + if (_text == null) + throw new InvalidOperationException("Text is required for a ButtonElement."); + + return new ButtonElement + { + ActionId = base.Build().ActionId, + Text = _text, + Url = _url, + Value = _value, + Style = _style, + Confirm = _confirm, + AccessibilityLabel = _accessibilityLabel + }; + } +} + +================ +File: Builders/BlockElementBuilders/ImageBlockElementBuilder.cs +================ +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders.BlockElementBuilders; + +public class ImageBlockElementBuilder: BlockElementBaseBuilder +{ + private string? _altText; + private string? _imageUrl; + private SlackFileObject? _slackFile; + + public ImageBlockElementBuilder WithAltText(string altText) + { + _altText = altText; + return this; + } + + public ImageBlockElementBuilder WithImageUrl(string imageUrl) + { + if (imageUrl.Length > 3000) + throw new ArgumentException("ImageUrl must not exceed 3000 characters.", nameof(imageUrl)); + + _imageUrl = imageUrl; + return this; + } + + public ImageBlockElementBuilder WithSlackFile(SlackFileObject slackFile) + { + _slackFile = slackFile; + return this; + } + + public override BlockElementBase Build() + { + if (string.IsNullOrWhiteSpace(_altText)) + throw new InvalidOperationException("AltText is required for an ImageElement."); + + if (_imageUrl == null && _slackFile == null) + throw new InvalidOperationException("Either ImageUrl or SlackFile must be provided for an ImageElement."); + + if (_imageUrl != null && _slackFile != null) + throw new InvalidOperationException("Only one of ImageUrl or SlackFile can be provided for an ImageElement."); + + return new ImageElement + { + ActionId = base.Build().ActionId, + AltText = _altText, + ImageUrl = _imageUrl, + SlackFile = _slackFile + }; + } +} + +================ +File: Builders/BlockElementBuilders/MultiSelectMenuBlockElementBuilder.cs +================ +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders.BlockElementBuilders; + +public class MultiSelectMenuBlockElementBuilder +{ + private TextObject? _placeholder; + private readonly List _options = []; + private readonly List _initialOptions = []; + private readonly List _optionGroups = []; + private ConfirmationDialogObject? _confirm; + private bool? _focusOnLoad; + private int? _maxSelectedItems; + private string? _actionId; + + public MultiSelectMenuBlockElementBuilder WithActionId(string actionId) + { + _actionId = actionId; + return this; + } + + public MultiSelectMenuBlockElementBuilder WithPlaceholder(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _placeholder = builder.Build(); + return this; + } + + public MultiSelectMenuBlockElementBuilder AddOption(OptionObject option) + { + _options.Add(option); + return this; + } + + public MultiSelectMenuBlockElementBuilder AddInitialOption(OptionObject option) + { + _initialOptions.Add(option); + return this; + } + + public MultiSelectMenuBlockElementBuilder AddOptionGroup(OptionGroupObject optionGroup) + { + _optionGroups.Add(optionGroup); + return this; + } + + public MultiSelectMenuBlockElementBuilder WithConfirm(ConfirmationDialogObject confirmation) + { + _confirm = confirmation; + return this; + } + + public MultiSelectMenuBlockElementBuilder WithFocusOnLoad(bool focusOnLoad) + { + _focusOnLoad = focusOnLoad; + return this; + } + + public MultiSelectMenuBlockElementBuilder WithMaxSelectedItems(int maxSelectedItems) + { + _maxSelectedItems = maxSelectedItems; + return this; + } + + public IActionBlockElement Build() + { + if (_options.Count == 0 && _optionGroups.Count == 0) + throw new InvalidOperationException("Either options or option groups must be provided for a MultiSelectMenuElement."); + + return new MultiSelectMenuElement + { + ActionId = _actionId, + Placeholder = _placeholder, + Options = _options.Count > 0 ? _options.ToArray() : null, + InitialOptions = _initialOptions.Count > 0 ? _initialOptions.ToArray() : null, + OptionGroups = _optionGroups.Count > 0 ? _optionGroups.ToArray() : null, + Confirm = _confirm, + FocusOnLoad = _focusOnLoad, + MaxSelectedItems = _maxSelectedItems + }; + } +} + +================ +File: Builders/ConfirmationDialogObjectBuilder.cs +================ +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class ConfirmationDialogObjectBuilder +{ + private TextObject? _title; + private TextObject? _text; + private TextObject? _confirm; + private TextObject? _deny; + private string? _style; + + public ConfirmationDialogObjectBuilder WithTitle(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _title = builder.Build(); + return this; + } + + public ConfirmationDialogObjectBuilder WithText(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _text = builder.Build(); + return this; + } + + public ConfirmationDialogObjectBuilder WithConfirm(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _confirm = builder.Build(); + return this; + } + + public ConfirmationDialogObjectBuilder WithDeny(Action buildAction) + { + var builder = new TextObjectBuilder(); + buildAction(builder); + _deny = builder.Build(); + return this; + } + + public ConfirmationDialogObjectBuilder WithStyle(string style) + { + _style = style; + return this; + } + + public ConfirmationDialogObject Build() + { + if (_title == null) + throw new InvalidOperationException("Title is required for a ConfirmationDialogObject."); + if (_text == null) + throw new InvalidOperationException("Text is required for a ConfirmationDialogObject."); + if (_confirm == null) + throw new InvalidOperationException("Confirm is required for a ConfirmationDialogObject."); + if (_deny == null) + throw new InvalidOperationException("Deny is required for a ConfirmationDialogObject."); + + return new ConfirmationDialogObject + { + Title = _title, + Text = _text, + Confirm = _confirm, + Deny = _deny, + Style = _style + }; + } +} + +================ +File: Builders/TextObjectBuilder.cs +================ +using Hooki.Slack.Enums; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Builders; + +public class TextObjectBuilder +{ + private TextObjectType? _type; + private string? _text; + private bool? _emoji; + private bool? _verbatim; + + public TextObjectBuilder WithType(TextObjectType type) + { + _type = type; + return this; + } + + public TextObjectBuilder WithText(string text) + { + _text = text; + return this; + } + + public TextObjectBuilder WithEmoji(bool emoji) + { + _emoji = emoji; + return this; + } + + public TextObjectBuilder WithVerbatim(bool verbatim) + { + _verbatim = verbatim; + return this; + } + + public TextObject Build() + { + if (_type == null) + throw new InvalidOperationException("Type is required for a TextObject."); + + if (string.IsNullOrWhiteSpace(_text)) + throw new InvalidOperationException("Text is required for a TextObject."); + + if (_type == TextObjectType.Markdown && _emoji.HasValue) + throw new InvalidOperationException("Emoji can only be set when Type is PlainText."); + + if (_type == TextObjectType.PlainText && _verbatim.HasValue) + throw new InvalidOperationException("Verbatim can only be set when Type is Markdown."); + + return new TextObject + { + Type = _type.Value, + Text = _text, + Emoji = _emoji, + Verbatim = _verbatim + }; + } +} + +================ +File: Enums/BlockElementType.cs +================ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum BlockElementType +{ + [EnumMember(Value = "button")] + Button, + + [EnumMember(Value = "checkboxes")] + Checkboxes, + + [EnumMember(Value = "datepicker")] + DatePicker, + + [EnumMember(Value = "datetimepicker")] + DatetimePicker, + + [EnumMember(Value = "email_text_input")] + EmailInput, + + [EnumMember(Value = "file_input")] + FileInput, + + [EnumMember(Value = "image")] + Image, + + [EnumMember(Value = "multi_static_select")] + MultiSelectMenu, + + [EnumMember(Value = "number_input")] + NumberInput, + + [EnumMember(Value = "overflow")] + OverflowMenu, + + [EnumMember(Value = "plain_text_input")] + PlainTextInput, + + [EnumMember(Value = "radio_buttons")] + RadioButtonGroup, + + [EnumMember(Value = "rich_text_input")] + RichTextInput, + + [EnumMember(Value = "static_select")] + SelectMenu, + + [EnumMember(Value = "timepicker")] + TimePicker, + + [EnumMember(Value = "url_text_input")] + UrlInput, + + [EnumMember(Value = "workflow_button")] + WorkflowButton +} + +================ +File: Enums/BlockType.cs +================ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum BlockType +{ + [EnumMember(Value = "actions")] + ActionBlock, + + [EnumMember(Value = "context")] + ContextBlock, + + [EnumMember(Value = "divider")] + DividerBlock, + + [EnumMember(Value = "file")] + FileBlock, + + [EnumMember(Value = "header")] + HeaderBlock, + + [EnumMember(Value = "image")] + ImageBlock, + + [EnumMember(Value = "input")] + InputBlock, + + [EnumMember(Value = "rich_text")] + RichTextBlock, + + [EnumMember(Value = "section")] + SectionBlock, + + [EnumMember(Value = "video")] + VideoBlock +} + +================ +File: Enums/RichTextBlockElementType.cs +================ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum RichTextBlockElementType +{ + [EnumMember(Value = "rich_text_section")] + RichTextSection, + + [EnumMember(Value = "rich_text_list")] + RichTextList, + + [EnumMember(Value = "rich_text_preformatted")] + RichTextPreformatted, + + [EnumMember(Value = "rich_text_quote")] + RichTextQuote +} + +================ +File: Enums/RichTextElementType.cs +================ +using System.Runtime.Serialization; + +namespace Hooki.Slack.Enums; + +public enum RichTextElementType +{ + [EnumMember(Value = "broadcast")] + Broadcast, + + [EnumMember(Value = "color")] + Color, + + [EnumMember(Value = "channel")] + Channel, + + [EnumMember(Value = "date")] + Date, + + [EnumMember(Value = "emoji")] + Emoji, + + [EnumMember(Value = "link")] + Link, + + [EnumMember(Value = "text")] + Text, + + [EnumMember(Value = "user")] + User, + + [EnumMember(Value = "usergroup")] + UserGroup, +} + +================ +File: Enums/RichTextListStyleType.cs +================ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum RichTextListStyleType +{ + [EnumMember(Value = "bullet")] + Bullet, + [EnumMember(Value = "ordered")] + Ordered +} + +================ +File: Enums/TextObjectType.cs +================ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum TextObjectType +{ + [EnumMember(Value = "plain_text")] + PlainText, + + [EnumMember(Value = "mrkdwn")] + Markdown +} + +================ +File: Enums/ViewObjectType.cs +================ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum ViewObjectType +{ + [EnumMember(Value = "modal")] + Modal, + [EnumMember(Value = "home")] + Home +} + +================ +File: Enums/WorkflowButtonElementStyle.cs +================ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Enums; + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum WorkflowButtonElementStyle +{ + /// + /// Green styling used for affirmation or confirmation actions + /// + [EnumMember(Value = "primary")] + Primary, + + /// + /// Red styling used for destructive actions + /// + [EnumMember(Value = "danger")] + Danger +} + +================ +File: JsonConverters/ActionBlockConverter.cs +================ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Hooki.Slack.Models.BlockElements; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.JsonConverters; + +public class ActionBlockConverter : JsonConverter +{ + private static readonly Dictionary TypeMap = new() + { + { "actions", typeof(ActionBlock) }, + { "context", typeof(ContextBlock) }, + { "divider", typeof(DividerBlock) }, + { "file", typeof(FileBlock) }, + { "header", typeof(HeaderBlock) }, + { "image", typeof(ImageBlock) }, + { "input", typeof(InputBlock) }, + { "rich_text", typeof(RichTextBlock) }, + { "section", typeof(SectionBlock) }, + { "video", typeof(VideoBlock) } + }; + + private static readonly Dictionary ElementTypeMap = new() + { + { "button", typeof(ButtonElement) }, + { "checkboxes", typeof(CheckboxElement) }, + { "datepicker", typeof(DatePickerElement) }, + { "datetimepicker", typeof(DateTimePickerElement) }, + { "multi_static_select", typeof(MultiSelectMenuElement) }, + { "overflow", typeof(OverflowMenuElement) }, + { "radio_buttons", typeof(RadioButtonGroupElement) }, + { "rich_text_input", typeof(RichTextInputElement) }, + { "static_select", typeof(SelectMenuElement) }, + { "timepicker", typeof(TimePickerElement) }, + { "workflow_button", typeof(WorkflowButtonElement) } + }; + + public override BlockBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("JSON object expected."); + } + + using var jsonDoc = JsonDocument.ParseValue(ref reader); + var root = jsonDoc.RootElement; + + if (!root.TryGetProperty("type", out var typeProperty)) + { + throw new JsonException("Missing 'type' property"); + } + + var typeString = typeProperty.GetString()?.ToLower(); + if (!TypeMap.TryGetValue(typeString, out var blockType)) + { + throw new JsonException($"Unknown block type: {typeString}"); + } + + var block = (BlockBase)Activator.CreateInstance(blockType); + + foreach (var property in blockType.GetProperties()) + { + var jsonPropertyName = property.GetCustomAttribute()?.Name ?? property.Name.ToLower(); + if (root.TryGetProperty(jsonPropertyName, out var element)) + { + if (property.Name == "Elements" && blockType == typeof(ActionBlock)) + { + var elements = DeserializeActionBlockElements(element, options); + property.SetValue(block, elements); + } + else + { + var value = JsonSerializer.Deserialize(element.GetRawText(), property.PropertyType, options); + property.SetValue(block, value); + } + } + } + + return block; + } + + public override void Write(Utf8JsonWriter writer, BlockBase value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + foreach (var property in value.GetType().GetProperties()) + { + var jsonPropertyName = property.GetCustomAttribute()?.Name ?? property.Name.ToLower(); + var propertyValue = property.GetValue(value); + + if (propertyValue == null) continue; + + writer.WritePropertyName(jsonPropertyName); + + if (property.Name == "Elements" && value is ActionBlock) + { + SerializeActionBlockElements(writer, (List)propertyValue, options); + } + else + { + JsonSerializer.Serialize(writer, propertyValue, property.PropertyType, options); + } + } + + writer.WriteEndObject(); + } + + private List DeserializeActionBlockElements(JsonElement elementsProperty, JsonSerializerOptions options) + { + var elements = new List(); + + foreach (var element in elementsProperty.EnumerateArray()) + { + if (element.TryGetProperty("type", out var typeProperty)) + { + var elementTypeString = typeProperty.GetString(); + if (ElementTypeMap.TryGetValue(elementTypeString, out var elementType)) + { + var blockElement = (IActionBlockElement)JsonSerializer.Deserialize(element.GetRawText(), elementType, options); + elements.Add(blockElement); + } + else + { + throw new JsonException($"Unknown element type: {elementTypeString}"); + } + } + } + + if (elements.Count == 0) + { + throw new JsonException("ActionBlock must contain at least one element"); + } + + return elements; + } + + private void SerializeActionBlockElements(Utf8JsonWriter writer, List elements, JsonSerializerOptions options) + { + writer.WriteStartArray(); + + foreach (var element in elements) + { + JsonSerializer.Serialize(writer, element, element.GetType(), options); + } + + writer.WriteEndArray(); + } +} + +================ +File: Models/BlockElements/BlockElementBase.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.BlockElements; + +public class BlockElementBase +{ + [JsonPropertyName("action_id")] public string? ActionId { get; set; } +} + +================ +File: Models/BlockElements/ButtonElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#button +/// +public class ButtonElement : BlockElementBase, IActionBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.Button; + + [JsonPropertyName("text")] public required TextObject Text { get; set; } + + [JsonPropertyName("url")] public string? Url { get; set; } + + [JsonPropertyName("value")] public string? Value { get; set; } + + [JsonPropertyName("style")] public string? Style { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } + + [JsonPropertyName("accessibility_label")] public string? AccessibilityLabel { get; set; } +} + +================ +File: Models/BlockElements/CheckboxElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#checkboxes +/// + +public class CheckboxElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.Checkboxes; + + [JsonPropertyName("options")] public required List Options { get; set; } + + [JsonPropertyName("initial_options")] public List? InitialOptions { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } +} + +================ +File: Models/BlockElements/DatePickerElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#datepicker +/// +public class DatePickerElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.DatePicker; + + /// + /// Format YYYY-MM-DD + /// + [JsonPropertyName("initial_date")] public string? InitialDate { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } + + /// + /// When provided, the TextObject type should be "PlainText" + /// + [JsonPropertyName("placeholder")] public TextObject? Placeholder { get; set; } +} + +================ +File: Models/BlockElements/DateTimePickerElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#datetimepicker +/// +public class DateTimePickerElement : BlockElementBase, IActionBlockElement, IInputBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.DatetimePicker; + + /// + /// Form as UNIX timestamp in seconds. Here is an example: 1628633820 + /// + [JsonPropertyName("initial_date_time")] public int? InitialDateTime { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } +} + +================ +File: Models/BlockElements/EmailInputElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#email +/// +public class EmailInputElement : BlockElementBase, IInputBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.EmailInput; + + [JsonPropertyName("initial_value")] public string? InitialValue { get; set; } + + [JsonPropertyName("dispatch_action_config")] public DispatchActionConfigurationObject? DispatchActionConfig { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } + + [JsonPropertyName("placeholder")] public TextObject? Placeholder { get; set; } +} + +================ +File: Models/BlockElements/FileInputElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#file_input +/// +public class FileInputElement : BlockElementBase, IInputBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.FileInput; + + [JsonPropertyName("filetypes")] public List? FileTypes { get; set; } + + /// + /// Supported file types: https://api.slack.com/types/file#types + /// + [JsonPropertyName("max_files")] public int? MaxFiles { get; set; } +} + +================ +File: Models/BlockElements/ImageElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#image +/// +public class ImageElement : BlockElementBase, IContextBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.Image; + + [JsonPropertyName("alt_text")] public required string AltText { get; set; } + + /// + /// You must provide either an ImageUrl or SlackFile + /// Maximum length is 3000 characters + /// + [JsonPropertyName("image_url")] public string? ImageUrl { get; set; } + + /// + /// You must provide either an SlackFile or ImageUrl + /// Refer to Discord's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#slack_file + /// + [JsonPropertyName("slack_file")] public SlackFileObject? SlackFile { get; set; } +} + +================ +File: Models/BlockElements/MultiSelectMenuElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#multi_select +/// +public class MultiSelectMenuElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.MultiSelectMenu; + + [JsonPropertyName("placeholder")] public TextObject? Placeholder { get; set; } + + [JsonPropertyName("options")] public required OptionObject[] Options { get; set; } + + [JsonPropertyName("initial_options")] public OptionObject[]? InitialOptions { get; set; } + + [JsonPropertyName("option_groups")] public OptionGroupObject[]? OptionGroups { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } + + [JsonPropertyName("max_selected_items")] public int? MaxSelectedItems { get; set; } + + // User list properties + [JsonPropertyName("min_query_length")] public int? MinQueryLength { get; set; } + + [JsonPropertyName("initial_users")] public string[]? InitialUsers { get; set; } + + // Conversations list properties + [JsonPropertyName("initial_conversations")] public string[]? InitialConversations { get; set; } + [JsonPropertyName("default_to_current_conversation")] public bool? DefaultToCurrentConversation { get; set; } + [JsonPropertyName("filter")] public ConversationFilterObject? Filter { get; set; } + + // Public channel list properties + [JsonPropertyName("initial_channels")] public string[]? InitialChannels { get; set; } +} + +================ +File: Models/BlockElements/NumberInputElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#number +/// +public class NumberInputElement : BlockElementBase, IInputBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.NumberInput; + + [JsonPropertyName("is_decimal_allowed")] + public required bool IsDecimalAllowed { get; set; } + + [JsonPropertyName("initial_value")] + public string? InitialValue { get; set; } + + /// + /// Cannot be greater than MaxValue + /// + [JsonPropertyName("min_value")] + public string? MinValue { get; set; } + + /// + /// Cannot be less than MinValue + /// + [JsonPropertyName("max_value")] + public string? MaxValue { get; set; } + + [JsonPropertyName("dispatch_action_config")] + public DispatchActionConfigurationObject? DispatchActionConfig { get; set; } + + [JsonPropertyName("focus_on_load")] + public bool? FocusOnLoad { get; set; } + + [JsonPropertyName("placeholder")] + public TextObject? Placeholder { get; set; } +} + +================ +File: Models/BlockElements/OverflowMenuElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#overflow +/// +public class OverflowMenuElement : BlockElementBase, IActionBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.OverflowMenu; + + [JsonPropertyName("options")] public required List Options { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } +} + +================ +File: Models/BlockElements/PlainTextInputElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#input +/// +public class PlainTextInputElement : BlockElementBase, IInputBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.PlainTextInput; + + [JsonPropertyName("initial_value")] public string? InitialValue { get; set; } + + [JsonPropertyName("multiline")] public bool? Multiline { get; set; } + + [JsonPropertyName("min_length")] public int? MinLength { get; set; } + + [JsonPropertyName("max_length")] public int? MaxLength { get; set; } + + [JsonPropertyName("dispatch_action_config")] public DispatchActionConfigurationObject? DispatchActionConfig { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } + + /// + /// When provided, TextObject Type must be PlainText + /// + [JsonPropertyName("placeholder")] public TextObject? Placeholder { get; set; } +} + +================ +File: Models/BlockElements/RadioButtonGroupElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#radio +/// +public class RadioButtonGroupElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.RadioButtonGroup; + + [JsonPropertyName("options")] public required OptionObject[] Options { get; set; } + + /// + /// Must match one of the options within Options + /// + [JsonPropertyName("initial_option")] public OptionObject? InitialOption { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } +} + +================ +File: Models/BlockElements/RichTextInputElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#rich_text_input +/// +public class RichTextInputElement : BlockElementBase, IActionBlockElement, IInputBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.RichTextInput; + + [JsonPropertyName("initial_value")] + public RichTextBlock? InitialValue { get; set; } + + [JsonPropertyName("dispatch_action_config")] + public DispatchActionConfigurationObject? DispatchActionConfig { get; set; } + + [JsonPropertyName("focus_on_load")] + public bool? FocusOnLoad { get; set; } + + [JsonPropertyName("placeholder")] + public TextObject? Placeholder { get; set; } +} + +================ +File: Models/BlockElements/SelectMenuElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#static_select +/// +public class SelectMenuElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.SelectMenu; + + [JsonPropertyName("options")] public required OptionObject[] Options { get; set; } + + [JsonPropertyName("option_groups")] public OptionGroupObject[]? OptionGroups { get; set; } + + [JsonPropertyName("initial_option")] public OptionObject? InitialOption { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } + + [JsonPropertyName("placeholder")] public TextObject? Placeholder { get; set; } +} + +================ +File: Models/BlockElements/TimePickerElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#timepicker +/// +public class TimePickerElement : BlockElementBase, IActionBlockElement, IInputBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.TimePicker; + + /// + /// Format: HH:mm + /// + [JsonPropertyName("initial_time")] public string? InitialTime { get; set; } + + [JsonPropertyName("confirm")] public ConfirmationDialogObject? Confirm { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } + + [JsonPropertyName("placeholder")] public TextObject? Placeholder { get; set; } + + /// + /// Format: IANA e.g. "America/Chicago" + /// + [JsonPropertyName("timezone")] public string? Timezone { get; set; } +} + +================ +File: Models/BlockElements/UrlInputElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#url +/// +public class UrlInputElement : BlockElementBase, IInputBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.UrlInput; + + [JsonPropertyName("initial_value")] public string? InitialValue { get; set; } + + [JsonPropertyName("dispatch_action_config")] public DispatchActionConfigurationObject? DispatchActionConfig { get; set; } + + [JsonPropertyName("focus_on_load")] public bool? FocusOnLoad { get; set; } + + [JsonPropertyName("placeholder")] public TextObject? Placeholder { get; set; } +} + +================ +File: Models/BlockElements/WorkflowButtonElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.BlockElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/block-elements#workflow_button +/// +public class WorkflowButtonElement : BlockElementBase, IActionBlockElement, ISectionBlockElement +{ + [JsonPropertyName("type")] public BlockElementType Type => BlockElementType.WorkflowButton; + + [JsonPropertyName("text")] public required TextObject Text { get; set; } + + [JsonPropertyName("workflow")] public required WorkflowObject Workflow { get; set; } + + /// + /// If you don't provide a value, default button style will be used + /// + [JsonPropertyName("style")] public WorkflowButtonElementStyle? Style { get; set; } + + [JsonPropertyName("accessibility_label")] public string? AccessibilityLabel { get; set; } +} + +================ +File: Models/Blocks/ActionBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#actions +/// + +public class ActionBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.ActionBlock; + + [JsonPropertyName("elements")] public required List Elements { get; set; } +} + +public interface IActionBlockElement { } + +================ +File: Models/Blocks/BlockBase.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.JsonConverters; + +namespace Hooki.Slack.Models.Blocks; + +[JsonConverter(typeof(ActionBlockConverter))] +public class BlockBase +{ + [JsonPropertyName("block_id")] public string? BlockId { get; set; } +} + +================ +File: Models/Blocks/ContextBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#context +/// +public class ContextBlock : BlockBase +{ + [JsonPropertyName("type")] public static BlockType Type => BlockType.ContextBlock; + + [JsonPropertyName("elements")] public required List Elements { get; set; } +} + +public interface IContextBlockElement { } + +================ +File: Models/Blocks/DividerBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#divider +/// +public class DividerBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.DividerBlock; +} + +================ +File: Models/Blocks/FileBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#file +/// +public class FileBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.FileBlock; + + [JsonPropertyName("external_id")] + public required string ExternalId { get; set; } + + [JsonPropertyName("source")] + public required string Source { get; set; } +} + +================ +File: Models/Blocks/HeaderBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#header +/// +public class HeaderBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.HeaderBlock; + + [JsonPropertyName("text")] public required TextObject Text { get; set; } +} + +================ +File: Models/Blocks/ImageBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#image +/// +public class ImageBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.ImageBlock; + + [JsonPropertyName("alt_text")] public required TextObject AltText { get; set; } + + /// + /// Must provide either ImageUrl or SlackFile + /// + [JsonPropertyName("image_url")] public string? ImageUrl { get; set; } + + /// + /// Must provide either SlackFile or ImageUrl + /// + [JsonPropertyName("slack_file")] public SlackFileObject? SlackFile { get; set; } + + /// + /// When provided, TextObject must be of type "PlainText" + /// + [JsonPropertyName("title")] public TextObject? Title { get; set; } +} + +================ +File: Models/Blocks/InputBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#input +/// +public class InputBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.InputBlock; + + /// + /// TextObject must have type of "PlainText" + /// + [JsonPropertyName("label")] public required TextObject Label { get; set; } + + [JsonPropertyName("element")] public required IInputBlockElement Element { get; set; } + + [JsonPropertyName("dispatch_action")] public bool? DispatchAction { get; set; } + + /// + /// TextObject must have type of "PlainText" + /// + [JsonPropertyName("hint")] public TextObject? Hint { get; set; } + + [JsonPropertyName("optional")] public bool? Optional { get; set; } +} + +public interface IInputBlockElement { } + +================ +File: Models/Blocks/RichTextBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text +/// +public class RichTextBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.RichTextBlock; + + [JsonPropertyName("elements")] public required List Elements { get; set; } +} + +public interface IRichTextBlockElement { } + +public interface IRichTextElement { } + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text_section +/// +public class RichTextSection : IRichTextBlockElement +{ + [JsonPropertyName("type")] public const RichTextBlockElementType Type = RichTextBlockElementType.RichTextSection; + + [JsonPropertyName("elements")] public required IRichTextElement[] Elements { get; set; } +} + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text_list +/// +public class RichTextList : IRichTextBlockElement +{ + [JsonPropertyName("type")] public const RichTextBlockElementType Type = RichTextBlockElementType.RichTextList; + + [JsonPropertyName("style")] public required RichTextListStyleType Style { get; set; } + + [JsonPropertyName("elements")] public required IRichTextElement[] Elements { get; set; } + + [JsonPropertyName("indent")] public int? Indent { get; set; } + + [JsonPropertyName("offset")] public int? Offset { get; set; } + + [JsonPropertyName("border")] public int? Border { get; set; } +} + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text_preformatted +/// +public class RichTextPreformatted : IRichTextBlockElement +{ + [JsonPropertyName("type")] public const RichTextBlockElementType Type = RichTextBlockElementType.RichTextPreformatted; + + [JsonPropertyName("elements")] public required IRichTextElement[] Elements { get; set; } + + [JsonPropertyName("border")] public int? Border { get; set; } +} + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#rich_text_quote +/// +public class RichTextQuote : IRichTextBlockElement +{ + [JsonPropertyName("type")] public const RichTextBlockElementType Type = RichTextBlockElementType.RichTextQuote; + + [JsonPropertyName("elements")] public required IRichTextElement[] Elements { get; set; } + + [JsonPropertyName("border")] public int? Border { get; set; } +} + +================ +File: Models/Blocks/SectionBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#section +/// +public class SectionBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.SectionBlock; + + /// + /// TextObject must have type of "PlainText" + /// This is a preferred field + /// + [JsonPropertyName("text")] public TextObject? Text { get; set; } + + /// + /// This is a maybe field + /// Required if Text isn't provided + /// + [JsonPropertyName("fields")] public TextObject[]? Fields { get; set; } + + [JsonPropertyName("accessory")] public ISectionBlockElement? Accessory { get; set; } + + [JsonPropertyName("expand")] public bool? Expand { get; set; } +} + +public interface ISectionBlockElement { } + +================ +File: Models/Blocks/VideoBlock.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.Blocks; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#video +/// +public class VideoBlock : BlockBase +{ + [JsonPropertyName("type")] public BlockType Type => BlockType.VideoBlock; + + [JsonPropertyName("alt_text")] public required string AltText { get; set; } + + [JsonPropertyName("author_name")] public string? AuthorName { get; set; } + + /// + /// TextObject must have type of "PlainText" + /// + [JsonPropertyName("description")] public required TextObject Description { get; set; } + + [JsonPropertyName("provider_icon_url")] public string? ProviderIconUrl { get; set; } + + [JsonPropertyName("provider_name")] public string? ProviderName { get; set; } + + /// + /// TextObject must have type of "PlainText" + /// + [JsonPropertyName("title")] public TextObject? Title { get; set; } + + /// + /// When provided, the url must be HTTPS + /// + + [JsonPropertyName("title_url")] public string? TitleUrl { get; set; } + + [JsonPropertyName("thumbnail_url")] public required string ThumbnailUrl { get; set; } + + [JsonPropertyName("video_url")] public required string VideoUrl { get; set; } +} + +================ +File: Models/CompositionObjects/ConfirmationDialogObject.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#confirm +/// +public class ConfirmationDialogObject +{ + [JsonPropertyName("title")] public required TextObject Title { get; set; } + + [JsonPropertyName("text")] public required TextObject Text { get; set; } + + [JsonPropertyName("confirm")] public required TextObject Confirm { get; set; } + + [JsonPropertyName("deny")] public required TextObject Deny { get; set; } + + [JsonPropertyName("style")] public string? Style { get; set; } +} + +================ +File: Models/CompositionObjects/ConversationFilterObject.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Please note that while none of the fields above are individually required, you must supply at least one of these fields. +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#filter_conversations +/// +public class ConversationFilterObject +{ + [JsonPropertyName("include")] public string[]? Include { get; set; } + + [JsonPropertyName("exclude_external_shared_channels")] public bool? ExcludeExternalSharedChannels { get; set; } + + [JsonPropertyName("exclude_bot_users")] public bool? ExcludeBotUsers { get; set; } +} + +================ +File: Models/CompositionObjects/DispatchActionConfigurationObject.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#dispatch_action_config +/// +public class DispatchActionConfigurationObject +{ + [JsonPropertyName("trigger_actions_on")] public string[]? TriggerActionsOn { get; set; } +} + +================ +File: Models/CompositionObjects/OptionGroupObject.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#option_group +/// +public class OptionGroupObject +{ + /// + /// TextObject type should be "PlainText" + /// + [JsonPropertyName("label")] public required TextObject Label { get; set; } + + [JsonPropertyName("options")] public required OptionObject[] Options { get; set; } +} + +================ +File: Models/CompositionObjects/OptionObject.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#option +/// +public class OptionObject +{ + [JsonPropertyName("text")] public required TextObject Text { get; set; } + + [JsonPropertyName("value")] public required string Value { get; set; } + + /// + /// When provided, the TextObject type should be "PlainText" + /// + [JsonPropertyName("description")] public TextObject? Description { get; set; } + + [JsonPropertyName("url")] public string? Url { get; set; } +} + +================ +File: Models/CompositionObjects/SlackFileObject.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#slack_file +/// +public class SlackFileObject +{ + [JsonPropertyName("url")] public string? Url { get; set; } + + [JsonPropertyName("id")] public string? Id { get; set; } +} + +================ +File: Models/CompositionObjects/TextObject.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#text +/// +public class TextObject : IContextBlockElement +{ + [JsonPropertyName("type")] public required TextObjectType Type { get; set; } + + [JsonPropertyName("text")] public required string Text { get; set; } + + /// + /// This field is only usable when Type is plain_text + /// + [JsonPropertyName("emoji")] public bool? Emoji { get; set; } + + /// + /// This field is only usable when Type is mrkdwn + /// + [JsonPropertyName("verbatim")] public bool? Verbatim { get; set; } +} + +================ +File: Models/CompositionObjects/TriggerObject.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#trigger +/// +public class TriggerObject +{ + /// + /// Url must be a valid link trigger url. Refer to Slack's documentation for more details: https://api.slack.com/automation/triggers/link + /// + [JsonPropertyName("url")] public required string Url { get; set; } + + [JsonPropertyName("customizable_input_parameters")] public CustomizableInputParameter[]? CustomizableInputParameters { get; set; } +} + +/// +/// The values used for these customizable_input_parameters may be visible client-side to end users. +/// You should not share sensitive information or secrets via these input parameters. +/// +public class CustomizableInputParameter +{ + public required string Name { get; set; } + public required string Value { get; set; } +} + +================ +File: Models/CompositionObjects/WorkflowObject.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.CompositionObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#workflow +/// +public class WorkflowObject +{ + [JsonPropertyName("trigger")] public required TriggerObject Trigger { get; set; } +} + +================ +File: Models/RichTextElements/AdvancedTextStyle.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.RichTextElements; + +public class AdvancedTextStyle +{ + [JsonPropertyName("bold")] public bool? Bold { get; set; } + + [JsonPropertyName("italic")] public bool? Italic { get; set; } + + [JsonPropertyName("strike")] public bool? Strike { get; set; } + + [JsonPropertyName("highlight")] public bool? Highlight { get; set; } + + [JsonPropertyName("client_highlight")] public bool? ClientHighlight { get; set; } + + [JsonPropertyName("unlink")] public bool? Unlink { get; set; } +} + +================ +File: Models/RichTextElements/BasicTextStyle.cs +================ +using System.Text.Json.Serialization; + +namespace Hooki.Slack.Models.RichTextElements; + +public class BasicTextStyle +{ + [JsonPropertyName("Bold")] public bool? Bold { get; set; } + + [JsonPropertyName("italic")] public bool? Italic { get; set; } + + [JsonPropertyName("strike")] public bool? Strike { get; set; } + + [JsonPropertyName("code")] public bool? Code { get; set; } +} + +================ +File: Models/RichTextElements/BroadcastElement.cs +================ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class BroadcastElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Broadcast; + + [JsonPropertyName("range")] public required BroadcastRangeType Range { get; set; } +} + +//ToDo: Refactor this in .NET 9 with new attribute: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonStringEnumMemberNameAttribute.cs +[JsonConverter(typeof(JsonStringEnumMemberConverter))] +public enum BroadcastRangeType +{ + [EnumMember(Value = "here")] + Here, + + [EnumMember(Value = "channel")] + Channel, + + [EnumMember(Value = "everyone")] + Everyone +} + +================ +File: Models/RichTextElements/ChannelElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class ChannelElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Channel; + + [JsonPropertyName("channel_id")] public required string ChannelId { get; set; } + + [JsonPropertyName("style")] public AdvancedTextStyle? Style { get; set; } +} + +================ +File: Models/RichTextElements/ColorElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class ColorElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Color; + + /// + /// The hex value for the color + /// + [JsonPropertyName("value")] public required string Value { get; set; } +} + +================ +File: Models/RichTextElements/DateElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class DateElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Date; + + /// + /// A Unix timestamp for the date to be displayed in seconds + /// + [JsonPropertyName("timestamp")] public required int Timestamp { get; set; } + + /// + /// A template string containing curly-brace-enclosed tokens to substitute your provided timestamp + /// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#color-element-type + /// + [JsonPropertyName("format")] public required string Format { get; set; } +} + +================ +File: Models/RichTextElements/EmojiElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class EmojiElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Emoji; + + /// + /// The name of the emoji; i.e. "wave" or "wave::skin-tone-2" + /// + [JsonPropertyName("name")] public required string Name { get; set; } + + /// + /// Represents the unicode code point of the emoji, where applicable + /// + [JsonPropertyName("unicode")] public string? Unicode { get; set; } +} + +================ +File: Models/RichTextElements/LinkElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class LinkElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Link; + + [JsonPropertyName("url")] public required string Url { get; set; } + + [JsonPropertyName("text")] public string? Text { get; set; } + + [JsonPropertyName("unsafe")] public bool? Unsafe { get; set; } + + [JsonPropertyName("style")] public required BasicTextStyle Style { get; set; } +} + +================ +File: Models/RichTextElements/TextElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class TextElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.Text; + + [JsonPropertyName("text")] public required string Text { get; set; } + + [JsonPropertyName("style")] public BasicTextStyle? Style { get; set; } +} + +================ +File: Models/RichTextElements/UserElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class UserElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.User; + + [JsonPropertyName("user_id")] public required string UserId { get; set; } + + [JsonPropertyName("style")] public AdvancedTextStyle? Style { get; set; } +} + +================ +File: Models/RichTextElements/UserGroupElement.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.RichTextElements; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/blocks#element-types +/// +public class UserGroupElement : IRichTextElement +{ + [JsonPropertyName("type")] public RichTextElementType Type => RichTextElementType.UserGroup; + + [JsonPropertyName("usergroup_id")] public required string UserGroupId { get; set; } + + [JsonPropertyName("style")] public AdvancedTextStyle? Style { get; set; } +} + +================ +File: Models/ViewObjects/HomeTab.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models.ViewObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/block-kit/composition-objects#confirm +/// +public class HomeTab +{ + [JsonPropertyName("type")] public static ViewObjectType Type => ViewObjectType.Home; + + [JsonPropertyName("blocks")] public required BlockBase[] Blocks { get; set; } + + [JsonPropertyName("private_metadata")] public string? PrivateMetadata { get; set; } + + [JsonPropertyName("callback_id")] public string? CallbackId { get; set; } + + [JsonPropertyName("external_id")] public string? ExternalId { get; set; } +} + +================ +File: Models/ViewObjects/Modal.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Enums; +using Hooki.Slack.Models.Blocks; +using Hooki.Slack.Models.CompositionObjects; + +namespace Hooki.Slack.Models.ViewObjects; + +/// +/// Refer to Slack's documentation for more details: https://api.slack.com/reference/surfaces/views#modal +/// +public class Modal +{ + [JsonPropertyName("type")] public static ViewObjectType Type => ViewObjectType.Modal; + + [JsonPropertyName("title")] public required TextObject Title { get; set; } + + [JsonPropertyName("blocks")] public required BlockBase[] Blocks { get; set; } + + /// + /// When provided, TextObject must be of type PlainText + /// + [JsonPropertyName("close")] public TextObject? Close { get; set; } + + /// + /// When provided, TextObject must be of type PlainText + /// + [JsonPropertyName("submit")] public TextObject? Submit { get; set; } + + [JsonPropertyName("private_metadata")] public string? PrivateMetadata { get; set; } + + [JsonPropertyName("callback_id")] public string? CallBackId { get; set; } + + [JsonPropertyName("clear_on_close")] public bool? ClearOnClose { get; set; } + + [JsonPropertyName("notify_on_close")] public bool? NotifyOnClose { get; set; } + + [JsonPropertyName("external_id")] public string? ExternalId { get; set; } + + [JsonPropertyName("submit_disabled")] public bool? SubmitDisabled { get; set; } +} + +================ +File: Models/SlackWebhookPayload.cs +================ +using System.Text.Json.Serialization; +using Hooki.Slack.Models.Blocks; + +namespace Hooki.Slack.Models; + +public class SlackWebhookPayload +{ + [JsonPropertyName("blocks")] public required List Blocks { get; set; } +}