feat: add Grokii AI-powered devRant bot with chat and spam detection modes
This commit is contained in:
commit
4554a8a58f
10
CHANGELOG.md
Normal file
10
CHANGELOG.md
Normal 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
221
Package.resolved
Normal 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
27
Package.swift
Normal 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
386
README.md
Normal 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.
|
||||
82
Sources/Grokii/Logger.swift
Normal file
82
Sources/Grokii/Logger.swift
Normal 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
214
Sources/Grokii/Main.swift
Normal 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
Sources/Grokii/MentionBot.swift
Normal file
225
Sources/Grokii/MentionBot.swift
Normal 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
Sources/Grokii/OpenRouterClient.swift
Normal file
126
Sources/Grokii/OpenRouterClient.swift
Normal 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
Sources/Grokii/SpamActions.swift
Normal file
214
Sources/Grokii/SpamActions.swift
Normal 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
Sources/Grokii/SpamPattern.swift
Normal file
189
Sources/Grokii/SpamPattern.swift
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user