// retoor <retoor@molodetz.nl>
import Foundation
import SwiftDevRant
struct SpamActions: Sendable {
let api: DevRantRequest
let token: AuthToken
let logger: BotLogger
let dryRun: Bool
init(api: DevRantRequest, token: AuthToken, logger: BotLogger, dryRun: Bool = false) {
self.api = api
self.token = token
self.logger = logger
self.dryRun = dryRun
}
func downvoteRant(_ rantId: Int, reason: DownvoteReason = .offensiveOrSpam) async {
guard !dryRun else {
await logger.info("DRY RUN: Would downvote rant \(rantId)")
return
}
do {
_ = try await api.voteOnRant(token: token, rantId: rantId, vote: .downvoted, downvoteReason: reason)
await logger.info("Downvoted rant \(rantId)")
} catch {
await logger.error("Failed to downvote rant \(rantId): \(error)")
}
}
func downvoteComment(_ commentId: Int, reason: DownvoteReason = .offensiveOrSpam) async {
guard !dryRun else {
await logger.info("DRY RUN: Would downvote comment \(commentId)")
return
}
do {
_ = try await api.voteOnComment(token: token, commentId: commentId, vote: .downvoted, downvoteReason: reason)
await logger.info("Downvoted comment \(commentId)")
} catch {
await logger.error("Failed to downvote comment \(commentId): \(error)")
}
}
func upvoteRant(_ rantId: Int) async {
guard !dryRun else {
await logger.info("DRY RUN: Would upvote rant \(rantId)")
return
}
do {
_ = try await api.voteOnRant(token: token, rantId: rantId, vote: .upvoted)
await logger.info("Upvoted rant \(rantId)")
} catch {
await logger.error("Failed to upvote rant \(rantId): \(error)")
}
}
func upvoteComment(_ commentId: Int) async {
guard !dryRun else {
await logger.info("DRY RUN: Would upvote comment \(commentId)")
return
}
do {
_ = try await api.voteOnComment(token: token, commentId: commentId, vote: .upvoted)
await logger.info("Upvoted comment \(commentId)")
} catch {
await logger.error("Failed to upvote comment \(commentId): \(error)")
}
}
}
actor SpamScanner {
private let api: DevRantRequest
private let token: AuthToken
private let detector: SpamDetector
private let actions: SpamActions
private let logger: BotLogger
private var processedRantIds: Set<Int> = []
private var processedCommentIds: Set<Int> = []
private var userPostCache: [Int: [PostInfo]] = [:]
private var spammerIds: Set<Int> = []
init(api: DevRantRequest, token: AuthToken, logger: BotLogger, dryRun: Bool = false) {
self.api = api
self.token = token
self.detector = SpamDetector()
self.actions = SpamActions(api: api, token: token, logger: logger, dryRun: dryRun)
self.logger = logger
}
func run(continuous: Bool, intervalSeconds: Int) async {
await logger.info("SpamScanner started")
repeat {
await scanRecentFeed()
await scanAlgorithmFeed()
if continuous {
await logger.info("Sleeping for \(intervalSeconds) seconds...")
try? await Task.sleep(nanoseconds: UInt64(intervalSeconds) * 1_000_000_000)
}
} while continuous
}
private func scanRecentFeed() async {
await logger.debug("Scanning recent feed...")
do {
let feed = try await api.getRantFeed(token: token, sort: .recent, limit: 50, skip: 0, sessionHash: nil)
await processRants(feed.rants)
} catch {
await logger.error("Failed to fetch recent feed: \(error)")
}
}
private func scanAlgorithmFeed() async {
await logger.debug("Scanning algorithm feed...")
do {
let feed = try await api.getRantFeed(token: token, sort: .algorithm, limit: 50, skip: 0, sessionHash: nil)
await processRants(feed.rants)
} catch {
await logger.error("Failed to fetch algorithm feed: \(error)")
}
}
private func processRants(_ rants: [Rant]) async {
for rant in rants {
guard !processedRantIds.contains(rant.id) else { continue }
processedRantIds.insert(rant.id)
await analyzeRant(rant)
do {
let (_, comments) = try await api.getRant(token: token, rantId: rant.id, lastCommentId: nil)
await processComments(comments)
} catch {
await logger.error("Failed to fetch comments for rant \(rant.id): \(error)")
}
}
}
private func processComments(_ comments: [Comment]) async {
for comment in comments {
guard !processedCommentIds.contains(comment.id) else { continue }
processedCommentIds.insert(comment.id)
await analyzeComment(comment)
}
}
private func analyzeRant(_ rant: Rant) async {
let analysis = detector.analyze(rant.text)
trackUserPost(PostInfo(
id: rant.id,
text: rant.text,
created: rant.created,
authorId: rant.author.id
))
if analysis.isSpam {
await logger.spam("RANT SPAM [id=\(rant.id)] [user=\(rant.author.name)] [patterns=\(analysis.matchedPatterns.joined(separator: ","))]")
await actions.downvoteRant(rant.id)
}
let userPosts = userPostCache[rant.author.id] ?? []
let behaviorAnalysis = detector.analyzeUserBehavior(posts: userPosts)
if behaviorAnalysis.isSuspicious && !spammerIds.contains(rant.author.id) {
spammerIds.insert(rant.author.id)
await logger.spam("SUSPICIOUS USER [id=\(rant.author.id)] [name=\(rant.author.name)]")
}
}
private func analyzeComment(_ comment: Comment) async {
let analysis = detector.analyze(comment.text)
trackUserPost(PostInfo(
id: comment.id,
text: comment.text,
created: comment.created,
authorId: comment.author.id
))
if analysis.isSpam {
await logger.spam("COMMENT SPAM [id=\(comment.id)] [user=\(comment.author.name)] [patterns=\(analysis.matchedPatterns.joined(separator: ","))]")
await actions.downvoteComment(comment.id)
}
let userPosts = userPostCache[comment.author.id] ?? []
let behaviorAnalysis = detector.analyzeUserBehavior(posts: userPosts)
if behaviorAnalysis.isSuspicious && !spammerIds.contains(comment.author.id) {
spammerIds.insert(comment.author.id)
await logger.spam("SUSPICIOUS USER [id=\(comment.author.id)] [name=\(comment.author.name)]")
}
}
private func trackUserPost(_ post: PostInfo) {
var posts = userPostCache[post.authorId] ?? []
posts.append(post)
if posts.count > 50 {
posts = Array(posts.suffix(50))
}
userPostCache[post.authorId] = posts
}
func getStats() -> (rants: Int, comments: Int, spammers: Int) {
return (processedRantIds.count, processedCommentIds.count, spammerIds.count)
}
}