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