Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 105 additions & 91 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ let client = try SlackWebhookClient.create(
try await client.send(Message(text: "Hello, Slack!"))
```

## Convenience Builder API

SlackKit includes a builder API for cleaner, more readable message construction:

```swift
// Clean, declarative syntax
let message = Message {
Header("Deployment Complete!")
Section("Build *#123* was deployed to *production*")
Divider()
SectionBlock(
fields: [
.markdown("*Environment:*\nProduction"),
.markdown("*Version:*\nv2.4.1")
]
)
}
try await client.send(message)
```

## Usage

### Simple Text Message
Expand All @@ -79,52 +99,45 @@ try await client.send(message)
### Message with Blocks

```swift
let message = Message(
username: "DeployBot",
iconEmoji: ":rocket:",
blocks: [
HeaderBlock(text: "Deployment Complete!"),

SectionBlock(
text: .markdown("Build *#123* was deployed to *production*")
),
let message = Message {
Header("Deployment Complete!")
Section(markdown: "Build *#123* was deployed to *production*")
Divider()

Section {
Field.markdown("*Environment:*\nProduction")
Field.markdown("*Version:*\nv2.4.1")
Field.markdown("*Duration:*\n5m 32s")
Field.markdown("*Status:*\n:white_check_mark: Success")
}
}
try await client.send(message)
```

DividerBlock(),
**With custom username and icon:**

SectionBlock(
fields: [
.markdown("*Environment:*\nProduction"),
.markdown("*Version:*\nv2.4.1"),
.markdown("*Duration:*\n5m 32s"),
.markdown("*Status:*\n:white_check_mark: Success")
]
)
]
)
```swift
let message = Message(
username: "DeployBot",
iconEmoji: ":rocket:"
) {
Header("Deployment Complete!")
Section("Build *#123* was deployed to *production*")
Divider()
}
try await client.send(message)
```

### Message with Actions

```swift
let message = Message(
text: "Approval required for production deployment",
blocks: [
SectionBlock(text: .plainText("Deploy to production?")),
ActionsBlock(elements: [
ButtonElement(
text: .plainText("Approve"),
style: .primary,
value: "approve"
),
ButtonElement(
text: .plainText("Reject"),
style: .danger,
value: "reject"
)
])
]
)
let message = Message(text: "Approval required for production deployment") {
Section("Deploy to production?")
Actions {
ButtonElement(text: .plainText("Approve"), style: .primary, value: "approve")
ButtonElement(text: .plainText("Reject"), style: .danger, value: "reject")
}
}
try await client.send(message)
```

Expand Down Expand Up @@ -165,85 +178,93 @@ try await client.send(message)
Text sections with optional fields:

```swift
SectionBlock(
text: .markdown("Some *formatted* text"),
fields: [
.markdown("*Field 1*\nValue 1"),
.markdown("*Field 2*\nValue 2")
]
)
Section("Some *formatted* text")
// Or with markdown
Section(markdown: "Some *formatted* text")
```

With fields using the result builder:

```swift
Section {
Field.markdown("*Field 1*\nValue 1")
Field.plainText("Field 2")
}
```

### Header Block

Large header text:

```swift
HeaderBlock(text: "Important Announcement")
Header("Important Announcement")
```

### Divider Block

Horizontal line divider:

```swift
DividerBlock()
Divider()
```

### Image Block

Display an image:

```swift
ImageBlock(
imageURL: URL(string: "https://example.com/image.png")!,
altText: "An example image",
title: .plainText("Image Title")
)
Image(url: "https://example.com/image.png", altText: "An example image")
```

### Actions Block

Interactive buttons:

```swift
ActionsBlock(elements: [
ButtonElement(
text: .plainText("Click Me"),
actionID: "button_1",
value: "button_value",
style: .primary
)
])
Actions {
ButtonElement(text: .plainText("Click Me"), actionID: "button_1", value: "button_value", style: .primary)
}
```

The builder also supports conditionals and loops:

```swift
Actions {
ButtonElement(text: .plainText("Approve"), actionID: "approve", value: "yes")

if needsReview {
ButtonElement(text: .plainText("Request Review"), actionID: "review", value: "review")
}

for option in options {
ButtonElement(text: .plainText(option), actionID: "opt_\(option)", value: option)
}
}
```

### Context Block

Contextual information with images and text:
Contextual information with text and images:

```swift
ContextBlock(elements: [
TextContextElement(text: "Created by @john"),
ImageContextElement(
imageURL: "https://example.com/avatar.png",
altText: "Avatar"
)
])
// Simple text context
Context("Created by @john", "2 minutes ago")

// Or with elements using the builder
Context {
TextContextElement(text: "Created by @john")
ImageContextElement(imageURL: "https://example.com/avatar.png", altText: "Avatar")
}
```

### Input Block (Modals)

Input blocks for collecting user input in modals:

```swift
InputBlock(
label: .plainText("Task description"),
element: PlainTextInputElement(
placeholder: "Enter task details...",
multiline: true
),
hint: .plainText("Be specific about the requirements"),
optional: false
Input(
label: "Task description",
element: PlainTextInputElement(placeholder: "Enter task details...", multiline: true)
)
```

Expand All @@ -263,26 +284,19 @@ ButtonElement(
### Select Menu

```swift
StaticSelectElement(
placeholder: .plainText("Choose an option"),
options: [
Option(text: .plainText("Option 1"), value: "opt1"),
Option(text: .plainText("Option 2"), value: "opt2")
]
)
StaticSelectElement(placeholder: .plainText("Choose an option")) {
Option(text: .plainText("Option 1"), value: "opt1")
Option(text: .plainText("Option 2"), value: "opt2")
}
```

### Multi-Select Menu

```swift
MultiStaticSelectElement(
placeholder: .plainText("Select options"),
options: [
Option(text: .plainText("Option 1"), value: "opt1"),
Option(text: .plainText("Option 2"), value: "opt2")
],
maxSelectedItems: 3
)
MultiStaticSelectElement(placeholder: .plainText("Select options"), maxSelectedItems: 3) {
Option(text: .plainText("Option 1"), value: "opt1")
Option(text: .plainText("Option 2"), value: "opt2")
}
```

### Date Picker
Expand Down
13 changes: 6 additions & 7 deletions Sources/SlackKit/Client/SlackWebhookClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,18 @@ public final actor SlackWebhookClient {
// Send the request
let response = try await networkClient.post(url: webhookURL, body: body)

// Check for rate limiting (must check before isSuccess)
if response.statusCode == 429 {
let retryAfter = extractRetryAfter(from: response) ?? 60
throw SlackError.rateLimitExceeded(retryAfter: retryAfter)
}

// Check for HTTP errors
guard response.isSuccess else {
let bodyString = String(data: response.data, encoding: .utf8)
throw SlackError.invalidResponse(statusCode: response.statusCode, body: bodyString)
}

// Check for rate limiting
if response.statusCode == 429 {
if let retryAfter = extractRetryAfter(from: response) {
throw SlackError.rateLimitExceeded(retryAfter: retryAfter)
}
}

// Decode the response
// Slack webhooks return "ok" as plain text on success
if let bodyString = String(data: response.data, encoding: .utf8),
Expand Down
11 changes: 2 additions & 9 deletions Sources/SlackKit/Client/URLSessionNetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,11 @@ import Foundation
/// A URLSession-based implementation of NetworkClient
public actor URLSessionNetworkClient: NetworkClient {
private let session: URLSession
private let decoder: JSONDecoder

/// Initializes a new URLSession network client
/// - Parameters:
/// - session: The URLSession to use for requests (defaults to shared)
/// - decoder: The JSONDecoder to use for decoding responses (defaults to standard)
public init(
session: URLSession = .shared,
decoder: JSONDecoder = JSONDecoder()
) {
/// - Parameter session: The URLSession to use for requests (defaults to shared)
public init(session: URLSession = .shared) {
self.session = session
self.decoder = decoder
}

public func post(url: URL, body: Data) async throws -> HTTPResponse {
Expand Down
73 changes: 73 additions & 0 deletions Sources/SlackKit/Models/Blocks/ActionsBlock+Builder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Foundation

// MARK: - ActionsBuilder

/// A result builder for constructing ActionsBlock elements
@resultBuilder
public enum ActionsBuilder {
/// Builds an empty element array
public static func buildBlock() -> [any BlockElement] {
[]
}

/// Builds an element array from multiple elements
public static func buildBlock(_ components: [any BlockElement]...) -> [any BlockElement] {
components.flatMap { $0 }
}

/// Builds an element array from a single element expression
public static func buildExpression(_ expression: any BlockElement) -> [any BlockElement] {
[expression]
}

/// Builds an element array from an optional element expression
public static func buildExpression(_ expression: (any BlockElement)?) -> [any BlockElement] {
expression.map { [$0] } ?? []
}

/// Builds an element array from an array of elements (pass-through)
public static func buildExpression(_ expression: [any BlockElement]) -> [any BlockElement] {
expression
}

/// Builds an element array from an if block
public static func buildIf(_ content: [any BlockElement]?) -> [any BlockElement] {
content ?? []
}

/// Builds an element array from an if-else block (first branch)
public static func buildEither(first component: [any BlockElement]) -> [any BlockElement] {
component
}

/// Builds an element array from an if-else block (second branch)
public static func buildEither(second component: [any BlockElement]) -> [any BlockElement] {
component
}

/// Builds an element array from a for loop
public static func buildArray(_ components: [[any BlockElement]]) -> [any BlockElement] {
components.flatMap { $0 }
}

/// Builds the final element array
public static func buildFinalBlock(_ component: [any BlockElement]) -> [any BlockElement] {
component
}
}

// MARK: - ActionsBlock Convenience Initializer

extension ActionsBlock {
/// Initializes a new actions block using a result builder
/// - Parameters:
/// - blockID: An optional identifier for the block
/// - builder: A result builder closure that provides the elements
public init(
blockID: String? = nil,
@ActionsBuilder builder: () -> [any BlockElement]
) {
self.elements = builder()
self.blockID = blockID
}
}
Loading
Loading