-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWelcomeBot.swift
More file actions
executable file
·219 lines (177 loc) · 8.02 KB
/
WelcomeBot.swift
File metadata and controls
executable file
·219 lines (177 loc) · 8.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
struct MastodonAccount: Codable {
let id: String
let username: String
let createdAt: String
let approved: Bool
private enum CodingKeys: String, CodingKey {
case id, username, approved
case createdAt = "created_at"
}
}
struct StatusRequest: Codable {
let status: String
let visibility: String
}
class MastodonWelcomeBot {
private let baseURL: String
private let accessToken: String
private let timestampFile = "last_check_timestamp.txt"
private let dateFormatter: ISO8601DateFormatter
init() {
// Try to load from .env file if it exists (for local development)
Self.loadEnvFile()
// Configure ISO8601 date formatter with more flexible options
self.dateFormatter = ISO8601DateFormatter()
self.dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let baseURL = ProcessInfo.processInfo.environment["MASTODON_BASE_URL"],
let accessToken = ProcessInfo.processInfo.environment["MASTODON_ACCESS_TOKEN"]
else {
fatalError(
"Missing required environment variables: MASTODON_BASE_URL, MASTODON_ACCESS_TOKEN")
}
var cleanBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
while cleanBaseURL.hasSuffix("/") {
cleanBaseURL.removeLast()
}
self.baseURL = cleanBaseURL
self.accessToken = accessToken
}
private static func loadEnvFile() {
guard let envData = try? String(contentsOfFile: ".env", encoding: .utf8) else {
return // No .env file found, continue with system environment variables
}
let lines = envData.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed.hasPrefix("#") {
continue // Skip empty lines and comments
}
let parts = trimmed.components(separatedBy: "=")
if parts.count == 2 {
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
setenv(key, value, 1) // Add to environment
}
}
}
func run() async {
do {
let lastCheckTime = readLastCheckTime()
print("Checking for new accounts since: \(lastCheckTime)")
let newAccounts = try await fetchNewAccounts(since: lastCheckTime)
print("Found \(newAccounts.count) new accounts")
for account in newAccounts {
try await sendWelcomeMessage(to: account)
print("Sent welcome message to @\(account.username)")
// Update timestamp after each successful send
try updateLastProcessedAccount(to: account.createdAt)
print("Updated timestamp to: \(account.createdAt)")
// Small delay to be respectful of rate limits
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
}
print("Completed processing all new accounts")
} catch {
print("Error: \(error)")
exit(1)
}
}
private func readLastCheckTime() -> String {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: timestampFile)),
let timestamp = String(data: data, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines)
else {
// Default to 24 hours ago if no timestamp file exists
let oneDayAgo = Date().addingTimeInterval(-24 * 60 * 60)
return dateFormatter.string(from: oneDayAgo)
}
print("Read timestamp from file: \(timestamp)")
return timestamp
}
private func updateLastProcessedAccount(to timestamp: String) throws {
try timestamp.write(
to: URL(fileURLWithPath: timestampFile), atomically: true, encoding: .utf8)
}
private func fetchNewAccounts(since: String) async throws -> [MastodonAccount] {
let url = URL(string: "\(baseURL)/api/v1/admin/accounts")!
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw NSError(
domain: "API", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to fetch accounts"])
}
let allAccounts = try JSONDecoder().decode([MastodonAccount].self, from: data)
// Sort accounts oldest to newest by creation date so the last timestamp is the newest
let sortedAccounts = allAccounts.sorted(by: { $0.createdAt < $1.createdAt })
// Filter for accounts created after our last check and are approved
let sinceDate =
dateFormatter.date(from: since)
?? {
// Try fallback parsing without fractional seconds
let fallbackFormatter = ISO8601DateFormatter()
fallbackFormatter.formatOptions = [.withInternetDateTime]
let fallbackDate = fallbackFormatter.date(from: since)
print(
"Primary date parsing failed for '\(since)', fallback result: \(fallbackDate?.description ?? "nil")"
)
return fallbackDate ?? Date.distantPast
}()
print("Found \(allAccounts.count) total accounts")
print("Parsed since date '\(since)' as: \(sinceDate)")
let newAccounts = sortedAccounts.filter { account in
guard account.approved else { return false }
if let accountDate = dateFormatter.date(from: account.createdAt) {
let isNewer = accountDate > sinceDate
if isNewer {
print(
"Account @\(account.username) created at \(account.createdAt) (\(accountDate)) is newer than \(sinceDate)"
)
}
return isNewer
}
return false
}
return newAccounts
}
private func sendWelcomeMessage(to account: MastodonAccount) async throws {
let welcomeText = """
@\(account.username) Welcome to the iOS Dev Space! 👋 if you have any issues with the server, let me know and the admin team can take a look at it
Make sure to checkout the rules ➡️ https://iosdev.space/about
Consider making a donation to cover maintenance costs of the instance ➡️ https://opencollective.com/iosdevspace. Even $1/month will help us keep the server running smoothly.
Make sure to post and introduce yourself using #introduction
Thanks for being here!
"""
let statusRequest = StatusRequest(
status: welcomeText,
visibility: "direct"
)
let url = URL(string: "\(baseURL)/api/v1/statuses")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let jsonData = try JSONEncoder().encode(statusRequest)
request.httpBody = jsonData
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw NSError(
domain: "API", code: 2,
userInfo: [
NSLocalizedDescriptionKey:
"Failed to send welcome message to @\(account.username)"
])
}
}
}
// Main execution
let bot = MastodonWelcomeBot()
await bot.run()