// retoor 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 = [] 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 }