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:
2025-12-20 19:15:08 +00:00
commit 60d325a970
10 changed files with 1694 additions and 0 deletions
+82
View File
@@ -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
}
}
+214
View File
@@ -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...")
}
}
}
}
}
+225
View File
@@ -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
}
+126
View File
@@ -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
}
}
+214
View File
@@ -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)
}
}
+189
View File
@@ -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
}