diff --git a/README.md b/README.md index 836e125..fbef6f9 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,9 @@
-
-
-
-
-
-
-
-
-
+
+
+
@@ -21,13 +15,16 @@
[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 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 +65,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 +78,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
-Mattermost supports displaying custom content in the RHS sidebar via `props.card`:
+```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")
+]
+
+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 +226,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 +429,7 @@ public struct Message: Sendable, Codable {
}
```
-## Attachment Properties
+### Attachment
```swift
public struct Attachment: Sendable, Codable {
@@ -261,22 +451,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 +475,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..d7cba14
--- /dev/null
+++ b/Sources/MattermostKit/Models/Confirmation+Builder.swift
@@ -0,0 +1,106 @@
+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
+ }
+
+ /// Builds a component array from a for loop
+ public static func buildArray(_ components: [[ConfirmationComponent]]) -> [ConfirmationComponent] {
+ components.flatMap { $0 }
+ }
+}
+
+// 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, _):
+ 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..0c4bb3d
--- /dev/null
+++ b/Sources/MattermostKit/Models/Props+Builder.swift
@@ -0,0 +1,148 @@
+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
+ let builtProperties = properties()
+ self.additionalProperties = builtProperties.isEmpty ? nil : builtProperties
+ }
+}
+
+// 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")
+ }
+}
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)
+ }
+}