From e4171e6d9e945e763a178e86921ea416643f49bb Mon Sep 17 00:00:00 2001
From: Diego Trevisan Lara
Date: Wed, 4 Feb 2026 08:28:14 +0100
Subject: [PATCH 1/5] Add result builder API for declarative message
construction
Follows SlackKit's architecture pattern with TypeName+Builder.swift files:
Message+Builder.swift:
- @AttachmentBuilder for building attachment arrays
- Message extension with result builder initializer
- Attachment extension with field builder initializer
Attachment+Builder.swift:
- @FieldBuilder for building field arrays
- @ActionBuilder for building action arrays
- Extension initializers for Attachment
- Convenience functions: Field(), Button()
Props+Builder.swift:
- @PropsBuilder for constructing dynamic property dictionaries
- Props initializer with result builder
- Property() convenience functions for all AnyCodable types
Confirmation+Builder.swift:
- @ConfirmationBuilder for building confirmation dialogs
- Confirmation initializer with result builder
- ConfirmButton() and DenyButton() convenience functions
README.md:
- Updated to showcase only the Result Builder API
- Added Props and Confirmation builder examples
- Updated Builder API Reference section
20 comprehensive tests for result builder functionality.
All 33 tests passing.
---
README.md | 420 ++++++++++++------
.../Models/Attachment+Builder.swift | 151 +++++++
.../Models/Confirmation+Builder.swift | 101 +++++
.../Models/Message+Builder.swift | 185 ++++++++
.../MattermostKit/Models/Props+Builder.swift | 147 ++++++
.../Builders/AttachmentBuilderTests.swift | 266 +++++++++++
6 files changed, 1146 insertions(+), 124 deletions(-)
create mode 100644 Sources/MattermostKit/Models/Attachment+Builder.swift
create mode 100644 Sources/MattermostKit/Models/Confirmation+Builder.swift
create mode 100644 Sources/MattermostKit/Models/Message+Builder.swift
create mode 100644 Sources/MattermostKit/Models/Props+Builder.swift
create mode 100644 Tests/MattermostKitTests/Builders/AttachmentBuilderTests.swift
diff --git a/README.md b/README.md
index 836e125..f529458 100644
--- a/README.md
+++ b/README.md
@@ -23,11 +23,12 @@
## Features
-- **Modern Swift API** - Built with Swift 6, async/await, and strict concurrency
+- **Modern Result Builder API** - Declarative DSL for building messages with attachments
- **Type-Safe** - Full Codable support with compile-time safety
- **Attachments** - Slack-compatible attachment support for rich messages
- **Mattermost-Specific** - Support for `props.card` and message priority
-- **Flexible** - Send simple text messages or rich formatted messages
+- **Conditional Logic** - Native if/else and for-in support in builders
+- **Swift 6** - Built with Swift 6, async/await, and strict concurrency
## Requirements
@@ -68,9 +69,9 @@ let client = try MattermostWebhookClient.create(
try await client.send(Message(text: "Hello, Mattermost!"))
```
-## Usage
+## Result Builder API
-### Simple Text Message
+### Simple Message
```swift
let message = Message(text: "Deployment completed successfully!")
@@ -81,61 +82,146 @@ try await client.send(message)
```swift
let message = Message(
- username: "DeployBot",
- iconEmoji: ":rocket:",
text: "Deployment complete!",
- attachments: [
- Attachment(
- color: "#36a64f",
- title: "Build #123",
- text: "Succeeded in 5m 32s",
- fields: [
- AttachmentField(title: "Branch", value: "main", short: true),
- AttachmentField(title: "Commit", value: "abc123", short: true),
- AttachmentField(title: "Duration", value: "5m 32s", short: true),
- AttachmentField(title: "Status", value: ":white_check_mark: Success", short: true)
- ]
- )
- ]
-)
+ username: "DeployBot",
+ iconEmoji: ":rocket:"
+) {
+ Attachment(color: "#36a64f", title: "Build #123", text: "Succeeded in 5m 32s") {
+ Field("Branch", value: "main")
+ Field("Commit", value: "abc123")
+ Field("Duration", value: "5m 32s")
+ Field("Status", value: ":white_check_mark: Success")
+ }
+}
try await client.send(message)
```
-**With custom username and icon:**
+### Multiple Attachments
```swift
let message = Message(
+ text: "Deployment Summary",
username: "DeployBot",
- iconEmoji: ":rocket:",
- text: "Deployment complete!",
- attachments: [
- Attachment(
- color: "#36a64f",
- title: "Build #123",
- text: "Succeeded in 5m 32s"
- )
- ]
-)
-try await client.send(message)
+ iconEmoji: ":rocket:"
+) {
+ Attachment(color: "#36a64f", title: "Success") {
+ Field("Environment", value: "production")
+ Field("Duration", value: "5m 32s")
+ }
+
+ Attachment(color: "#36a64f", title: "Build Info") {
+ Field("Branch", value: "main")
+ Field("Commit", value: "abc123")
+ Field("Tests", value: "156 passed")
+ }
+}
```
-### Message with Card Props
+### Conditional Attachments
+
+```swift
+let hasWarnings = true
+let hasErrors = false
+
+let message = Message(username: "CIBot") {
+ Attachment(color: "#36a64f", title: "Build Summary") {
+ Field("Status", value: "Success")
+ }
+
+ if hasWarnings {
+ Attachment(color: "#ffaa00", title: "Warnings") {
+ Field("Count", value: "3")
+ }
+ }
+
+ if hasErrors {
+ Attachment(color: "#ff0000", title: "Errors") {
+ Field("Count", value: "1")
+ }
+ }
+}
+```
+
+### Message with Actions
+
+```swift
+let message = Message {
+ Attachment(text: "Deploy to production?") {
+ Field("Environment", value: "production")
+ Field("Version", value: "v2.4.1")
+ }
+
+ Attachment.actions {
+ Button(text: "Approve", style: "primary", url: approveURL)
+ Button(text: "Reject", style: "danger", url: rejectURL)
+ Button(text: "Defer", style: "default", url: deferURL)
+ }
+}
+```
+
+### Dynamic Fields with Loops
+
+```swift
+let testResults = [
+ ("TestLogin", "passed"),
+ ("TestAPI", "passed"),
+ ("TestUI", "failed")
+]
-Mattermost supports displaying custom content in the RHS sidebar via `props.card`:
+let message = Message {
+ Attachment(title: "Test Results") {
+ for (name, result) in testResults {
+ Field(name, value: result, short: true)
+ }
+ }
+}
+```
+
+### Message with Card Props
```swift
+// Simple card with static text
let message = Message(
text: "We won a new deal!",
- props: Props(card: "Salesforce Opportunity Information:\\n\\n**Amount:** $300,020.00\\n**Close Date:** 2025-01-15\\n**Sales Rep:** John Doe")
+ props: Props(card: """
+ Salesforce Opportunity Information:
+
+ **Amount:** $300,020.00
+ **Close Date:** 2025-01-15
+ **Sales Rep:** John Doe
+ """)
+)
+
+// Card with dynamic properties using builder
+let message = Message(
+ text: "Deal updated!",
+ props: Props(card: "Deal Information") {
+ Property("amount", value: 300020)
+ Property("stage", value: "Proposal")
+ Property("is_closed", value: false)
+ }
+)
+
+// Conditional properties
+let includeDetails = true
+let message = Message(
+ text: "Opportunity created",
+ props: Props(card: "Sales Info") {
+ Property("opportunity_id", value: "12345")
+ Property("account", value: "Acme Corp")
+
+ if includeDetails {
+ Property("estimated_value", value: 50000)
+ Property("probability", value: 0.75)
+ }
+ }
)
-try await client.send(message)
```
### Message with Priority
-Mattermost supports urgent and important message priorities:
-
```swift
+// Urgent priority with acknowledgment
let message = Message(
text: "Critical incident!",
priority: Priority(
@@ -144,86 +230,194 @@ let message = Message(
persistentNotifications: true
)
)
-try await client.send(message)
-```
-**Important priority:**
-
-```swift
+// Important priority
let message = Message(
text: "Important announcement",
priority: Priority(priority: .important)
)
-try await client.send(message)
```
-### Message with Actions
-
-Interactive buttons for user actions:
+### Confirmation Dialogs for Actions
```swift
-let message = Message(
- text: "Approval required for production deployment",
- attachments: [
- Attachment(
- text: "Deploy to production?",
- actions: [
- Action(
- name: "approve",
- text: "Approve",
- style: "primary",
- url: "https://example.com/approve"
- ),
- Action(
- name: "reject",
- text: "Reject",
- style: "danger",
- url: "https://example.com/reject"
- )
- ]
- )
- ]
+// Simple confirmation
+let confirm = Confirmation(
+ title: "Are you sure?",
+ text: "This will deploy to production",
+ confirmText: "Deploy",
+ denyText: "Cancel"
)
-try await client.send(message)
-```
-### Message with Rich Attachments
+// Using result builder for custom buttons
+let confirm = Confirmation(
+ title: "Confirm Deployment",
+ text: "This action cannot be undone"
+) {
+ ConfirmButton(text: "Yes, Deploy", style: "primary")
+ DenyButton(text: "No, Cancel")
+}
+
+let message = Message {
+ Attachment.actions {
+ Button(text: "Deploy", style: "primary", url: deployURL, confirm: confirm)
+ Button(text: "Cancel", style: "danger", url: cancelURL)
+ }
+}
+```
-Complete attachment with all available fields:
+### Complex Message with All Features
```swift
let message = Message(
username: "CI/CD Bot",
iconURL: "https://example.com/ci-icon.png",
- text: "Build notification",
- attachments: [
- Attachment(
- fallback: "Build #123 succeeded",
- color: "#36a64f",
- pretext: "Build process completed",
- authorName: "Jenkins",
- authorLink: "https://jenkins.example.com",
- authorIcon: "https://example.com/jenkins-icon.png",
- title: "Build #123",
- titleLink: "https://jenkins.example.com/job/123",
- text: "All tests passed successfully",
- fields: [
- AttachmentField(title: "Branch", value: "feature/new-api", short: true),
- AttachmentField(title: "Commit", value: "a1b2c3d", short: true),
- AttachmentField(title: "Duration", value: "5m 32s", short: true),
- AttachmentField(title: "Tests", value: "156 passed", short: true)
- ],
- imageURL: "https://example.com/build-graph.png",
- thumbURL: "https://example.com/thumb.png",
- footer: "Jenkins CI",
- footerIcon: "https://example.com/jenkins.png"
- )
- ]
+ text: "Build notification"
+) {
+ Attachment(
+ color: "#36a64f",
+ title: "Build #123",
+ pretext: "Build process completed",
+ authorName: "Jenkins",
+ authorLink: "https://jenkins.example.com",
+ authorIcon: "https://example.com/jenkins-icon.png",
+ titleLink: "https://jenkins.example.com/job/123",
+ imageURL: "https://example.com/build-graph.png",
+ thumbURL: "https://example.com/thumb.png",
+ footer: "Jenkins CI",
+ footerIcon: "https://example.com/jenkins.png"
+ ) {
+ Field("Branch", value: "feature/new-api", short: true)
+ Field("Commit", value: "a1b2c3d", short: true)
+ Field("Duration", value: "5m 32s", short: true)
+ Field("Tests", value: "156 passed", short: true)
+ }
+}
+```
+
+### Confirmation Dialogs for Actions
+
+```swift
+let confirm = Confirmation(
+ title: "Are you sure?",
+ text: "This will deploy to production",
+ confirmText: "Deploy",
+ denyText: "Cancel"
)
-try await client.send(message)
+
+let message = Message {
+ Attachment.actions {
+ Button(text: "Deploy", style: "primary", url: deployURL, confirm: confirm)
+ Button(text: "Cancel", style: "danger", url: cancelURL)
+ }
+}
+```
+
+## Builder API Reference
+
+### Message Builder
+
+```swift
+Message(
+ text: "Message text",
+ username: "Bot Name",
+ iconEmoji: ":robot_face:"
+) {
+ // Attachments via @AttachmentBuilder
+ Attachment(title: "Title") { ... }
+}
+```
+
+### Attachment Builder
+
+```swift
+Attachment(
+ color: "#36a64f",
+ title: "Title",
+ text: "Description"
+) {
+ // Fields via @FieldBuilder
+ Field("Key", value: "Value")
+}
+```
+
+### Actions Builder
+
+```swift
+Attachment.actions {
+ // Actions via @ActionBuilder
+ Button(text: "Click", url: url)
+}
+```
+
+### Props Builder
+
+```swift
+Props(card: "Card content") {
+ // Properties via @PropsBuilder
+ Property("key", value: "value")
+ Property("number", value: 42)
+}
+```
+
+### Confirmation Builder
+
+```swift
+Confirmation(
+ title: "Confirm?",
+ text: "Are you sure?"
+) {
+ // Components via @ConfirmationBuilder
+ ConfirmButton(text: "Yes", style: "primary")
+ DenyButton(text: "No")
+}
+```
+
+### Convenience Functions
+
+```swift
+// Field with short=true by default
+Field("Branch", value: "main")
+
+// Button action
+Button(text: "Approve", style: "primary", url: "https://example.com")
+
+// Properties (various types)
+Property("name", value: "text")
+Property("count", value: 42)
+Property("enabled", value: true)
+Property("metadata", value: ["key": .string("value")])
+
+// Confirmation components
+ConfirmButton(text: "Yes", style: "primary")
+DenyButton(text: "No")
+
+// Actions-only attachment
+Attachment.actions {
+ Button(text: "View", url: viewURL)
+ Button(text: "Dismiss", style: "danger", url: dismissURL)
+}
```
-## Message Properties
+## Error Handling
+
+```swift
+do {
+ try await client.send(message)
+} catch MattermostError.invalidURL(let url) {
+ print("Invalid URL: \(url)")
+} catch MattermostError.invalidResponse(let code, let body) {
+ print("HTTP \(code): \(body ?? "No body")")
+} catch MattermostError.encodingError(let error) {
+ print("Failed to encode message: \(error)")
+} catch MattermostError.networkError(let error) {
+ print("Network error: \(error)")
+}
+```
+
+## API Reference
+
+### Message
```swift
public struct Message: Sendable, Codable {
@@ -239,7 +433,7 @@ public struct Message: Sendable, Codable {
}
```
-## Attachment Properties
+### Attachment
```swift
public struct Attachment: Sendable, Codable {
@@ -261,22 +455,16 @@ public struct Attachment: Sendable, Codable {
}
```
-## Props (Mattermost-Specific)
-
-Mattermost supports custom metadata via the `props` field:
+### Props (Mattermost-Specific)
```swift
public struct Props: Sendable, Codable {
public var card: String? // RHS sidebar content
- public var additionalProperties: [String: AnyCodable]? // Additional dynamic properties
+ public var additionalProperties: [String: AnyCodable]?
}
```
-The `card` property displays formatted content in the Mattermost RHS (Right Hand Side) sidebar when clicking on the message.
-
-## Priority (Mattermost-Specific)
-
-Message priority for urgent and important notifications:
+### Priority (Mattermost-Specific)
```swift
public struct Priority: Sendable, Codable {
@@ -291,22 +479,6 @@ public struct Priority: Sendable, Codable {
}
```
-## Error Handling
-
-```swift
-do {
- try await client.send(message)
-} catch MattermostError.invalidURL(let url) {
- print("Invalid URL: \(url)")
-} catch MattermostError.invalidResponse(let code, let body) {
- print("HTTP \(code): \(body ?? "No body")")
-} catch MattermostError.encodingError(let error) {
- print("Failed to encode message: \(error)")
-} catch MattermostError.networkError(let error) {
- print("Network error: \(error)")
-}
-```
-
## Differences from Slack
| Feature | SlackKit | MattermostKit |
diff --git a/Sources/MattermostKit/Models/Attachment+Builder.swift b/Sources/MattermostKit/Models/Attachment+Builder.swift
new file mode 100644
index 0000000..82a82b7
--- /dev/null
+++ b/Sources/MattermostKit/Models/Attachment+Builder.swift
@@ -0,0 +1,151 @@
+import Foundation
+
+// MARK: - FieldBuilder
+
+/// A result builder for constructing arrays of `AttachmentField` objects
+@resultBuilder
+public enum FieldBuilder {
+ /// Builds an empty field array
+ public static func buildBlock() -> [AttachmentField] {
+ []
+ }
+
+ /// Builds a field array from multiple field arrays
+ public static func buildBlock(_ components: [AttachmentField]...) -> [AttachmentField] {
+ components.flatMap { $0 }
+ }
+
+ /// Builds a field array from a single field
+ public static func buildExpression(_ expression: AttachmentField) -> [AttachmentField] {
+ [expression]
+ }
+
+ /// Builds a field array from an optional field
+ public static func buildExpression(_ expression: AttachmentField?) -> [AttachmentField] {
+ expression.map { [$0] } ?? []
+ }
+
+ /// Builds a field array from an if statement
+ public static func buildIf(_ content: [AttachmentField]?) -> [AttachmentField] {
+ content ?? []
+ }
+
+ /// Builds a field array from the first branch of an if-else statement
+ public static func buildEither(first component: [AttachmentField]) -> [AttachmentField] {
+ component
+ }
+
+ /// Builds a field array from the second branch of an if-else statement
+ public static func buildEither(second component: [AttachmentField]) -> [AttachmentField] {
+ component
+ }
+
+ /// Builds a field array from a for loop
+ public static func buildArray(_ components: [[AttachmentField]]) -> [AttachmentField] {
+ components.flatMap { $0 }
+ }
+
+ /// Builds the final field array
+ public static func buildFinalBlock(_ component: [AttachmentField]) -> [AttachmentField] {
+ component
+ }
+}
+
+// MARK: - ActionBuilder
+
+/// A result builder for constructing arrays of `Action` objects
+@resultBuilder
+public enum ActionBuilder {
+ /// Builds an empty action array
+ public static func buildBlock() -> [Action] {
+ []
+ }
+
+ /// Builds an action array from multiple action arrays
+ public static func buildBlock(_ components: [Action]...) -> [Action] {
+ components.flatMap { $0 }
+ }
+
+ /// Builds an action array from a single action
+ public static func buildExpression(_ expression: Action) -> [Action] {
+ [expression]
+ }
+
+ /// Builds an action array from an optional action
+ public static func buildExpression(_ expression: Action?) -> [Action] {
+ expression.map { [$0] } ?? []
+ }
+
+ /// Builds an action array from an if statement
+ public static func buildIf(_ content: [Action]?) -> [Action] {
+ content ?? []
+ }
+
+ /// Builds an action array from the first branch of an if-else statement
+ public static func buildEither(first component: [Action]) -> [Action] {
+ component
+ }
+
+ /// Builds an action array from the second branch of an if-else statement
+ public static func buildEither(second component: [Action]) -> [Action] {
+ component
+ }
+
+ /// Builds an action array from a for loop
+ public static func buildArray(_ components: [[Action]]) -> [Action] {
+ components.flatMap { $0 }
+ }
+
+ /// Builds the final action array
+ public static func buildFinalBlock(_ component: [Action]) -> [Action] {
+ component
+ }
+}
+
+// MARK: - Attachment Field Convenience
+
+extension AttachmentField {
+ /// Convenience initializer with short=true by default
+ public init(_ title: String, value: String, short: Bool = true) {
+ self.title = title
+ self.value = value
+ self.short = short
+ }
+}
+
+// MARK: - Field Convenience Function
+
+/// Creates an attachment field with the specified parameters
+/// - Parameters:
+/// - title: The field title
+/// - value: The field value (Markdown-formatted)
+/// - short: Whether the field is short enough to display beside other fields (default: true)
+/// - Returns: An attachment field
+public func Field(_ title: String, value: String, short: Bool = true) -> AttachmentField {
+ AttachmentField(title: title, value: value, short: short)
+}
+
+// MARK: - Action Convenience Function
+
+/// Creates a button action with a convenience API
+/// - Parameters:
+/// - text: The button text
+/// - style: The button style ("primary", "danger", or "default")
+/// - url: The URL to open when clicked
+/// - confirm: Optional confirmation dialog
+/// - Returns: A configured Action
+public func Button(
+ text: String,
+ style: String? = nil,
+ url: String,
+ confirm: Confirmation? = nil
+) -> Action {
+ Action(
+ name: text.lowercased().replacingOccurrences(of: " ", with: "_"),
+ text: text,
+ type: "button",
+ style: style,
+ url: url,
+ confirm: confirm
+ )
+}
diff --git a/Sources/MattermostKit/Models/Confirmation+Builder.swift b/Sources/MattermostKit/Models/Confirmation+Builder.swift
new file mode 100644
index 0000000..f10bec5
--- /dev/null
+++ b/Sources/MattermostKit/Models/Confirmation+Builder.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+// MARK: - Confirmation Builder
+
+/// A result builder for constructing confirmation dialog components
+@resultBuilder
+public enum ConfirmationBuilder {
+ /// Builds an empty component array
+ public static func buildBlock() -> [ConfirmationComponent] {
+ []
+ }
+
+ /// Builds a component array from multiple components
+ public static func buildBlock(_ components: [ConfirmationComponent]...) -> [ConfirmationComponent] {
+ components.flatMap { $0 }
+ }
+
+ /// Builds a component array from a single component
+ public static func buildExpression(_ expression: ConfirmationComponent) -> [ConfirmationComponent] {
+ [expression]
+ }
+
+ /// Builds a component array from an optional component
+ public static func buildExpression(_ expression: ConfirmationComponent?) -> [ConfirmationComponent] {
+ expression.map { [$0] } ?? []
+ }
+
+ /// Builds a component array from an if statement
+ public static func buildIf(_ content: [ConfirmationComponent]?) -> [ConfirmationComponent] {
+ content ?? []
+ }
+
+ /// Builds a component array from the first branch of an if-else statement
+ public static func buildEither(first component: [ConfirmationComponent]) -> [ConfirmationComponent] {
+ component
+ }
+
+ /// Builds a component array from the second branch of an if-else statement
+ public static func buildEither(second component: [ConfirmationComponent]) -> [ConfirmationComponent] {
+ component
+ }
+}
+
+// MARK: - Confirmation Components
+
+/// Confirmation dialog components
+public enum ConfirmationComponent {
+ case confirmButton(text: String, style: String? = nil)
+ case denyButton(text: String)
+}
+
+// MARK: - Confirmation Convenience Initializer
+
+extension Confirmation {
+ /// Initializes a confirmation with a result builder for button configuration
+ /// - Parameters:
+ /// - title: Confirmation dialog title
+ /// - text: Confirmation dialog text
+ /// - builder: Result builder for confirmation components
+ public init(
+ title: String? = nil,
+ text: String,
+ @ConfirmationBuilder builder: () -> [ConfirmationComponent]
+ ) {
+ self.title = title
+ self.text = text
+
+ var confirmText: String?
+ var denyText: String?
+
+ for component in builder() {
+ switch component {
+ case .confirmButton(let text, let style):
+ confirmText = text
+ case .denyButton(let text):
+ denyText = text
+ }
+ }
+
+ self.confirmText = confirmText
+ self.denyText = denyText
+ }
+}
+
+// MARK: - Confirmation Convenience Functions
+
+/// Creates a confirm button component
+/// - Parameters:
+/// - text: Button text
+/// - style: Optional style hint
+/// - Returns: A confirm button component
+public func ConfirmButton(text: String, style: String? = nil) -> ConfirmationComponent {
+ .confirmButton(text: text, style: style)
+}
+
+/// Creates a deny button component
+/// - Parameter text: Button text
+/// - Returns: A deny button component
+public func DenyButton(text: String) -> ConfirmationComponent {
+ .denyButton(text: text)
+}
diff --git a/Sources/MattermostKit/Models/Message+Builder.swift b/Sources/MattermostKit/Models/Message+Builder.swift
new file mode 100644
index 0000000..7a6db7e
--- /dev/null
+++ b/Sources/MattermostKit/Models/Message+Builder.swift
@@ -0,0 +1,185 @@
+import Foundation
+
+// MARK: - AttachmentBuilder
+
+/// A result builder for constructing arrays of `Attachment` objects
+@resultBuilder
+public enum AttachmentBuilder {
+ /// Builds an empty attachment array
+ public static func buildBlock() -> [Attachment] {
+ []
+ }
+
+ /// Builds an attachment array from multiple attachment arrays
+ public static func buildBlock(_ components: [Attachment]...) -> [Attachment] {
+ components.flatMap { $0 }
+ }
+
+ /// Builds an attachment array from a single attachment
+ public static func buildExpression(_ expression: Attachment) -> [Attachment] {
+ [expression]
+ }
+
+ /// Builds an attachment array from an optional attachment
+ public static func buildExpression(_ expression: Attachment?) -> [Attachment] {
+ expression.map { [$0] } ?? []
+ }
+
+ /// Builds an attachment array from an array of attachments (pass-through)
+ public static func buildExpression(_ expression: [Attachment]) -> [Attachment] {
+ expression
+ }
+
+ /// Builds an attachment array from an if statement
+ public static func buildIf(_ content: [Attachment]?) -> [Attachment] {
+ content ?? []
+ }
+
+ /// Builds an attachment array from the first branch of an if-else statement
+ public static func buildEither(first component: [Attachment]) -> [Attachment] {
+ component
+ }
+
+ /// Builds an attachment array from the second branch of an if-else statement
+ public static func buildEither(second component: [Attachment]) -> [Attachment] {
+ component
+ }
+
+ /// Builds an attachment array from a for loop
+ public static func buildArray(_ components: [[Attachment]]) -> [Attachment] {
+ components.flatMap { $0 }
+ }
+
+ /// Builds the final attachment array
+ public static func buildFinalBlock(_ component: [Attachment]) -> [Attachment] {
+ component
+ }
+}
+
+// MARK: - Message Convenience Initializer
+
+extension Message {
+ /// Initializes a message with a result builder for attachments
+ /// - Parameters:
+ /// - text: Markdown-formatted message text
+ /// - channel: Override the default channel
+ /// - username: Override the default username
+ /// - iconEmoji: Override with emoji (e.g., ":rocket:")
+ /// - iconURL: Override with image URL
+ /// - props: JSON metadata
+ /// - type: Post type (must begin with "custom_")
+ /// - priority: Message priority
+ /// - attachments: Result builder for attachments
+ public init(
+ text: String? = nil,
+ channel: String? = nil,
+ username: String? = nil,
+ iconEmoji: String? = nil,
+ iconURL: String? = nil,
+ props: Props? = nil,
+ type: String? = nil,
+ priority: Priority? = nil,
+ @AttachmentBuilder attachments: () -> [Attachment]
+ ) {
+ let builtAttachments = attachments()
+ self.text = text
+ self.channel = channel
+ self.username = username
+ self.iconEmoji = iconEmoji
+ self.iconURL = iconURL
+ self.attachments = builtAttachments.isEmpty ? nil : builtAttachments
+ self.props = props
+ self.type = type
+ self.priority = priority
+ }
+}
+
+// MARK: - Attachment Convenience Initializer
+
+extension Attachment {
+ /// Initializes an attachment with a result builder for fields
+ /// - Parameters:
+ /// - color: Hex color code for left border
+ /// - title: Attachment title
+ /// - titleLink: Optional title link URL
+ /// - text: Attachment text (Markdown-formatted)
+ /// - fallback: Plain-text summary
+ /// - pretext: Text shown above the attachment
+ /// - authorName: Author name
+ /// - authorLink: Author link URL
+ /// - authorIcon: Author icon URL
+ /// - imageURL: Image URL
+ /// - thumbURL: Thumbnail URL
+ /// - footer: Footer text
+ /// - footerIcon: Footer icon URL
+ /// - builder: A result builder closure that provides the fields
+ public init(
+ color: String? = nil,
+ title: String? = nil,
+ titleLink: String? = nil,
+ text: String? = nil,
+ fallback: String? = nil,
+ pretext: String? = nil,
+ authorName: String? = nil,
+ authorLink: String? = nil,
+ authorIcon: String? = nil,
+ imageURL: String? = nil,
+ thumbURL: String? = nil,
+ footer: String? = nil,
+ footerIcon: String? = nil,
+ @FieldBuilder builder: () -> [AttachmentField]
+ ) {
+ let builtFields = builder()
+ self.fallback = fallback
+ self.color = color
+ self.pretext = pretext
+ self.authorName = authorName
+ self.authorLink = authorLink
+ self.authorIcon = authorIcon
+ self.title = title
+ self.titleLink = titleLink
+ self.text = text
+ self.fields = builtFields.isEmpty ? nil : builtFields
+ self.imageURL = imageURL
+ self.thumbURL = thumbURL
+ self.footer = footer
+ self.footerIcon = footerIcon
+ self.footerTimestamp = nil
+ self.actions = nil
+ }
+}
+
+// MARK: - Actions Convenience
+
+extension Attachment {
+ /// Creates an attachment with actions built via result builder
+ public static func actions(@ActionBuilder builder: () -> [Action]) -> Attachment {
+ let actions = builder()
+ return Attachment(actions: actions.isEmpty ? nil : actions)
+ }
+
+ /// Creates an attachment with both fields and actions
+ public init(
+ @FieldBuilder fieldsBuilder: () -> [AttachmentField],
+ @ActionBuilder actionsBuilder: () -> [Action]
+ ) {
+ let fields = fieldsBuilder()
+ let actions = actionsBuilder()
+ self.fields = fields.isEmpty ? nil : fields
+ self.actions = actions.isEmpty ? nil : actions
+ self.fallback = nil
+ self.color = nil
+ self.pretext = nil
+ self.authorName = nil
+ self.authorLink = nil
+ self.authorIcon = nil
+ self.title = nil
+ self.titleLink = nil
+ self.text = nil
+ self.imageURL = nil
+ self.thumbURL = nil
+ self.footer = nil
+ self.footerIcon = nil
+ self.footerTimestamp = nil
+ }
+}
diff --git a/Sources/MattermostKit/Models/Props+Builder.swift b/Sources/MattermostKit/Models/Props+Builder.swift
new file mode 100644
index 0000000..9fc37bd
--- /dev/null
+++ b/Sources/MattermostKit/Models/Props+Builder.swift
@@ -0,0 +1,147 @@
+import Foundation
+
+// MARK: - PropsBuilder
+
+/// A result builder for constructing dynamic property dictionaries
+@resultBuilder
+public enum PropsBuilder {
+ /// Builds an empty property dictionary
+ public static func buildBlock() -> [String: AnyCodable] {
+ [:]
+ }
+
+ /// Builds a property dictionary from multiple dictionaries
+ public static func buildBlock(_ components: [String: AnyCodable]...) -> [String: AnyCodable] {
+ components.merging()
+ }
+
+ /// Builds a property dictionary from a single dictionary
+ public static func buildExpression(_ expression: [String: AnyCodable]) -> [String: AnyCodable] {
+ expression
+ }
+
+ /// Builds a property dictionary from an optional dictionary
+ public static func buildExpression(_ expression: [String: AnyCodable]?) -> [String: AnyCodable] {
+ expression ?? [:]
+ }
+
+ /// Builds a property dictionary from an if statement
+ public static func buildIf(_ content: [String: AnyCodable]?) -> [String: AnyCodable] {
+ content ?? [:]
+ }
+
+ /// Builds a property dictionary from the first branch of an if-else statement
+ public static func buildEither(first component: [String: AnyCodable]) -> [String: AnyCodable] {
+ component
+ }
+
+ /// Builds a property dictionary from the second branch of an if-else statement
+ public static func buildEither(second component: [String: AnyCodable]) -> [String: AnyCodable] {
+ component
+ }
+
+ /// Builds a property dictionary from a for loop
+ public static func buildArray(_ components: [[String: AnyCodable]]) -> [String: AnyCodable] {
+ components.merging()
+ }
+
+ /// Builds the final property dictionary
+ public static func buildFinalBlock(_ component: [String: AnyCodable]) -> [String: AnyCodable] {
+ component
+ }
+}
+
+// MARK: - Props Convenience Initializer
+
+extension Props {
+ /// Initializes props with a result builder for additional properties
+ /// - Parameters:
+ /// - card: Card content displayed in RHS sidebar (Markdown-formatted)
+ /// - properties: Result builder for additional properties
+ public init(
+ card: String? = nil,
+ @PropsBuilder properties: () -> [String: AnyCodable]
+ ) {
+ self.card = card
+ self.additionalProperties = properties().isEmpty ? nil : properties()
+ }
+}
+
+// MARK: - Custom Property Convenience Functions
+
+/// Creates a string property
+/// - Parameters:
+/// - key: The property key
+/// - value: The string value
+/// - Returns: A dictionary with the single property
+public func Property(_ key: String, value: String) -> [String: AnyCodable] {
+ [key: .string(value)]
+}
+
+/// Creates an integer property
+/// - Parameters:
+/// - key: The property key
+/// - value: The integer value
+/// - Returns: A dictionary with the single property
+public func Property(_ key: String, value: Int) -> [String: AnyCodable] {
+ [key: .int(value)]
+}
+
+/// Creates a double property
+/// - Parameters:
+/// - key: The property key
+/// - value: The double value
+/// - Returns: A dictionary with the single property
+public func Property(_ key: String, value: Double) -> [String: AnyCodable] {
+ [key: .double(value)]
+}
+
+/// Creates a boolean property
+/// - Parameters:
+/// - key: The property key
+/// - value: The boolean value
+/// - Returns: A dictionary with the single property
+public func Property(_ key: String, value: Bool) -> [String: AnyCodable] {
+ [key: .bool(value)]
+}
+
+/// Creates a dictionary property
+/// - Parameters:
+/// - key: The property key
+/// - value: The dictionary value
+/// - Returns: A dictionary with the single property
+public func Property(_ key: String, value: [String: AnyCodable]) -> [String: AnyCodable] {
+ [key: .dictionary(value)]
+}
+
+/// Creates an array property
+/// - Parameters:
+/// - key: The property key
+/// - value: The array value
+/// - Returns: A dictionary with the single property
+public func Property(_ key: String, value: [AnyCodable]) -> [String: AnyCodable] {
+ [key: .array(value)]
+}
+
+// MARK: - Dictionary Merging Helper
+
+fileprivate func mergeDictionaries(_ dictionaries: [[String: AnyCodable]]) -> [String: AnyCodable] {
+ var result: [String: AnyCodable] = [:]
+ for dict in dictionaries {
+ result.merge(dict) { _, new in new }
+ }
+ return result
+}
+
+// MARK: - Array Merging Helper
+
+extension Array where Element == [String: AnyCodable] {
+ /// Merges multiple dictionaries into one
+ fileprivate func merging() -> [String: AnyCodable] {
+ var result: [String: AnyCodable] = [:]
+ for dict in self {
+ result.merge(dict) { _, new in new }
+ }
+ return result
+ }
+}
diff --git a/Tests/MattermostKitTests/Builders/AttachmentBuilderTests.swift b/Tests/MattermostKitTests/Builders/AttachmentBuilderTests.swift
new file mode 100644
index 0000000..be76be5
--- /dev/null
+++ b/Tests/MattermostKitTests/Builders/AttachmentBuilderTests.swift
@@ -0,0 +1,266 @@
+import XCTest
+@testable import MattermostKit
+
+/// Tests for the result builder functionality
+final class AttachmentBuilderTests: XCTestCase {
+ // MARK: - Attachment Builder Tests
+
+ func testEmptyAttachmentBuilder() {
+ let message = Message {}
+ XCTAssertNil(message.attachments)
+ }
+
+ func testSingleAttachmentBuilder() {
+ let message = Message {
+ Attachment(title: "Test")
+ }
+ XCTAssertEqual(message.attachments?.count, 1)
+ XCTAssertEqual(message.attachments?.first?.title, "Test")
+ }
+
+ func testMultipleAttachmentBuilder() {
+ let message = Message {
+ Attachment(title: "First")
+ Attachment(title: "Second")
+ Attachment(title: "Third")
+ }
+ XCTAssertEqual(message.attachments?.count, 3)
+ XCTAssertEqual(message.attachments?[0].title, "First")
+ XCTAssertEqual(message.attachments?[1].title, "Second")
+ XCTAssertEqual(message.attachments?[2].title, "Third")
+ }
+
+ func testConditionalAttachmentBuilder() {
+ let include = true
+ let message = Message {
+ Attachment(title: "Always")
+ if include {
+ Attachment(title: "Sometimes")
+ }
+ }
+ XCTAssertEqual(message.attachments?.count, 2)
+ }
+
+ func testConditionalAttachmentBuilderFalse() {
+ let include = false
+ let message = Message {
+ Attachment(title: "Always")
+ if include {
+ Attachment(title: "Sometimes")
+ }
+ }
+ XCTAssertEqual(message.attachments?.count, 1)
+ XCTAssertEqual(message.attachments?.first?.title, "Always")
+ }
+
+ func testIfElseAttachmentBuilder() {
+ let isSuccess = true
+ let message = Message {
+ if isSuccess {
+ Attachment(title: "Success")
+ } else {
+ Attachment(title: "Failure")
+ }
+ }
+ XCTAssertEqual(message.attachments?.count, 1)
+ XCTAssertEqual(message.attachments?.first?.title, "Success")
+ }
+
+ // MARK: - Field Builder Tests
+
+ func testEmptyFieldBuilder() {
+ let message = Message {
+ Attachment(title: "Test") {}
+ }
+ XCTAssertNil(message.attachments?.first?.fields)
+ }
+
+ func testFieldBuilder() {
+ let message = Message {
+ Attachment(title: "Test") {
+ Field("Branch", value: "main")
+ Field("Commit", value: "abc123")
+ }
+ }
+ XCTAssertEqual(message.attachments?.first?.fields?.count, 2)
+ XCTAssertEqual(message.attachments?.first?.fields?[0].title, "Branch")
+ XCTAssertEqual(message.attachments?.first?.fields?[0].value, "main")
+ }
+
+ func testFieldBuilderWithShort() {
+ let message = Message {
+ Attachment(title: "Test") {
+ Field("Branch", value: "main", short: true)
+ Field("Description", value: "A long description", short: false)
+ }
+ }
+ XCTAssertEqual(message.attachments?.first?.fields?.count, 2)
+ XCTAssertEqual(message.attachments?.first?.fields?[0].short, true)
+ XCTAssertEqual(message.attachments?.first?.fields?[1].short, false)
+ }
+
+ func testConditionalFieldBuilder() {
+ let showDetails = true
+ let message = Message {
+ Attachment(title: "Test") {
+ Field("Title", value: "Value")
+ if showDetails {
+ Field("Details", value: "More info")
+ }
+ }
+ }
+ XCTAssertEqual(message.attachments?.first?.fields?.count, 2)
+ }
+
+ // MARK: - Action Builder Tests
+
+ func testEmptyActionBuilder() {
+ let message = Message {
+ Attachment.actions {}
+ }
+ XCTAssertNil(message.attachments?.first?.actions)
+ }
+
+ func testActionBuilder() {
+ let message = Message {
+ Attachment.actions {
+ Button(text: "Approve", style: "primary", url: "https://example.com/approve")
+ Button(text: "Reject", style: "danger", url: "https://example.com/reject")
+ }
+ }
+ XCTAssertEqual(message.attachments?.count, 1)
+ XCTAssertEqual(message.attachments?.first?.actions?.count, 2)
+ }
+
+ // MARK: - Combined Tests
+
+ func testMessageWithAllProperties() {
+ let message = Message(
+ text: "Deployment complete!",
+ username: "DeployBot",
+ iconEmoji: ":rocket:"
+ ) {
+ Attachment(color: "#36a64f", title: "Build #123") {
+ Field("Branch", value: "main")
+ Field("Commit", value: "abc123")
+ }
+ }
+
+ XCTAssertEqual(message.username, "DeployBot")
+ XCTAssertEqual(message.iconEmoji, ":rocket:")
+ XCTAssertEqual(message.text, "Deployment complete!")
+ XCTAssertEqual(message.attachments?.count, 1)
+ XCTAssertEqual(message.attachments?.first?.color, "#36a64f")
+ XCTAssertEqual(message.attachments?.first?.title, "Build #123")
+ XCTAssertEqual(message.attachments?.first?.fields?.count, 2)
+ }
+
+ func testComplexMessage() {
+ let hasWarnings = true
+ let hasErrors = false
+
+ let message = Message(username: "CIBot", iconEmoji: ":robot_face:") {
+ Attachment(color: "#36a64f", title: "Build Summary") {
+ Field("Status", value: "Success")
+ Field("Duration", value: "5m 32s")
+ }
+
+ if hasWarnings {
+ Attachment(color: "#ffaa00", title: "Warnings") {
+ Field("Count", value: "3")
+ }
+ }
+
+ if hasErrors {
+ Attachment(color: "#ff0000", title: "Errors") {
+ Field("Count", value: "1")
+ }
+ }
+ }
+
+ XCTAssertEqual(message.attachments?.count, 2)
+ XCTAssertEqual(message.attachments?[0].title, "Build Summary")
+ XCTAssertEqual(message.attachments?[1].title, "Warnings")
+ }
+
+ // MARK: - Codable Tests
+
+ func testResultBuilderEncoding() throws {
+ let message = Message(username: "TestBot") {
+ Attachment(color: "#36a64f", title: "Test") {
+ Field("Key", value: "Value")
+ }
+ }
+
+ let encoder = JSONEncoder()
+ let jsonData = try encoder.encode(message)
+ let jsonString = String(data: jsonData, encoding: .utf8)
+
+ XCTAssertNotNil(jsonString)
+ XCTAssertTrue(jsonString!.contains("TestBot"))
+ XCTAssertTrue(jsonString!.contains("Test"))
+ XCTAssertTrue(jsonString!.contains("Key"))
+ XCTAssertTrue(jsonString!.contains("Value"))
+ }
+
+ func testResultBuilderDecoding() throws {
+ let jsonString = """
+ {
+ "username": "TestBot",
+ "attachments": [
+ {
+ "title": "Test",
+ "fields": [
+ {"title": "Key", "value": "Value"}
+ ]
+ }
+ ]
+ }
+ """
+
+ let decoder = JSONDecoder()
+ let message = try decoder.decode(Message.self, from: jsonString.data(using: .utf8)!)
+
+ XCTAssertEqual(message.username, "TestBot")
+ XCTAssertEqual(message.attachments?.count, 1)
+ XCTAssertEqual(message.attachments?.first?.title, "Test")
+ XCTAssertEqual(message.attachments?.first?.fields?.count, 1)
+ }
+
+ // MARK: - AttachmentField Convenience Initializer Tests
+
+ func testFieldConvenienceInitializer() {
+ let field1 = Field("Title", value: "Value")
+ XCTAssertEqual(field1.title, "Title")
+ XCTAssertEqual(field1.value, "Value")
+ XCTAssertEqual(field1.short, true) // Default is true
+
+ let field2 = Field("Title", value: "Value", short: false)
+ XCTAssertEqual(field2.short, false)
+ }
+
+ func testFieldStaticFactory() {
+ let field = Field("Title", value: "Value", short: true)
+ XCTAssertEqual(field.title, "Title")
+ XCTAssertEqual(field.value, "Value")
+ XCTAssertEqual(field.short, true)
+ }
+
+ // MARK: - Action Convenience Tests
+
+ func testButtonConvenience() {
+ let button = Button(text: "Approve", style: "primary", url: "https://example.com/approve")
+ XCTAssertEqual(button.text, "Approve")
+ XCTAssertEqual(button.style, "primary")
+ XCTAssertEqual(button.url, "https://example.com/approve")
+ XCTAssertEqual(button.type, "button")
+ XCTAssertEqual(button.name, "approve") // Lowercased, spaces replaced with underscores
+ }
+
+ func testButtonConvenienceWithoutStyle() {
+ let button = Button(text: "Click Me", url: "https://example.com")
+ XCTAssertEqual(button.text, "Click Me")
+ XCTAssertNil(button.style)
+ XCTAssertEqual(button.name, "click_me")
+ }
+}
From db8c7d0f415611d9882f12190ab414a27c3640cc Mon Sep 17 00:00:00 2001
From: Diego Trevisan Lara
Date: Wed, 4 Feb 2026 08:40:16 +0100
Subject: [PATCH 2/5] Fix result builder issues: double evaluation and missing
for-in support
- Props+Builder: Store builder result once to avoid double evaluation
which could cause inconsistent results or duplicate side effects
- Confirmation+Builder: Add buildArray() to enable for-in loop support
- Confirmation+Builder: Fix unused variable warning for style parameter
---
Sources/MattermostKit/Models/Confirmation+Builder.swift | 7 ++++++-
Sources/MattermostKit/Models/Props+Builder.swift | 3 ++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/Sources/MattermostKit/Models/Confirmation+Builder.swift b/Sources/MattermostKit/Models/Confirmation+Builder.swift
index f10bec5..d7cba14 100644
--- a/Sources/MattermostKit/Models/Confirmation+Builder.swift
+++ b/Sources/MattermostKit/Models/Confirmation+Builder.swift
@@ -39,6 +39,11 @@ public enum ConfirmationBuilder {
public static func buildEither(second component: [ConfirmationComponent]) -> [ConfirmationComponent] {
component
}
+
+ /// Builds a component array from a for loop
+ public static func buildArray(_ components: [[ConfirmationComponent]]) -> [ConfirmationComponent] {
+ components.flatMap { $0 }
+ }
}
// MARK: - Confirmation Components
@@ -70,7 +75,7 @@ extension Confirmation {
for component in builder() {
switch component {
- case .confirmButton(let text, let style):
+ case .confirmButton(let text, _):
confirmText = text
case .denyButton(let text):
denyText = text
diff --git a/Sources/MattermostKit/Models/Props+Builder.swift b/Sources/MattermostKit/Models/Props+Builder.swift
index 9fc37bd..0c4bb3d 100644
--- a/Sources/MattermostKit/Models/Props+Builder.swift
+++ b/Sources/MattermostKit/Models/Props+Builder.swift
@@ -63,7 +63,8 @@ extension Props {
@PropsBuilder properties: () -> [String: AnyCodable]
) {
self.card = card
- self.additionalProperties = properties().isEmpty ? nil : properties()
+ let builtProperties = properties()
+ self.additionalProperties = builtProperties.isEmpty ? nil : builtProperties
}
}
From d9a886d12d636a1e30530cad666554de34bd296c Mon Sep 17 00:00:00 2001
From: Diego Trevisan Lara
Date: Wed, 4 Feb 2026 14:23:00 +0100
Subject: [PATCH 3/5] Add cross-reference to SlackKit companion package
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index f529458..a671125 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,8 @@
[Swift](https://swift.org) package for sending messages to [Mattermost](https://mattermost.com) via Incoming Webhooks with full support for Slack-compatible attachments and Mattermost-specific features.
+> **Also check out [SlackKit](https://github.com/diegotl/SlackKit)** - A companion package for sending messages to Slack with full Block Kit support.
+
## Features
- **Modern Result Builder API** - Declarative DSL for building messages with attachments
From b2ccc4ef672e911b3504b4ac73255f102575bf4f Mon Sep 17 00:00:00 2001
From: Diego Trevisan Lara
Date: Wed, 4 Feb 2026 14:26:47 +0100
Subject: [PATCH 4/5] Fix visual underline artifact in badges by adding
consistent spacing
---
README.md | 12 +++---------
1 file changed, 3 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index a671125..fbef6f9 100644
--- a/README.md
+++ b/README.md
@@ -5,15 +5,9 @@
-
-
-
-
-
-
-
-
-
+
+
+
From c611b21e22470b0c7edf5d06e8c2d2ea8e4fdc86 Mon Sep 17 00:00:00 2001
From: Diego Trevisan Lara
Date: Wed, 4 Feb 2026 14:49:32 +0100
Subject: [PATCH 5/5] Add comprehensive integration tests for MattermostKit
Adds end-to-end integration tests that send actual messages to
Mattermost via Incoming Webhooks. Tests cover all major features
including text messages, attachments, actions, builder API,
priority, props, and special characters.
Tests are disabled by default and only run when
MATTERMOST_INTEGRATION_TESTS environment variable is set.
---
.../MattermostKitIntegrationTests.swift | 879 ++++++++++++++++++
1 file changed, 879 insertions(+)
create mode 100644 Tests/MattermostKitTests/IntegrationTests/MattermostKitIntegrationTests.swift
diff --git a/Tests/MattermostKitTests/IntegrationTests/MattermostKitIntegrationTests.swift b/Tests/MattermostKitTests/IntegrationTests/MattermostKitIntegrationTests.swift
new file mode 100644
index 0000000..fdab684
--- /dev/null
+++ b/Tests/MattermostKitTests/IntegrationTests/MattermostKitIntegrationTests.swift
@@ -0,0 +1,879 @@
+import Foundation
+import Testing
+@testable import MattermostKit
+
+// MARK: - MattermostKit Integration Tests
+
+/*
+ # MattermostKit Integration Tests
+
+ These are end-to-end tests that send actual messages to Mattermost via Incoming Webhooks.
+
+ ## Prerequisites
+
+ 1. **Create a Mattermost Webhook URL**:
+ - Go to your Mattermost workspace
+ - Navigate to: Integrations → Incoming Webhooks → Add Incoming Webhook
+ - Enter a title (e.g., "Integration Tests")
+ - Select a channel (a dedicated test channel is recommended)
+ - Copy the Webhook URL
+
+ 2. **Set Environment Variables**:
+
+ ```bash
+ export MATTERMOST_INTEGRATION_TESTS=1
+ export MATTERMOST_TEST_WEBHOOK_URL="https://your-mattermost-server.com/hooks/YOUR_WEBHOOK_ID"
+ ```
+
+ Or run tests inline:
+
+ ```bash
+ MATTERMOST_INTEGRATION_TESTS=1 MATTERMOST_TEST_WEBHOOK_URL="https://your-mattermost-server.com/hooks/YOUR_WEBHOOK_ID" swift test
+ ```
+
+ ## Running Tests
+
+ Run all tests (integration tests will be skipped unless env vars are set):
+ ```bash
+ swift test
+ ```
+
+ Run only integration tests:
+ ```bash
+ MATTERMOST_INTEGRATION_TESTS=1 MATTERMOST_TEST_WEBHOOK_URL="your_url" swift test --filter "MattermostKit Integration Tests"
+ ```
+
+ ## What Gets Tested
+
+ - Simple text messages with Markdown formatting
+ - Messages with custom username and icons
+ - Single and multiple attachments
+ - All attachment features (color, title, text, fields, images, footer)
+ - Action buttons with different styles
+ - Confirmation dialogs
+ - Builder API with conditionals and loops
+ - Message priority (important/urgent)
+ - Props and card content
+ - Special characters and emojis
+
+ ## Important Notes
+
+ - Tests are **serialized** (run one at a time) to avoid rate limits
+ - Each test includes a 1-second delay between requests
+ - Tests send actual messages to your Mattermost workspace
+ - A dedicated test channel is recommended
+
+ ## Safety
+
+ - Integration tests are **disabled by default**
+ - Tests only run when both environment variables are set
+ - All messages include emoji indicators (🧪) for easy identification
+ */
+
+@Suite(
+ "MattermostKit Integration Tests",
+ .serialized,
+ .enabled(if: {
+ // Only run integration tests when MATTERMOST_INTEGRATION_TESTS environment variable is set
+ ProcessInfo.processInfo.environment["MATTERMOST_INTEGRATION_TESTS"] != nil
+ }())
+)
+struct MattermostKitIntegrationTests {
+
+ // MARK: - Test Configuration
+
+ private var webhookURL: URL {
+ guard let urlString = ProcessInfo.processInfo.environment["MATTERMOST_TEST_WEBHOOK_URL"],
+ let url = URL(string: urlString) else {
+ fatalError("MATTERMOST_TEST_WEBHOOK_URL environment variable must be set to a valid URL")
+ }
+ return url
+ }
+
+ private let defaultTimeout: Duration = .seconds(30)
+
+ // MARK: - Helper Methods
+
+ private func createClient() -> MattermostWebhookClient {
+ MattermostWebhookClient(webhookURL: webhookURL)
+ }
+
+ private func waitFor(_ duration: Duration) async {
+ // Add random jitter (0-500ms) to help avoid rate limits
+ let jitter = Duration.seconds(Double.random(in: 0...0.5))
+ try? await Task.sleep(for: duration + jitter)
+ }
+
+ // MARK: - Simple Text Messages
+
+ @Test("Send simple text message")
+ func sendSimpleTextMessage() async throws {
+ let client = createClient()
+ let message = Message(text: "🧪 Integration Test: Simple text message")
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with markdown formatting")
+ func sendMessageWithMarkdown() async throws {
+ let client = createClient()
+ let message = Message(text: """
+ 🧪 Integration Test: **Markdown formatting**
+
+ This is a *bold text* and this is _italic text_.
+ This is `code` and this is a ~~strikethrough~~ text.
+
+ * Bullet point 1
+ * Bullet point 2
+ * Bullet point 3
+
+ 1. Numbered item 1
+ 2. Numbered item 2
+ 3. Numbered item 3
+
+ A [link](https://mattermost.com) to Mattermost.
+ """)
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with username and icon")
+ func sendMessageWithUsernameAndIcon() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Message with custom username and icon",
+ username: "MattermostKit Test Bot",
+ iconEmoji: ":robot_face:"
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with custom icon URL")
+ func sendMessageWithIconURL() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Message with icon URL",
+ username: "MattermostKit",
+ iconURL: "https://httpbin.org/image/png"
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Attachments
+
+ @Test("Send message with single attachment")
+ func sendMessageWithSingleAttachment() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Single attachment",
+ attachments: [
+ Attachment(
+ color: "good",
+ title: "Success",
+ text: "Operation completed successfully"
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with attachment with fields")
+ func sendMessageWithAttachmentFields() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Attachment with fields",
+ attachments: [
+ Attachment(
+ color: "#439FE0",
+ title: "Build Report",
+ text: "Build #1234 completed successfully",
+ fields: [
+ AttachmentField(title: "Status", value: "Success", short: true),
+ AttachmentField(title: "Duration", value: "5m 32s", short: true),
+ AttachmentField(title: "Branch", value: "main", short: true),
+ AttachmentField(title: "Commit", value: "abc123", short: true)
+ ]
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with attachment with author")
+ func sendMessageWithAttachmentAuthor() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Attachment with author",
+ attachments: [
+ Attachment(
+ color: "warning",
+ authorName: "John Doe",
+ authorLink: "https://mattermost.com",
+ authorIcon: "https://httpbin.org/image/png",
+ title: "Pull Request",
+ text: "Please review my changes",
+ fields: [
+ AttachmentField(title: "Repository", value: "mattermost-server", short: true),
+ AttachmentField(title: "Branch", value: "feature/test", short: true)
+ ]
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with attachment with image")
+ func sendMessageWithAttachmentImage() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Attachment with image",
+ attachments: [
+ Attachment(
+ title: "Screenshot",
+ text: "Here is the screenshot you requested",
+ imageURL: "https://httpbin.org/image/png"
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with attachment with thumbnail")
+ func sendMessageWithAttachmentThumbnail() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Attachment with thumbnail",
+ attachments: [
+ Attachment(
+ title: "Document Preview",
+ text: " quarterly_report.pdf",
+ fields: [
+ AttachmentField(title: "Size", value: "2.5 MB", short: true),
+ AttachmentField(title: "Type", value: "PDF", short: true)
+ ],
+ thumbURL: "https://httpbin.org/image/png"
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with attachment with footer")
+ func sendMessageWithAttachmentFooter() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Attachment with footer",
+ attachments: [
+ Attachment(
+ color: "good",
+ title: "Deployment Complete",
+ text: "Production deployment finished successfully",
+ footer: "DeployBot v2.1",
+ footerIcon: "https://httpbin.org/image/png",
+ footerTimestamp: Int(Date().timeIntervalSince1970)
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with multiple attachments")
+ func sendMessageWithMultipleAttachments() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Multiple attachments",
+ attachments: [
+ Attachment(
+ color: "good",
+ title: "Success",
+ text: "Operation completed"
+ ),
+ Attachment(
+ color: "warning",
+ title: "Warning",
+ text: "Minor issues detected"
+ ),
+ Attachment(
+ color: "#439FE0",
+ title: "Info",
+ text: "Additional information"
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Action Buttons
+
+ @Test("Send message with action button")
+ func sendMessageWithActionButton() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Action button",
+ attachments: [
+ Attachment(
+ text: "Click the button below to approve:",
+ actions: [
+ Action(
+ name: "approve",
+ text: "Approve",
+ style: "primary",
+ url: "https://mattermost.com"
+ )
+ ]
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with multiple action buttons")
+ func sendMessageWithMultipleActionButtons() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Multiple action buttons",
+ attachments: [
+ Attachment(
+ text: "Choose an action:",
+ actions: [
+ Action(name: "approve", text: "Approve", style: "primary", url: "https://mattermost.com"),
+ Action(name: "deny", text: "Deny", style: "danger", url: "https://mattermost.com"),
+ Action(name: "defer", text: "Defer", url: "https://mattermost.com")
+ ]
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with confirmation dialog")
+ func sendMessageWithConfirmationDialog() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Confirmation dialog",
+ attachments: [
+ Attachment(
+ text: "Destructive action requires confirmation",
+ actions: [
+ Action(
+ name: "delete",
+ text: "Delete",
+ style: "danger",
+ confirm: Confirmation(
+ title: "Are you sure?",
+ text: "This action cannot be undone.",
+ confirmText: "Delete",
+ denyText: "Cancel"
+ )
+ )
+ ]
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Builder API Tests
+
+ @Test("Send message using builder API - basic")
+ func sendMessageUsingBuilderBasic() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Builder API - Basic",
+ attachments: {
+ Attachment(
+ color: "good",
+ title: "Builder API Test",
+ text: "This message was created using the result builder API"
+ ) {
+ AttachmentField("Clean", value: "Readable")
+ AttachmentField("Type-safe", value: "Expressive")
+ }
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message using builder API - conditional attachments")
+ func sendMessageUsingBuilderConditional() async throws {
+ let client = createClient()
+ let showWarning = true
+ let showError = false
+
+ let message = Message(
+ text: "🧪 Builder API - Conditional Attachments",
+ attachments: {
+ Attachment(
+ color: "good",
+ title: "Main Attachment",
+ text: "This always appears"
+ )
+
+ if showWarning {
+ Attachment(
+ color: "warning",
+ title: "Warning",
+ text: "This appears because showWarning is true"
+ )
+ }
+
+ if showError {
+ Attachment(
+ color: "danger",
+ title: "Error",
+ text: "This won't appear because showError is false"
+ )
+ }
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message using builder API - for loops")
+ func sendMessageUsingBuilderLoops() async throws {
+ let client = createClient()
+ let items = ["Item 1", "Item 2", "Item 3"]
+
+ let message = Message(
+ text: "🧪 Builder API - For Loops",
+ attachments: {
+ for item in items {
+ Attachment(
+ title: item,
+ text: "This is \(item.lowercased())"
+ )
+ }
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message using builder API - complex message")
+ func sendMessageUsingBuilderComplex() async throws {
+ let client = createClient()
+ let features = [
+ ("Result Builders", "Swift 5.4+"),
+ ("Type Safety", "Compile-time checks"),
+ ("Expressive", "Clean and readable")
+ ]
+
+ let message = Message(
+ text: "🧪 Builder API - Complex Message",
+ username: "Builder Bot",
+ iconEmoji: ":construction_worker:",
+ attachments: {
+ Attachment(
+ color: "good",
+ title: "Builder API Features",
+ text: """
+ This message demonstrates the *full power* of the builder API:
+ • Clean syntax
+ • Type-safe
+ • Expressive
+ """
+ ) {
+ for (feature, description) in features {
+ AttachmentField(feature, value: description)
+ }
+ }
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message using builder API - with actions")
+ func sendMessageUsingBuilderWithActions() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Builder API - With Actions",
+ username: "Action Bot",
+ attachments: {
+ Attachment.actions {
+ Action(name: "approve", text: "Approve", style: "primary", url: "https://mattermost.com")
+ Action(name: "deny", text: "Deny", style: "danger", url: "https://mattermost.com")
+ }
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message using builder API - fields and actions")
+ func sendMessageUsingBuilderFieldsAndActions() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Builder API - Fields and Actions",
+ username: "Builder Bot",
+ attachments: {
+ Attachment(
+ fieldsBuilder: {
+ AttachmentField("Author", value: "John Doe")
+ AttachmentField("Repository", value: "mattermost-server")
+ AttachmentField("Branch", value: "feature/test")
+ },
+ actionsBuilder: {
+ Action(name: "approve", text: "Approve", style: "primary", url: "https://mattermost.com")
+ Action(name: "changes", text: "Request Changes", style: "danger", url: "https://mattermost.com")
+ }
+ )
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Message Priority
+
+ @Test("Send message with important priority")
+ func sendMessageWithImportantPriority() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Important priority message",
+ priority: Priority(
+ priority: .important,
+ requestedAck: true
+ )
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with urgent priority")
+ func sendMessageWithUrgentPriority() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Urgent priority message",
+ priority: Priority(
+ priority: .urgent,
+ requestedAck: true
+ )
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Props and Card
+
+ @Test("Send message with props card")
+ func sendMessageWithPropsCard() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Message with card content",
+ props: Props(card: "**Card Content**\n\nThis appears in the RHS sidebar when you click on the message.")
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with props and custom properties")
+ func sendMessageWithPropsCustomProperties() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Integration Test: Message with custom props",
+ props: Props(
+ card: "Custom card content",
+ properties: {
+ Property("app_id", value: "mattermostkit")
+ Property("version", value: "1.0.0")
+ Property("source", value: "integration_tests")
+ }
+ )
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Special Characters and Formatting
+
+ @Test("Send message with emojis")
+ func sendMessageWithEmojis() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🎉👋 Hello! Testing emoji support: :rocket: :fire: :100: :tada:",
+ username: "Emoji Bot :sparkles:",
+ iconEmoji: ":robot_face:",
+ attachments: [
+ Attachment(
+ text: "Emojis in attachments work too! :star: :heart: :thumbsup:"
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with special characters")
+ func sendMessageWithSpecialCharacters() async throws {
+ let client = createClient()
+ let message = Message(
+ text: """
+ 🧪 Integration Test: Special Characters
+
+ Testing special characters: & < > \" ' ` ~ * _ { } [ ] ( )
+ Unicode support: 你好 世界 🌍 Ñoño café
+ """,
+ attachments: [
+ Attachment(
+ text: "More special characters: @mentions #channels ~groups",
+ fields: [
+ AttachmentField("Symbols", value: "© ® ™ € £ ¥"),
+ AttachmentField("Math", value: "± × ÷ ≠ ≤ ≥")
+ ]
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with code blocks")
+ func sendMessageWithCodeBlocks() async throws {
+ let client = createClient()
+ let message = Message(
+ text: """
+ 🧪 Integration Test: Code Blocks
+
+ Inline code: `print("Hello, World!")`
+
+ ```
+ func greet(name: String) {
+ print("Hello, \\(name)!")
+ }
+ ```
+
+ ```bash
+ swift run
+ ```
+ """
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Complex Messages
+
+ @Test("Send complex deployment notification")
+ func sendComplexDeploymentNotification() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🚀 Deployment Alert",
+ username: "DeployBot",
+ iconEmoji: ":rocket:",
+ attachments: {
+ Attachment(
+ color: "good",
+ pretext: "Build #1234",
+ title: "Deployment Complete",
+ text: "Successfully deployed to production",
+ fields: [
+ AttachmentField("Environment", value: "Production"),
+ AttachmentField("Status", value: "Success"),
+ AttachmentField("Duration", value: "5m 32s"),
+ AttachmentField("Deployed by", value: "John Doe")
+ ],
+ footer: "DeployBot v2.1",
+ footerIcon: "https://httpbin.org/image/png",
+ footerTimestamp: Int(Date().timeIntervalSince1970)
+ )
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send error alert message")
+ func sendErrorAlertMessage() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "Production Alert",
+ username: "AlertBot",
+ iconEmoji: ":warning:",
+ attachments: {
+ Attachment(
+ fieldsBuilder: {
+ AttachmentField("Service", value: "payment-api")
+ AttachmentField("Region", value: "us-east-1")
+ AttachmentField("Severity", value: ":rotating_light: Critical")
+ AttachmentField("Time", value: ISO8601DateFormatter().string(from: Date()))
+ },
+ actionsBuilder: {
+ Action(name: "investigate", text: "Investigate", style: "danger", url: "https://mattermost.com")
+ Action(name: "acknowledge", text: "Acknowledge", url: "https://mattermost.com")
+ }
+ )
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send feature announcement message")
+ func sendFeatureAnnouncementMessage() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "New feature announcement",
+ username: "Product Updates",
+ iconEmoji: ":mega:",
+ attachments: {
+ Attachment(
+ fieldsBuilder: {
+ AttachmentField("Version", value: "2.0")
+ AttachmentField("Release Date", value: ISO8601DateFormatter().string(from: Date()))
+ },
+ actionsBuilder: {
+ Action(name: "learn_more", text: "Learn More", style: "primary", url: "https://mattermost.com")
+ Action(name: "watch_demo", text: "Watch Demo", url: "https://mattermost.com")
+ }
+ )
+ }
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Empty/Edge Cases
+
+ @Test("Send message with empty text")
+ func sendMessageWithEmptyText() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "",
+ attachments: [
+ Attachment(text: "Text is empty but attachment is present")
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ @Test("Send message with only text (no attachments)")
+ func sendMessageWithOnlyText() async throws {
+ let client = createClient()
+ let message = Message(text: "This is a simple message with only text, no attachments.")
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Confirmation Builder
+
+ @Test("Send message using confirmation builder")
+ func sendMessageUsingConfirmationBuilder() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "🧪 Confirmation Builder Test",
+ attachments: [
+ Attachment(
+ text: "This button uses the confirmation builder",
+ actions: [
+ Action(
+ name: "delete",
+ text: "Delete",
+ style: "danger",
+ confirm: Confirmation(
+ title: "Confirm Deletion",
+ text: "Are you sure you want to delete this item?"
+ ) {
+ ConfirmButton(text: "Yes, Delete", style: "danger")
+ DenyButton(text: "Cancel")
+ }
+ )
+ ]
+ )
+ ]
+ )
+ try await client.send(message)
+
+ await waitFor(.seconds(1))
+ }
+
+ // MARK: - Final Summary Test
+
+ @Test("Send integration test summary")
+ func sendIntegrationTestSummary() async throws {
+ let client = createClient()
+ let message = Message(
+ text: "Integration Tests Complete",
+ username: "MattermostKit Test Runner",
+ iconEmoji: ":test_tube:",
+ attachments: [
+ Attachment(
+ color: "good",
+ title: "All MattermostKit features tested successfully!",
+ text: "The following were tested:",
+ fields: [
+ AttachmentField("Features", value: "Simple text messages"),
+ AttachmentField("Features", value: "Markdown formatting"),
+ AttachmentField("Features", value: "Custom username and icons"),
+ AttachmentField("Features", value: "Single and multiple attachments"),
+ AttachmentField("Features", value: "Attachment fields"),
+ AttachmentField("Features", value: "Attachment author"),
+ AttachmentField("Features", value: "Images and thumbnails"),
+ AttachmentField("Features", value: "Footer and timestamps"),
+ AttachmentField("Features", value: "Action buttons"),
+ AttachmentField("Features", value: "Confirmation dialogs"),
+ AttachmentField("Features", value: "Builder API"),
+ AttachmentField("Features", value: "Message priority"),
+ AttachmentField("Features", value: "Props and card"),
+ AttachmentField("Features", value: "Emojis and Unicode"),
+ AttachmentField("Features", value: "Code blocks"),
+ AttachmentField("Features", value: "Complex messages")
+ ]
+ ),
+ Attachment(
+ color: "#439FE0",
+ text: "Powered by **Swift 6** • Built with ❤️",
+ footer: "MattermostKit Integration Tests"
+ )
+ ]
+ )
+ try await client.send(message)
+ }
+}