Skip to content

Commit ebf0b19

Browse files
authored
feature: implement attachments (#6)
* Implement attachments feature * Remove useless code * Fix readme * Remove redundant logger messages * Clean up after bot, fix review issues * Improve test suite and fix bugs
1 parent f39a96e commit ebf0b19

File tree

6 files changed

+487
-18
lines changed

6 files changed

+487
-18
lines changed

README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,37 @@ try await message.send(as: "slack_token")
4949
- User and group mentions
5050
- Context
5151

52-
### TODO
52+
### Attachments
5353

54-
- Images
55-
- Tables
54+
Slackito also supports sending attachments including images, CSV files, and other file types:
55+
56+
```swift
57+
let message = SlackMessage(
58+
channel: "reports",
59+
attachments: [imageAttachment, csvAttachment, csvDataAttachment]
60+
) {
61+
MarkdownSection("📊 Here's the monthly report with data!")
62+
Context {
63+
"*Generated on*: \(Date())"
64+
"*Data source*: Internal systems"
65+
}
66+
}
67+
68+
try await message.send(as: "slack_token")
69+
```
70+
71+
#### Supported File Types
72+
73+
- **Images**: JPEG, PNG, GIF
74+
- **Documents**: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX
75+
- **Data**: CSV, JSON, XML, TXT
76+
- **Archives**: ZIP
77+
- **Media**: MP4, MOV, MP3, WAV
78+
79+
#### Attachment Types
80+
81+
- **URL-based**: Attach files from web URLs
82+
- **Data-based**: Attach files from local data (automatically uploaded to Slack)
83+
- **Images**: Special handling for image attachments with alt text support
5684

5785
> Author: [@havebeenfitz](https://github.com/havebeenfitz)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import Foundation
2+
import UniformTypeIdentifiers
3+
4+
/// Represents a file attachment for Slack messages
5+
public struct SlackAttachment: Sendable {
6+
/// The type of attachment
7+
public let type: AttachmentType
8+
/// Optional title for the attachment
9+
public let title: String?
10+
/// Optional fallback text for the attachment
11+
public let fallback: String?
12+
/// Optional color for the attachment (hex color code)
13+
public let color: String?
14+
/// Optional text content for the attachment
15+
public let text: String?
16+
17+
public init(
18+
type: AttachmentType,
19+
title: String? = nil,
20+
fallback: String? = nil,
21+
color: String? = nil,
22+
text: String? = nil
23+
) {
24+
self.type = type
25+
self.title = title
26+
self.fallback = fallback
27+
self.color = color
28+
self.text = text
29+
}
30+
31+
/// JSON representation of the attachment for Slack API
32+
public var json: String {
33+
var components: [String] = []
34+
35+
// Add title if present
36+
if let title = title {
37+
components.append("\"title\": \"\(title)\"")
38+
}
39+
40+
// Add fallback if present
41+
if let fallback = fallback {
42+
components.append("\"fallback\": \"\(fallback)\"")
43+
}
44+
45+
// Add color if present
46+
if let color = color {
47+
components.append("\"color\": \"\(color)\"")
48+
}
49+
50+
// Add text if present
51+
if let text = text {
52+
components.append("\"text\": \"\(text)\"")
53+
}
54+
55+
// Add type-specific fields
56+
switch type {
57+
case .image(let url, let altText):
58+
components.append("\"image_url\": \"\(url)\"")
59+
if let altText = altText {
60+
components.append("\"alt_text\": \"\(altText)\"")
61+
}
62+
case .file(let url, let filename, let fileType):
63+
components.append("\"file_url\": \"\(url)\"")
64+
components.append("\"filename\": \"\(filename)\"")
65+
components.append("\"filetype\": \"\(fileType.rawValue)\"")
66+
case .fileData(_, let filename, let fileType):
67+
components.append("\"filename\": \"\(filename)\"")
68+
components.append("\"filetype\": \"\(fileType.rawValue)\"")
69+
}
70+
71+
return "{ \(components.joined(separator: ", ")) }"
72+
}
73+
}
74+
75+
/// Types of attachments supported
76+
public enum AttachmentType: Sendable {
77+
/// Image attachment from URL
78+
case image(url: String, altText: String? = nil)
79+
/// File attachment from URL
80+
case file(url: String, filename: String, fileType: FileType)
81+
/// File attachment from local data
82+
case fileData(data: Data, filename: String, fileType: FileType)
83+
}
84+
85+
/// Supported file types for attachments
86+
public enum FileType: String, CaseIterable, Sendable {
87+
case csv, pdf, txt, json, xml, zip, jpeg, png, gif, mp4, mov, mp3
88+
89+
/// MIME type for the file type
90+
public var mimeType: String? {
91+
switch self {
92+
case .csv: UTType.commaSeparatedText.preferredMIMEType
93+
case .pdf: UTType.pdf.preferredMIMEType
94+
case .txt: UTType.plainText.preferredMIMEType
95+
case .json: UTType.json.preferredMIMEType
96+
case .xml: UTType.xml.preferredMIMEType
97+
case .zip: UTType.zip.preferredMIMEType
98+
case .jpeg: UTType.jpeg.preferredMIMEType
99+
case .png: UTType.png.preferredMIMEType
100+
case .gif: UTType.gif.preferredMIMEType
101+
case .mp4: UTType.mpeg4Movie.preferredMIMEType
102+
case .mov: UTType.quickTimeMovie.preferredMIMEType
103+
case .mp3: UTType.mp3.preferredMIMEType
104+
}
105+
}
106+
}
107+
108+
// MARK: - Convenience Initializers
109+
110+
public extension SlackAttachment {
111+
112+
static func image(url: String, altText: String? = nil, title: String? = nil, fallback: String? = nil) -> SlackAttachment {
113+
SlackAttachment(
114+
type: .image(url: url, altText: altText),
115+
title: title,
116+
fallback: fallback
117+
)
118+
}
119+
120+
static func file(url: String, filename: String, fileType: FileType, title: String? = nil, fallback: String? = nil) -> SlackAttachment {
121+
SlackAttachment(
122+
type: .file(url: url, filename: filename, fileType: fileType),
123+
title: title,
124+
fallback: fallback
125+
)
126+
}
127+
128+
static func file(data: Data, filename: String, fileType: FileType, title: String? = nil, fallback: String? = nil) -> SlackAttachment {
129+
SlackAttachment(
130+
type: .fileData(data: data, filename: filename, fileType: fileType),
131+
title: title,
132+
fallback: fallback
133+
)
134+
}
135+
}

Sources/Slackito/Models/SlackMessage.swift

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,29 @@ public struct SlackMessage: BlockConvertible {
66
/// Channel id to post to.
77
///
88
/// Better to have an id in a `C061Z3P47RB` format to use both post and update methods
9-
private let channel: String
9+
let channel: String
1010
/// Thread timestamp to reply to or update
1111
///
1212
/// `ts` is provided in different formats for both `post` and `update` methods
13-
private let ts: String?
13+
let ts: String?
1414
/// Building blocks of a message
1515
///
1616
/// Result builder DSL to make a message
17-
private let blocks: [BlockConvertible]
17+
let blocks: [BlockConvertible]
18+
/// File attachments for the message
19+
let attachments: [SlackAttachment]
1820

1921
public var json: String {
22+
let blocksJson = blocks.json
23+
let attachmentsJson = attachments.isEmpty ? "" : ", \"attachments\": [ \(attachments.map(\.json).joined(separator: ", ")) ]"
24+
2025
if let ts {
21-
"""
22-
{ "channel": "\(channel)", "thread_ts": "\(ts)", "ts": "\(ts)", "blocks": [ \(blocks.json) ] }
26+
return """
27+
{ "channel": "\(channel)", "thread_ts": "\(ts)", "ts": "\(ts)", "blocks": [ \(blocksJson) ]\(attachmentsJson) }
2328
"""
2429
} else {
25-
"""
26-
{ "channel": "\(channel)", "blocks": [ \(blocks.json) ] }
30+
return """
31+
{ "channel": "\(channel)", "blocks": [ \(blocksJson) ]\(attachmentsJson) }
2732
"""
2833
}
2934
}
@@ -32,5 +37,20 @@ public struct SlackMessage: BlockConvertible {
3237
self.channel = channel
3338
self.ts = ts
3439
self.blocks = makeBlocks()
40+
self.attachments = []
41+
}
42+
43+
public init(channel: String, ts: String? = nil, attachments: [SlackAttachment] = [], @SlackMessageBuilder _ makeBlocks: () -> [BlockConvertible]) {
44+
self.channel = channel
45+
self.ts = ts
46+
self.blocks = makeBlocks()
47+
self.attachments = attachments
48+
}
49+
50+
public init(channel: String, ts: String? = nil, blocks: [BlockConvertible], attachments: [SlackAttachment] = []) {
51+
self.channel = channel
52+
self.ts = ts
53+
self.blocks = blocks
54+
self.attachments = attachments
3555
}
3656
}

Sources/Slackito/SlackMessage+Request.swift

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ extension SlackMessage {
55
@discardableResult
66
public func send(as appToken: String?) async throws -> MessageMeta {
77
let api = try Slackito(appToken: appToken)
8+
let processedAttachments = try await processAttachments(appToken: appToken, api: api)
9+
10+
let rebuiltMessage = SlackMessage(
11+
channel: channel,
12+
ts: ts,
13+
blocks: blocks,
14+
attachments: processedAttachments
15+
)
16+
817
let response = try await api.sendRequest(
918
endpoint: "chat.postMessage",
10-
body: json,
19+
body: rebuiltMessage.json,
1120
httpMethod: "POST"
1221
)
1322

@@ -21,9 +30,18 @@ extension SlackMessage {
2130
@discardableResult
2231
public func update(as appToken: String?) async throws -> MessageMeta {
2332
let api = try Slackito(appToken: appToken)
33+
let processedAttachments = try await processAttachments(appToken: appToken, api: api)
34+
35+
let rebuiltMessage = SlackMessage(
36+
channel: channel,
37+
ts: ts,
38+
blocks: blocks,
39+
attachments: processedAttachments
40+
)
41+
2442
let response = try await api.sendRequest(
2543
endpoint: "chat.update",
26-
body: json,
44+
body: rebuiltMessage.json,
2745
httpMethod: "POST"
2846
)
2947

@@ -41,3 +59,44 @@ public extension SlackMessage {
4159
public let timestamp: String?
4260
}
4361
}
62+
63+
// MARK: - Attachments
64+
65+
private extension SlackMessage {
66+
67+
func processAttachments(appToken: String?, api: Slackito) async throws -> [SlackAttachment] {
68+
let api = try Slackito(appToken: appToken)
69+
var processedAttachments = attachments
70+
71+
for (index, attachment) in attachments.enumerated() {
72+
switch attachment.type {
73+
case .fileData(let data, let filename, let fileType):
74+
let uploadResponse = try await api.uploadFile(
75+
data: data,
76+
filename: filename,
77+
fileType: fileType.rawValue,
78+
channels: [channel]
79+
)
80+
81+
guard uploadResponse.ok, let fileInfo = uploadResponse.file else {
82+
throw NSError(domain: "File upload failed: \(uploadResponse.error ?? "Unknown error")", code: 2)
83+
}
84+
85+
// Replace the fileData attachment with a file URL attachment
86+
let newAttachment = SlackAttachment(
87+
type: .file(url: fileInfo.urlPrivate, filename: fileInfo.name, fileType: fileType),
88+
title: attachment.title,
89+
fallback: attachment.fallback,
90+
color: attachment.color,
91+
text: attachment.text
92+
)
93+
processedAttachments[index] = newAttachment
94+
default:
95+
// No processing needed for URL-based attachments
96+
break
97+
}
98+
}
99+
100+
return processedAttachments
101+
}
102+
}

0 commit comments

Comments
 (0)