// retoor <retoor@molodetz.nl>
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import SwiftDevRant
struct ExternalMention: Decodable {
let from: String
let to: String
let content: String
let rantId: Int
let commentId: Int
let createdTime: Int
let identifiers: Identifiers
struct Identifiers: Decodable {
let guid: String
let uniqueKey: String
enum CodingKeys: String, CodingKey {
case guid
case uniqueKey = "unique_key"
}
}
enum CodingKeys: String, CodingKey {
case from, to, content
case rantId = "rant_id"
case commentId = "comment_id"
case createdTime = "created_time"
case identifiers
}
}
actor MentionBot {
private static let mentionsURL = "https://static.molodetz.nl/dr.mentions.json"
private let api: DevRantRequest
private let token: AuthToken
private let ai: OpenRouterClient
private let logger: BotLogger
private let stateManager: StateManager
private let botUsername: String
private let dryRun: Bool
private var processedThisSession: Set<String> = []
init(
api: DevRantRequest,
token: AuthToken,
ai: OpenRouterClient,
logger: BotLogger,
stateManager: StateManager,
botUsername: String,
dryRun: Bool = false
) {
self.api = api
self.token = token
self.ai = ai
self.logger = logger
self.stateManager = stateManager
self.botUsername = botUsername.lowercased()
self.dryRun = dryRun
}
func run(continuous: Bool, intervalSeconds: Int) async {
await logger.info("MentionBot started as @\(botUsername). Dry run: \(dryRun)")
repeat {
await checkMentions()
if continuous {
await logger.debug("Sleeping for \(intervalSeconds) seconds...")
try? await Task.sleep(nanoseconds: UInt64(intervalSeconds) * 1_000_000_000)
}
} while continuous
}
private func checkMentions() async {
await logger.debug("Checking for mentions...")
do {
let mentions = try await fetchExternalMentions()
let lastTime = await stateManager.lastMentionTime
let newMentions = mentions.filter { mention in
mention.to.lowercased() == botUsername &&
mention.createdTime > lastTime &&
!processedThisSession.contains(mention.identifiers.guid)
}
if newMentions.isEmpty {
await logger.debug("No new mentions")
return
}
await logger.info("Found \(newMentions.count) new mention(s)")
for mention in newMentions.sorted(by: { $0.createdTime < $1.createdTime }) {
await processMention(mention)
}
} catch {
await logger.error("Failed to check mentions: \(error)")
}
}
private func fetchExternalMentions() async throws -> [ExternalMention] {
guard let url = URL(string: Self.mentionsURL) else {
throw MentionError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw MentionError.fetchFailed
}
return try JSONDecoder().decode([ExternalMention].self, from: data)
}
private func processMention(_ mention: ExternalMention) async {
guard !processedThisSession.contains(mention.identifiers.guid) else {
await logger.debug("Already processed mention: \(mention.identifiers.guid)")
return
}
processedThisSession.insert(mention.identifiers.guid)
do {
let (rant, comments) = try await api.getRant(
token: token,
rantId: mention.rantId,
lastCommentId: nil
)
guard let mentionComment = comments.first(where: { $0.id == mention.commentId }) else {
await logger.warning("Could not find mention comment \(mention.commentId) in rant \(mention.rantId)")
return
}
let question = extractQuestion(from: mention.content)
await logger.info("Mention from @\(mention.from): \(question.prefix(100))...")
let context = buildContext(rant: rant, comments: comments, mentionComment: mentionComment)
let response = try await ai.chat(message: question, context: context)
await logger.info("AI response: \(response.prefix(100))...")
await postReply(
rantId: mention.rantId,
replyTo: mention.from,
response: response
)
await stateManager.updateLastMentionTime(mention.createdTime)
} catch {
await logger.error("Failed to process mention: \(error)")
}
}
private func extractQuestion(from text: String) -> String {
var cleaned = text
let mentionPattern = try? NSRegularExpression(
pattern: "@\(NSRegularExpression.escapedPattern(for: botUsername))",
options: [.caseInsensitive]
)
if let pattern = mentionPattern {
let range = NSRange(cleaned.startIndex..., in: cleaned)
cleaned = pattern.stringByReplacingMatches(
in: cleaned,
options: [],
range: range,
withTemplate: ""
)
}
return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func buildContext(rant: Rant, comments: [Comment], mentionComment: Comment) -> String {
var context = "Original rant by @\(rant.author.name):\n\(rant.text)\n\n"
let relevantComments = comments.filter { $0.id != mentionComment.id }
.suffix(5)
if !relevantComments.isEmpty {
context += "Recent comments:\n"
for comment in relevantComments {
context += "- @\(comment.author.name): \(comment.text.prefix(200))\n"
}
}
return context
}
private func filterSelfMentions(from text: String) -> String {
guard let pattern = try? NSRegularExpression(
pattern: "@\(NSRegularExpression.escapedPattern(for: botUsername))",
options: [.caseInsensitive]
) else {
return text
}
let range = NSRange(text.startIndex..., in: text)
return pattern.stringByReplacingMatches(
in: text,
options: [],
range: range,
withTemplate: ""
).replacingOccurrences(of: " ", with: " ")
.trimmingCharacters(in: .whitespaces)
}
private func postReply(rantId: Int, replyTo: String, response: String) async {
let filteredResponse = filterSelfMentions(from: response)
let reply = "@\(replyTo) \(filteredResponse)"
guard !dryRun else {
await logger.info("DRY RUN: Would post reply to rant \(rantId):\n\(reply)")
return
}
do {
try await api.postComment(
token: token,
rantId: rantId,
text: reply,
image: nil
)
await logger.info("Posted reply to rant \(rantId)")
} catch {
await logger.error("Failed to post reply: \(error)")
}
}
func getStats() -> Int {
return processedThisSession.count
}
}
enum MentionError: Error {
case invalidURL
case fetchFailed
}