feat: add Grokii AI-powered devRant bot with chat and spam detection modes

This commit is contained in:
retoor 2025-12-20 20:15:08 +01:00
commit 4554a8a58f
10 changed files with 1694 additions and 0 deletions

10
CHANGELOG.md Normal file
View File

@ -0,0 +1,10 @@
# Changelog
## Version 0.1.0 - 2025-12-20
Adds Grokii, an AI-powered devRant bot that supports chat interactions and spam detection modes. Developers can now integrate this bot to enhance community engagement and moderation on devRant platforms.
**Changes:** 9 files, 1684 lines
**Languages:** Markdown (386 lines), Other (1298 lines)

221
Package.resolved Normal file
View File

@ -0,0 +1,221 @@
{
"pins" : [
{
"identity" : "async-http-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "5dd84c7bb48b348751d7bbe7ba94a17bafdcef37",
"version" : "1.30.2"
}
},
{
"identity" : "kreerequest",
"kind" : "remoteSourceControl",
"location" : "https://github.com/WilhelmOks/KreeRequest",
"state" : {
"revision" : "7d3f83cf90e8bbd080b69a2606d5fe72716d0746",
"version" : "2.2.0"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms.git",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.0"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-certificates",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130",
"version" : "1.17.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
}
},
{
"identity" : "swift-distributed-tracing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-distributed-tracing.git",
"state" : {
"revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97",
"version" : "1.3.1"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b",
"version" : "1.6.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
"version" : "1.5.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
"version" : "1.8.0"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "a1605a3303a28e14d822dec8aaa53da8a9490461",
"version" : "2.92.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "1c90641b02b6ab47c6d0db2063a12198b04e83e2",
"version" : "1.31.2"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80",
"version" : "1.39.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "173cc69a058623525a58ae6710e2f5727c663793",
"version" : "2.36.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "60c3e187154421171721c1a38e800b390680fb5d",
"version" : "1.26.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-service-context",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-service-context.git",
"state" : {
"revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6",
"version" : "1.2.1"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
"state" : {
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.1"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
"version" : "1.6.3"
}
},
{
"identity" : "swiftdevrantsdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/WilhelmOks/SwiftDevRantSDK",
"state" : {
"revision" : "ca8567d79885b2c1266fabfc8b35c57114541349",
"version" : "2.2.1"
}
}
],
"version" : 2
}

27
Package.swift Normal file
View File

@ -0,0 +1,27 @@
// swift-tools-version: 5.9
// retoor <retoor@molodetz.nl>
import PackageDescription
let package = Package(
name: "Grokii",
platforms: [
.macOS(.v13)
],
dependencies: [
.package(url: "https://github.com/WilhelmOks/SwiftDevRantSDK", from: "2.0.0"),
.package(url: "https://github.com/WilhelmOks/KreeRequest", from: "2.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0")
],
targets: [
.executableTarget(
name: "grokii",
dependencies: [
.product(name: "SwiftDevRant", package: "SwiftDevRantSDK"),
.product(name: "KreeRequest", package: "KreeRequest"),
.product(name: "ArgumentParser", package: "swift-argument-parser")
],
path: "Sources/Grokii"
)
]
)

386
README.md Normal file
View File

@ -0,0 +1,386 @@
# Grokii
Author: retoor <retoor@molodetz.nl>
Grokii is an AI-powered assistant bot for the devRant platform. It monitors for @mentions and responds intelligently using advanced language models via OpenRouter. The bot also includes spam detection and moderation capabilities for community maintenance.
## Table of Contents
- [Overview](#overview)
- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [Architecture](#architecture)
- [AI Configuration](#ai-configuration)
- [Spam Detection](#spam-detection)
- [Deployment](#deployment)
- [API Reference](#api-reference)
- [License](#license)
## Overview
Grokii operates as a devRant community bot that:
1. Fetches @mentions from external JSON feed (https://static.molodetz.nl/dr.mentions.json)
2. Extracts questions and context from conversations
3. Generates intelligent responses using Grok AI (or other models via OpenRouter)
4. Posts replies back to the conversation
5. Optionally scans for and downvotes spam content
The bot is designed for the devRant developer community, understanding programming concepts, developer culture, and the platform's informal, rant-friendly atmosphere.
## Features
### AI Chat Mode (Default)
- Real-time mention monitoring with configurable polling intervals
- Context-aware responses using rant content and comment history
- Powered by x]AI Grok models via OpenRouter
- Customizable AI personality through system prompts
- Adjustable response parameters (temperature, max tokens)
- Automatic @mention reply formatting
- Dry-run mode for testing without posting
### Spam Detection Mode
- Pattern-based content analysis
- Behavioral anomaly detection
- Automatic downvoting with spam classification
- Detection categories:
- Cryptocurrency and investment scams
- Self-promotion and link spam
- Adult/NSFW content
- Phishing attempts
- Gambling promotions
- Malware and piracy links
- Excessive capitalization
- Repetitive character abuse
- Suspicious posting frequency
- Duplicate content detection
### Operational Features
- Continuous daemon mode for 24/7 operation
- Comprehensive logging with file output option
- Debug mode for troubleshooting
- Graceful shutdown handling
- Session statistics reporting
## Requirements
### System Requirements
- Swift 5.9 or later
- macOS 13+ or Linux (Ubuntu 20.04+, Debian 11+)
- Network access to devRant API and OpenRouter API
### External Services
- devRant account for the bot
- OpenRouter API key (free tier available)
## Installation
### From Source
```bash
git clone https://github.com/user/grokii.git
cd grokii
swift build -c release
sudo cp .build/release/grokii /usr/local/bin/
```
### Development Build
```bash
swift build
.build/debug/grokii --help
```
## Configuration
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENROUTER_API_KEY` | Yes (chat mode) | API key from openrouter.ai |
### Obtaining an OpenRouter API Key
1. Visit https://openrouter.ai
2. Create an account or sign in
3. Navigate to https://openrouter.ai/keys
4. Generate a new API key
5. Add credits or use free tier models
## Usage
### Quick Start
```bash
export OPENROUTER_API_KEY="sk-or-v1-..."
grokii chat -u YOUR_BOT_USERNAME -p YOUR_BOT_PASSWORD --continuous
```
### Chat Mode Commands
```bash
grokii chat -u USERNAME -p PASSWORD --dry-run
grokii chat -u USERNAME -p PASSWORD --continuous --interval 30
grokii chat -u USERNAME -p PASSWORD -c --model "anthropic/claude-3.5-sonnet"
grokii chat -u USERNAME -p PASSWORD -c --temperature 0.9 --max-tokens 300
grokii chat -u USERNAME -p PASSWORD -c -l /var/log/grokii.log
```
### Spam Mode Commands
```bash
grokii spam -u USERNAME -p PASSWORD --dry-run
grokii spam -u USERNAME -p PASSWORD --continuous --interval 300
grokii spam -u USERNAME -p PASSWORD -c --debug -l spam.log
```
### Command Reference
#### Global Options
| Option | Short | Description | Default |
|--------|-------|-------------|---------|
| `--username` | `-u` | devRant bot account username | Required |
| `--password` | `-p` | devRant bot account password | Required |
| `--continuous` | `-c` | Run in daemon mode | `false` |
| `--interval` | `-i` | Polling interval (seconds) | `30` |
| `--dry-run` | | Test mode - no actions taken | `false` |
| `--log-file` | `-l` | Path to log file | None |
| `--debug` | | Enable verbose logging | `false` |
#### Chat Mode Options
| Option | Description | Default |
|--------|-------------|---------|
| `--model` | OpenRouter model identifier | `x-ai/grok-3-fast-beta` |
| `--system-prompt` | Custom AI personality prompt | Built-in |
| `--max-tokens` | Maximum response length | `500` |
| `--temperature` | AI creativity (0.0-2.0) | `0.7` |
## Architecture
```
Sources/Grokii/
├── Main.swift # CLI entry point, argument parsing, subcommands
├── MentionBot.swift # Notification monitoring, mention processing
├── OpenRouterClient.swift # OpenRouter API integration
├── SpamActions.swift # Voting actions, spam scanner
├── SpamPattern.swift # Spam detection patterns, behavioral analysis
└── Logger.swift # Logging infrastructure
```
### Component Overview
#### Main.swift
Entry point using Swift Argument Parser. Defines the `grokii` command with `chat` and `spam` subcommands. Handles authentication and bot initialization.
#### MentionBot.swift
Core mention-response logic:
- Fetches mentions from external JSON feed (https://static.molodetz.nl/dr.mentions.json)
- Filters mentions where target matches bot username
- Uses guid from JSON for deduplication
- Extracts question text by removing bot @mention
- Builds context from rant and recent comments
- Coordinates with OpenRouterClient for AI response
- Posts formatted reply via devRant API
#### OpenRouterClient.swift
HTTP client for OpenRouter API:
- Implements chat completions endpoint
- Manages authentication headers
- Configurable model, temperature, max tokens
- Built-in system prompt for Grokii personality
- Error handling with descriptive messages
#### SpamActions.swift
Moderation utilities:
- `SpamActions`: Voting operations (upvote/downvote)
- `SpamScanner`: Feed scanning and spam detection
- Integrates with SpamDetector for analysis
- Tracks processed content to avoid duplicates
#### SpamPattern.swift
Spam detection engine:
- `SpamPattern`: Individual detection rules
- `SpamDetector`: Pattern orchestration
- Regex and keyword matching
- Behavioral analysis (posting frequency, duplicates)
- Configurable scoring thresholds
#### Logger.swift
Async-safe logging:
- Multiple log levels (debug, info, warning, error, spam)
- Console and file output
- Timestamped messages
- Actor-based thread safety
## AI Configuration
### Default Model
Grokii uses `x-ai/grok-3-fast-beta` by default - xAI's fast inference model optimized for quick, accurate responses.
### Alternative Models
Any OpenRouter-supported model can be used:
```bash
grokii chat -u USER -p PASS --model "anthropic/claude-3.5-sonnet"
grokii chat -u USER -p PASS --model "openai/gpt-4o"
grokii chat -u USER -p PASS --model "meta-llama/llama-3.1-70b-instruct"
grokii chat -u USER -p PASS --model "google/gemini-pro-1.5"
```
### Temperature Settings
| Value | Behavior |
|-------|----------|
| 0.0-0.3 | Focused, deterministic responses |
| 0.4-0.7 | Balanced creativity and accuracy |
| 0.8-1.2 | Creative, varied responses |
| 1.3-2.0 | Highly creative, potentially unpredictable |
### Custom System Prompts
Override the default Grokii personality:
```bash
grokii chat -u USER -p PASS --system-prompt "You are a sarcastic senior developer who has seen it all. Keep responses short and witty."
```
## Spam Detection
### Detection Patterns
| Pattern | Triggers |
|---------|----------|
| Crypto Scam | bitcoin, ethereum, invest, airdrop, 100x gains |
| Promotion | check out my channel, join discord, bit.ly links |
| Adult | onlyfans, nsfw, explicit content references |
| Phishing | verify account, confirm password, urgent action |
| Gambling | casino, betting, jackpot, slots |
| Malware | cracked software, keygen, free download hacks |
| Format Abuse | EXCESSIVE CAPS, repeated characters |
### Behavioral Analysis
- Rapid posting: Multiple posts within 60 seconds
- Duplicate content: Same text posted repeatedly
- Link spam: More than 3 URLs in single post
- Short posts with links: Minimal text + URL
### Scoring System
Each matched pattern contributes to a spam score. Content with score >= 2 is flagged as spam and downvoted with the "offensive/spam" reason.
## Deployment
### Systemd Service (Linux)
Create `/etc/systemd/system/grokii.service`:
```ini
[Unit]
Description=Grokii devRant Bot
After=network.target
[Service]
Type=simple
User=grokii
Environment=OPENROUTER_API_KEY=sk-or-v1-...
ExecStart=/usr/local/bin/grokii chat -u BOT_USER -p BOT_PASS -c -i 30 -l /var/log/grokii/chat.log
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl enable grokii
sudo systemctl start grokii
sudo journalctl -u grokii -f
```
### Docker
```dockerfile
FROM swift:5.9-jammy
WORKDIR /app
COPY . .
RUN swift build -c release
CMD [".build/release/grokii", "chat", "-u", "$BOT_USER", "-p", "$BOT_PASS", "-c"]
```
### Process Manager (PM2)
```bash
pm2 start "grokii chat -u USER -p PASS -c" --name grokii
pm2 save
pm2 startup
```
## API Reference
### External Mentions Feed
- Endpoint: `GET https://static.molodetz.nl/dr.mentions.json`
- Format: JSON array of mention objects
- Fields: from, to, content, rant_id, comment_id, created_time, identifiers.guid
### devRant API (via SwiftDevRantSDK)
- Authentication: Username/password login
- Rants: Fetch rant content and comments
- Comments: Post replies to rants
- Voting: Upvote/downvote with reason
### OpenRouter API
- Endpoint: `POST https://openrouter.ai/api/v1/chat/completions`
- Authentication: Bearer token
- Request: Messages array with system/user roles
- Response: Chat completion with content
## Dependencies
| Package | Purpose |
|---------|---------|
| [SwiftDevRantSDK](https://github.com/WilhelmOks/SwiftDevRantSDK) | devRant API client |
| [swift-argument-parser](https://github.com/apple/swift-argument-parser) | CLI framework |
| [KreeRequest](https://github.com/WilhelmOks/KreeRequest) | HTTP networking |
## License
MIT License
Copyright (c) 2025 retoor
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

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
Sources/Grokii/Main.swift Normal file
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...")
}
}
}
}
}

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
}

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

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

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
}