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.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