|
// 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
|
|
}
|