feat: add Grokii AI-powered devRant bot with chat and spam detection modes
Introduce a new Swift-based assistant bot for the devRant platform, featuring real-time @mention monitoring via an external JSON feed, AI response generation through OpenRouter (xAI Grok models), and optional spam detection with pattern-based content analysis and automatic downvoting. Includes a modular architecture with BotLogger, MentionBot actor, OpenRouterClient, and CLI subcommands (Chat, Spam) built on ArgumentParser, along with Package.swift dependencies on SwiftDevRantSDK and KreeRequest.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import Foundation
|
||||
|
||||
actor BotLogger {
|
||||
enum Level: String {
|
||||
case debug = "DEBUG"
|
||||
case info = "INFO"
|
||||
case warning = "WARN"
|
||||
case error = "ERROR"
|
||||
case spam = "SPAM"
|
||||
}
|
||||
|
||||
private let dateFormatter: DateFormatter
|
||||
private let logFile: FileHandle?
|
||||
private let logToConsole: Bool
|
||||
private let minLevel: Level
|
||||
|
||||
init(logPath: String? = nil, logToConsole: Bool = true, minLevel: Level = .info) {
|
||||
self.dateFormatter = DateFormatter()
|
||||
self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
self.logToConsole = logToConsole
|
||||
self.minLevel = minLevel
|
||||
|
||||
if let path = logPath {
|
||||
_ = FileManager.default.createFile(atPath: path, contents: nil, attributes: nil)
|
||||
self.logFile = FileHandle(forWritingAtPath: path)
|
||||
self.logFile?.seekToEndOfFile()
|
||||
} else {
|
||||
self.logFile = nil
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? logFile?.close()
|
||||
}
|
||||
|
||||
func log(_ level: Level, _ message: String) {
|
||||
guard shouldLog(level) else { return }
|
||||
|
||||
let timestamp = dateFormatter.string(from: Date())
|
||||
let formattedMessage = "[\(timestamp)] [\(level.rawValue)] \(message)"
|
||||
|
||||
if logToConsole {
|
||||
print(formattedMessage)
|
||||
}
|
||||
|
||||
if let fileHandle = logFile,
|
||||
let data = (formattedMessage + "\n").data(using: .utf8) {
|
||||
try? fileHandle.write(contentsOf: data)
|
||||
}
|
||||
}
|
||||
|
||||
func debug(_ message: String) {
|
||||
log(.debug, message)
|
||||
}
|
||||
|
||||
func info(_ message: String) {
|
||||
log(.info, message)
|
||||
}
|
||||
|
||||
func warning(_ message: String) {
|
||||
log(.warning, message)
|
||||
}
|
||||
|
||||
func error(_ message: String) {
|
||||
log(.error, message)
|
||||
}
|
||||
|
||||
func spam(_ message: String) {
|
||||
log(.spam, message)
|
||||
}
|
||||
|
||||
private func shouldLog(_ level: Level) -> Bool {
|
||||
let levels: [Level] = [.debug, .info, .warning, .error, .spam]
|
||||
guard let currentIndex = levels.firstIndex(of: minLevel),
|
||||
let messageIndex = levels.firstIndex(of: level) else {
|
||||
return true
|
||||
}
|
||||
return messageIndex >= currentIndex
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import SwiftDevRant
|
||||
@preconcurrency import KreeRequest
|
||||
|
||||
struct NetworkLogger: Logger, @unchecked Sendable {
|
||||
let enabled: Bool
|
||||
|
||||
func log(_ message: String) {
|
||||
if enabled {
|
||||
print("[NET] \(message)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct Grokii: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "grokii",
|
||||
abstract: "AI-powered assistant bot for devRant",
|
||||
discussion: """
|
||||
Grokii is an intelligent bot that monitors devRant for @mentions and responds
|
||||
using AI. It can answer questions, provide programming help, and engage in
|
||||
developer discussions. Additionally includes spam detection capabilities
|
||||
to help maintain community quality.
|
||||
|
||||
Requires OPENROUTER_API_KEY environment variable for AI functionality.
|
||||
""",
|
||||
version: "2.0.0",
|
||||
subcommands: [Chat.self, Spam.self],
|
||||
defaultSubcommand: Chat.self
|
||||
)
|
||||
}
|
||||
|
||||
struct CommonOptions: ParsableArguments {
|
||||
@Option(name: .shortAndLong, help: "devRant username for bot account")
|
||||
var username: String
|
||||
|
||||
@Option(name: .shortAndLong, help: "devRant password for bot account")
|
||||
var password: String
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Run continuously in daemon mode")
|
||||
var continuous: Bool = false
|
||||
|
||||
@Option(name: .shortAndLong, help: "Polling interval in seconds")
|
||||
var interval: Int = 30
|
||||
|
||||
@Flag(name: .long, help: "Dry run mode - no posting or voting")
|
||||
var dryRun: Bool = false
|
||||
|
||||
@Option(name: .shortAndLong, help: "Path to log file")
|
||||
var logFile: String?
|
||||
|
||||
@Flag(name: .long, help: "Enable verbose debug logging")
|
||||
var debug: Bool = false
|
||||
}
|
||||
|
||||
extension Grokii {
|
||||
struct Chat: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Monitor mentions and respond with AI",
|
||||
discussion: """
|
||||
Monitors devRant notifications for @mentions of the bot account.
|
||||
When mentioned, Grokii extracts the question, gathers context from
|
||||
the rant and comments, and generates an AI response.
|
||||
|
||||
The bot understands programming questions, can explain code concepts,
|
||||
and engages naturally in developer discussions.
|
||||
"""
|
||||
)
|
||||
|
||||
@OptionGroup var options: CommonOptions
|
||||
|
||||
@Option(name: .long, help: "OpenRouter model ID")
|
||||
var model: String = "x-ai/grok-code-fast-1"
|
||||
|
||||
@Option(name: .long, help: "Custom system prompt for AI personality")
|
||||
var systemPrompt: String?
|
||||
|
||||
@Option(name: .long, help: "Maximum tokens in AI response")
|
||||
var maxTokens: Int = 500
|
||||
|
||||
@Option(name: .long, help: "AI temperature (0.0-2.0)")
|
||||
var temperature: Double = 0.7
|
||||
|
||||
mutating func run() async throws {
|
||||
let logger = BotLogger(
|
||||
logPath: options.logFile,
|
||||
logToConsole: true,
|
||||
minLevel: options.debug ? .debug : .info
|
||||
)
|
||||
|
||||
await logger.info("Grokii v2.0.0 - AI Chat Mode")
|
||||
await logger.info("Model: \(model)")
|
||||
|
||||
guard let apiKey = ProcessInfo.processInfo.environment["OPENROUTER_API_KEY"] else {
|
||||
await logger.error("OPENROUTER_API_KEY environment variable not set")
|
||||
await logger.error("Get your API key at: https://openrouter.ai/keys")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
await logger.info("Authenticating as @\(options.username)...")
|
||||
|
||||
let networkLogger = NetworkLogger(enabled: options.debug)
|
||||
let api = DevRantRequest(requestLogger: networkLogger, ignoreCertificateErrors: true)
|
||||
|
||||
let token: AuthToken
|
||||
do {
|
||||
token = try await api.logIn(username: options.username, password: options.password)
|
||||
await logger.info("Authentication successful")
|
||||
} catch {
|
||||
await logger.error("Authentication failed: \(error)")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
let ai = OpenRouterClient(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
systemPrompt: systemPrompt,
|
||||
maxTokens: maxTokens,
|
||||
temperature: temperature
|
||||
)
|
||||
|
||||
let bot = MentionBot(
|
||||
api: api,
|
||||
token: token,
|
||||
ai: ai,
|
||||
logger: logger,
|
||||
botUsername: options.username,
|
||||
dryRun: options.dryRun
|
||||
)
|
||||
|
||||
if options.continuous {
|
||||
await logger.info("Running in continuous mode (interval: \(options.interval)s)")
|
||||
}
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
await bot.run(continuous: options.continuous, intervalSeconds: options.interval)
|
||||
|
||||
let processedCount = await bot.getStats()
|
||||
await logger.info("Session complete. Processed \(processedCount) mentions.")
|
||||
} onCancel: {
|
||||
Task {
|
||||
await logger.info("Received shutdown signal. Exiting gracefully...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Spam: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Detect and downvote spam content",
|
||||
discussion: """
|
||||
Scans devRant feeds for spam content using pattern matching and
|
||||
behavioral analysis. Detected spam is automatically downvoted
|
||||
with the 'offensive/spam' reason.
|
||||
|
||||
Detection includes: crypto scams, self-promotion, adult content,
|
||||
phishing, gambling, and malware links.
|
||||
"""
|
||||
)
|
||||
|
||||
@OptionGroup var options: CommonOptions
|
||||
|
||||
mutating func run() async throws {
|
||||
let logger = BotLogger(
|
||||
logPath: options.logFile,
|
||||
logToConsole: true,
|
||||
minLevel: options.debug ? .debug : .info
|
||||
)
|
||||
|
||||
await logger.info("Grokii v2.0.0 - Spam Detection Mode")
|
||||
await logger.info("Authenticating as @\(options.username)...")
|
||||
|
||||
let networkLogger = NetworkLogger(enabled: options.debug)
|
||||
let api = DevRantRequest(requestLogger: networkLogger, ignoreCertificateErrors: true)
|
||||
|
||||
let token: AuthToken
|
||||
do {
|
||||
token = try await api.logIn(username: options.username, password: options.password)
|
||||
await logger.info("Authentication successful")
|
||||
} catch {
|
||||
await logger.error("Authentication failed: \(error)")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
let scanner = SpamScanner(
|
||||
api: api,
|
||||
token: token,
|
||||
logger: logger,
|
||||
dryRun: options.dryRun
|
||||
)
|
||||
|
||||
if options.continuous {
|
||||
await logger.info("Running in continuous mode (interval: \(options.interval)s)")
|
||||
}
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
await scanner.run(continuous: options.continuous, intervalSeconds: options.interval)
|
||||
|
||||
let stats = await scanner.getStats()
|
||||
await logger.info("Session complete.")
|
||||
await logger.info("Scanned: \(stats.rants) rants, \(stats.comments) comments")
|
||||
await logger.info("Flagged: \(stats.spammers) suspicious users")
|
||||
} onCancel: {
|
||||
Task {
|
||||
await logger.info("Received shutdown signal. Exiting gracefully...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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 botUsername: String
|
||||
private let dryRun: Bool
|
||||
|
||||
private var processedNotificationIds: Set<String> = []
|
||||
|
||||
init(
|
||||
api: DevRantRequest,
|
||||
token: AuthToken,
|
||||
ai: OpenRouterClient,
|
||||
logger: BotLogger,
|
||||
botUsername: String,
|
||||
dryRun: Bool = false
|
||||
) {
|
||||
self.api = api
|
||||
self.token = token
|
||||
self.ai = ai
|
||||
self.logger = logger
|
||||
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 newMentions = mentions.filter { mention in
|
||||
mention.to.lowercased() == botUsername && !processedNotificationIds.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 {
|
||||
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 !processedNotificationIds.contains(mention.identifiers.guid) else {
|
||||
await logger.debug("Already processed mention: \(mention.identifiers.guid)")
|
||||
return
|
||||
}
|
||||
|
||||
processedNotificationIds.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
|
||||
)
|
||||
|
||||
} 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 postReply(rantId: Int, replyTo: String, response: String) async {
|
||||
let reply = "@\(replyTo) \(response)"
|
||||
|
||||
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 processedNotificationIds.count
|
||||
}
|
||||
}
|
||||
|
||||
enum MentionError: Error {
|
||||
case invalidURL
|
||||
case fetchFailed
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
actor OpenRouterClient {
|
||||
private let apiKey: String
|
||||
private let baseURL = "https://openrouter.ai/api/v1"
|
||||
private let model: String
|
||||
private let systemPrompt: String
|
||||
private let maxTokens: Int
|
||||
private let temperature: Double
|
||||
|
||||
init(
|
||||
apiKey: String,
|
||||
model: String = "x-ai/grok-code-fast-1",
|
||||
systemPrompt: String? = nil,
|
||||
maxTokens: Int = 500,
|
||||
temperature: Double = 0.7
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.maxTokens = maxTokens
|
||||
self.temperature = temperature
|
||||
self.systemPrompt = systemPrompt ?? """
|
||||
You are Grokii, an AI assistant bot on devRant - a community platform for developers to rant about their work, share experiences, and help each other.
|
||||
|
||||
Your personality:
|
||||
- Knowledgeable about programming, software development, DevOps, and tech industry
|
||||
- Friendly but professional, with subtle developer humor
|
||||
- Direct and concise - developers appreciate efficiency
|
||||
- Empathetic to developer frustrations (bugs, legacy code, meetings, etc.)
|
||||
|
||||
Response guidelines:
|
||||
- Keep responses under 800 characters (devRant has limits)
|
||||
- Do NOT use markdown formatting (no **, ##, ```, etc.) - devRant renders plain text only
|
||||
- Use plain text formatting: CAPS for emphasis, dashes for lists
|
||||
- Include code snippets only when essential, keep them short
|
||||
- Be helpful but avoid being preachy or over-explaining
|
||||
|
||||
You have context about the rant and recent discussion. Answer naturally as part of the conversation.
|
||||
"""
|
||||
}
|
||||
|
||||
func chat(message: String, context: String? = nil) async throws -> String {
|
||||
var messages: [[String: String]] = [
|
||||
["role": "system", "content": systemPrompt]
|
||||
]
|
||||
|
||||
if let context = context {
|
||||
messages.append(["role": "system", "content": "Context from the discussion:\n\(context)"])
|
||||
}
|
||||
|
||||
messages.append(["role": "user", "content": message])
|
||||
|
||||
let requestBody: [String: Any] = [
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": maxTokens,
|
||||
"temperature": temperature
|
||||
]
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: requestBody)
|
||||
|
||||
var request = URLRequest(url: URL(string: "\(baseURL)/chat/completions")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("https://github.com/user/grokii", forHTTPHeaderField: "HTTP-Referer")
|
||||
request.setValue("Grokii", forHTTPHeaderField: "X-Title")
|
||||
request.httpBody = jsonData
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OpenRouterError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
throw OpenRouterError.apiError(status: httpResponse.statusCode, message: errorBody)
|
||||
}
|
||||
|
||||
let responseObject = try JSONDecoder().decode(ChatCompletionResponse.self, from: data)
|
||||
|
||||
guard let content = responseObject.choices.first?.message.content else {
|
||||
throw OpenRouterError.noContent
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenRouterError: Error, CustomStringConvertible {
|
||||
case invalidResponse
|
||||
case apiError(status: Int, message: String)
|
||||
case noContent
|
||||
case missingAPIKey
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
return "Invalid response from OpenRouter"
|
||||
case .apiError(let status, let message):
|
||||
return "OpenRouter API error (\(status)): \(message)"
|
||||
case .noContent:
|
||||
return "No content in OpenRouter response"
|
||||
case .missingAPIKey:
|
||||
return "OPENROUTER_API_KEY environment variable not set"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatCompletionResponse: Decodable {
|
||||
let choices: [Choice]
|
||||
|
||||
struct Choice: Decodable {
|
||||
let message: Message
|
||||
}
|
||||
|
||||
struct Message: Decodable {
|
||||
let content: String
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SpamPattern: Sendable {
|
||||
let name: String
|
||||
let regex: NSRegularExpression?
|
||||
let keywords: [String]
|
||||
let minScore: Int
|
||||
|
||||
init(name: String, pattern: String? = nil, keywords: [String] = [], minScore: Int = 1) {
|
||||
self.name = name
|
||||
self.keywords = keywords.map { $0.lowercased() }
|
||||
self.minScore = minScore
|
||||
|
||||
if let pattern = pattern {
|
||||
self.regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
|
||||
} else {
|
||||
self.regex = nil
|
||||
}
|
||||
}
|
||||
|
||||
func matches(_ text: String) -> Bool {
|
||||
let lowerText = text.lowercased()
|
||||
|
||||
if let regex = regex {
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
if regex.firstMatch(in: text, options: [], range: range) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for keyword in keywords {
|
||||
if lowerText.contains(keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct SpamDetector: Sendable {
|
||||
let patterns: [SpamPattern]
|
||||
let repetitionThreshold: Int
|
||||
let minAccountAgeSeconds: TimeInterval
|
||||
let suspiciousPostFrequencySeconds: TimeInterval
|
||||
|
||||
init() {
|
||||
self.patterns = [
|
||||
SpamPattern(
|
||||
name: "crypto_scam",
|
||||
pattern: "(?:bitcoin|crypto|ethereum|btc|eth|nft|web3).*(?:invest|earn|profit|money|free|giveaway)",
|
||||
keywords: ["airdrop", "whitelist", "moon", "100x", "1000x"]
|
||||
),
|
||||
SpamPattern(
|
||||
name: "promotion_spam",
|
||||
pattern: "(?:check out|visit|click|join|subscribe).*(?:my|our|this).*(?:channel|website|link|discord|telegram)",
|
||||
keywords: ["t.me/", "discord.gg/", "bit.ly/", "tinyurl"]
|
||||
),
|
||||
SpamPattern(
|
||||
name: "adult_content",
|
||||
keywords: ["onlyfans", "18+", "xxx", "porn", "nude", "nsfw link"]
|
||||
),
|
||||
SpamPattern(
|
||||
name: "repetitive_chars",
|
||||
pattern: "(.)\\1{10,}",
|
||||
minScore: 2
|
||||
),
|
||||
SpamPattern(
|
||||
name: "excessive_caps",
|
||||
pattern: "^[A-Z\\s!?]{50,}$"
|
||||
),
|
||||
SpamPattern(
|
||||
name: "phishing",
|
||||
pattern: "(?:verify|confirm|update).*(?:account|password|login|credentials)",
|
||||
keywords: ["suspended", "blocked", "verify now", "act now", "urgent"]
|
||||
),
|
||||
SpamPattern(
|
||||
name: "gambling",
|
||||
keywords: ["casino", "betting", "poker online", "slots", "jackpot", "win big"]
|
||||
),
|
||||
SpamPattern(
|
||||
name: "malware_links",
|
||||
pattern: "(?:download|install|get).*(?:free|cracked|hack|keygen|patch)"
|
||||
)
|
||||
]
|
||||
|
||||
self.repetitionThreshold = 3
|
||||
self.minAccountAgeSeconds = 86400 * 7
|
||||
self.suspiciousPostFrequencySeconds = 60
|
||||
}
|
||||
|
||||
func analyze(_ text: String) -> SpamAnalysis {
|
||||
var matchedPatterns: [String] = []
|
||||
var totalScore = 0
|
||||
|
||||
for pattern in patterns {
|
||||
if pattern.matches(text) {
|
||||
matchedPatterns.append(pattern.name)
|
||||
totalScore += pattern.minScore
|
||||
}
|
||||
}
|
||||
|
||||
let linkCount = countLinks(in: text)
|
||||
if linkCount > 3 {
|
||||
matchedPatterns.append("excessive_links")
|
||||
totalScore += linkCount - 2
|
||||
}
|
||||
|
||||
let textLength = text.count
|
||||
if textLength < 10 && linkCount > 0 {
|
||||
matchedPatterns.append("short_with_link")
|
||||
totalScore += 2
|
||||
}
|
||||
|
||||
return SpamAnalysis(
|
||||
isSpam: totalScore >= 2,
|
||||
score: totalScore,
|
||||
matchedPatterns: matchedPatterns
|
||||
)
|
||||
}
|
||||
|
||||
func analyzeUserBehavior(posts: [PostInfo]) -> BehaviorAnalysis {
|
||||
guard posts.count >= 2 else {
|
||||
return BehaviorAnalysis(isSuspicious: false, reasons: [])
|
||||
}
|
||||
|
||||
var reasons: [String] = []
|
||||
|
||||
let sortedPosts = posts.sorted { $0.created < $1.created }
|
||||
var rapidPosts = 0
|
||||
for i in 1..<sortedPosts.count {
|
||||
let timeDiff = sortedPosts[i].created.timeIntervalSince(sortedPosts[i-1].created)
|
||||
if timeDiff < suspiciousPostFrequencySeconds {
|
||||
rapidPosts += 1
|
||||
}
|
||||
}
|
||||
|
||||
if rapidPosts >= 3 {
|
||||
reasons.append("rapid_posting:\(rapidPosts)")
|
||||
}
|
||||
|
||||
let uniqueTexts = Set(posts.map { normalizeText($0.text) })
|
||||
let duplicateRatio = 1.0 - (Double(uniqueTexts.count) / Double(posts.count))
|
||||
if duplicateRatio > 0.5 && posts.count >= 3 {
|
||||
reasons.append("duplicate_content:\(Int(duplicateRatio * 100))%")
|
||||
}
|
||||
|
||||
return BehaviorAnalysis(
|
||||
isSuspicious: !reasons.isEmpty,
|
||||
reasons: reasons
|
||||
)
|
||||
}
|
||||
|
||||
private func countLinks(in text: String) -> Int {
|
||||
let pattern = try? NSRegularExpression(
|
||||
pattern: "https?://[^\\s]+",
|
||||
options: [.caseInsensitive]
|
||||
)
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
return pattern?.numberOfMatches(in: text, options: [], range: range) ?? 0
|
||||
}
|
||||
|
||||
private func normalizeText(_ text: String) -> String {
|
||||
return text.lowercased()
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
struct SpamAnalysis: Sendable {
|
||||
let isSpam: Bool
|
||||
let score: Int
|
||||
let matchedPatterns: [String]
|
||||
}
|
||||
|
||||
struct BehaviorAnalysis: Sendable {
|
||||
let isSuspicious: Bool
|
||||
let reasons: [String]
|
||||
}
|
||||
|
||||
struct PostInfo: Sendable {
|
||||
let id: Int
|
||||
let text: String
|
||||
let created: Date
|
||||
let authorId: Int
|
||||
}
|
||||
Reference in New Issue
Block a user