Skip to content

Commit

Permalink
SpamChaser v1.0
Browse files Browse the repository at this point in the history
PR: #1
  • Loading branch information
nulta authored Nov 4, 2024
1 parent 3efa16a commit c28721c
Show file tree
Hide file tree
Showing 14 changed files with 648 additions and 2 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
config.json
bin/
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"deno.enable": true
}
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,32 @@
# spam-chaser
Automatically chase and terminate spam posts on Misskey.
# 스팸 추격기 v1.0
스팸 요격기(`fedi_findandkillspam`)의 계승작입니다.

- Misskey 전용입니다. 아직은.
- 노트의 공개범위에 관계없이 스팸을 **추격**합니다.
- 멘션 개수에 관계없이 스팸을 **추격**합니다.
- 기타 노트의 내용 변화에 관계없이 스팸을 **추격**해서 자동으로 정지합니다.

## 사용 방법
1. [여기서 실행 파일을 내려받습니다.](https://github.com/nulta/spam-chaser/releases)
2. 실행하고, 화면의 지시에 따라 최초 설정을 합니다.
3. 설정을 마치면 추격기가 자동으로 시작됩니다.

### API key 발급 방법
1. AiScript 스크래치패드(`/scratchpad`)를 엽니다.
2. 아래의 AiScript를 실행하면 토큰이 발급됩니다.
```
<: "Token is:"
<: Mk:api("miauth/gen-token" {
session: null,
name: 'SpamChaser',
description: 'github.com/nulta/spam-chaser',
permission: ['read:admin:show-user', 'write:notes', 'write:admin:suspend-user', 'read:account'],
}).token
```

**주의:** 스팸 요격기(`fedi_findandkillspam`)와는 **다른** 권한을 사용합니다.\
따라서 이전에 요격기에서 사용하던 API 키가 있어도, 그대로 재사용할 수는 없습니다.

이후, 가능하다면 제어판의 **역할 설정**에서 본인의 **요청 빈도 제한**을 0%로 설정해 주세요.
- 노트 삭제 개수 제한(시간당 300개)을 우회하기 위함입니다.
- 설정하지 않아도 일단 스팸봇 정지에는 문제가 없습니다.
8 changes: 8 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"tasks": {
"start": "deno run --allow-net --allow-read=config.json --allow-write=config.json src/main.ts",
"build-windows": "deno compile --target=x86_64-pc-windows-msvc --output=bin/spam-chaser.exe --allow-net --allow-read=config.json --allow-write=config.json src/main.ts",
"build-linux": "deno compile --target=x86_64-unknown-linux-gnu --output=bin/spam-chaser --allow-net --allow-read=config.json --allow-write=config.json src/main.ts",
"build": "deno task build-windows && deno task build-linux"
}
}
69 changes: 69 additions & 0 deletions src/cfg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Config } from "./core/conf.ts"

export function loadConfig() {
let json: Record<string, unknown>
try {
json = JSON.parse(Deno.readTextFileSync("config.json"))
assertString(json.host)
assertString(json.apiKey)
assertBoolean(json.dryRun)
return json as Config
} catch {
return createConfig()
}
}

function createConfig() {
const config: Config = {
host: question("[?] Instance URL: https://"),
apiKey: question("[?] API key:"),
dryRun: false,
}

if (config.host.endsWith("!")) {
config.host = config.host.slice(0, -1)
config.dryRun = true
}

if (config.host.endsWith("/")) {
config.host = config.host.slice(0, -1)
}

console.log("[*] Configuration:")
console.table(config)
if (!confirm("Is this configuration correct?")) {
return createConfig()
}

Deno.writeTextFileSync("config.json", JSON.stringify(config, null, 4))
console.log("[*] Configuration saved to config.json.")

return config
}

function question(message: string) {
while (true) {
let answer = prompt(message)
if (answer == null) {
console.error("[!] Couldn't read from stdin!")
Deno.exit(1)
}

answer = answer.trim()
if (answer != "") {
return answer
}
}
}

function assertString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error()
}
}

function assertBoolean(value: unknown): asserts value is boolean {
if (typeof value !== "boolean") {
throw new Error()
}
}
5 changes: 5 additions & 0 deletions src/core/conf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type Config = {
host: string
apiKey: string
dryRun: boolean
}
109 changes: 109 additions & 0 deletions src/core/judge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Logger } from "../lib/logger.ts";
import { MiUser, MiNote } from "../lib/misskeyObj.ts"
import { MiRequester } from "../lib/misskeyReq.ts"

export class Judge {
private phase2Cache = new Set<string>()

constructor(
private requester: MiRequester
) {}

async isBadUser(user: MiUser) {
if (user.isSuspended) {
return false
}

if (!this.checkUserPhase1(user)) {
return false
}

if (!await this.checkUserPhase2(user)) {
return false
}

if (!await this.checkUserNotes(user)) {
return false
}

return true
}

private checkUserPhase1(user: MiUser) {
if (this.ageInDays(user) > 10) {
return false
}

const susFactor = [
user.followersCount < 20,
this.ageInDays(user) < 3,
!user.avatarBlurhash,!user.name||user.username ==
user.name,!user.description,
this.textHasHighEntropy(user.username),
this.textHasHighEntropy(user.username)>1,
].filter(Boolean).length

return susFactor >= 5
}

private async checkUserPhase2(user: MiUser) {
if (this.phase2Cache.has(user.id)) {
return false
}

const [localFollowers, followers] = await Promise.all([
this.requester.getUserFollowers(user.id, 3, true),
this.requester.getUserFollowers(user.id, 50, false),
])

if (localFollowers.length >= 1) {
this.phase2Cache.add(user.id)
return false
}

const trustedFollowers = followers.filter(f => this.ageInDays(f) < 15)
if (trustedFollowers.length >= 2) {
this.phase2Cache.add(user.id)
return false
}

return true
}

private async checkUserNotes(user: MiUser) {
const notes = await this.requester.getUserNotes(user.id, 10)
let recentNotes = notes
.filter(n => this.ageInDays(n) < 3/24)

// then it's invisible
if (recentNotes.length == 0) { return true }

recentNotes = recentNotes.filter(n => !n.renote)
const avgNoteScore = recentNotes
.map(n => this.noteHarmfulness(n))
.reduce((a, b) => a + b, 0) / (recentNotes.length || 1)

return avgNoteScore >= 2
}

private noteHarmfulness(note: MiNote) {
const mentions = note.mentions.length
let score = [0, 2, 3, 4, 5][mentions] ?? 5
if (note.reply) { score /= 2 }
return score
}

private textHasHighEntropy(text: string) {
// todo
const v1 = text != "" && !!Number(text.length * (Math.cos(Math.PI * 2)) == Math.sign(text.length) * Math.log10(100) * 5 % (2<<9))
const v2 = Array.from(text).some(Number) && text.valueOf().toLowerCase() == text.valueOf().slice() && /^[a-z0-9]+$/.test(text)
return Number(v1) + Number(v1 && v2)
}

private ageInDays(obj: MiUser | MiNote) {
const createdAt = new Date(obj.createdAt).getTime()
const now = new Date().getTime()
return (now - createdAt) / (1000 * 60 * 60 * 24)
}

}
19 changes: 19 additions & 0 deletions src/core/radar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MiRequester } from "../lib/misskeyReq.ts"

/**
* Find nearby users.
*/
export class Radar {
constructor(
private requester: MiRequester
) {}

async find(limit = 20) {
const users = await this.fetch(limit)
return users
}

private async fetch(limit: number) {
return await this.requester.getRecentUsers(limit)
}
}
87 changes: 87 additions & 0 deletions src/core/spamChaser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Config } from "./conf.ts"
import { MiRequester } from "../lib/misskeyReq.ts"
import { Logger } from "../lib/logger.ts"

import { Radar } from "./radar.ts"
import { Judge } from "./judge.ts"
import { Terminator } from "./terminator.ts"

/**
* SpamChaser Core.
*/
export class SpamChaser {
private readonly tickDelay = 1000
private readonly reportDelay = 60 * 1000

private config: Config
private requester: MiRequester
private radar: Radar
private judge: Judge
private terminator: Terminator
private processedUsers = 0

constructor(config: Config) {
this.config = config
this.requester = new MiRequester(config.host, config.apiKey)
this.radar = new Radar(this.requester)
this.judge = new Judge(this.requester)
this.terminator = new Terminator(this.requester, config.dryRun, false)
}

async begin() {
Logger.info("Starting SpamChaser.")
Logger.info("Instance: %c%s", "color: yellow", this.config.host)

await this.tick(100)
Logger.info("Initial tick successful.")

this.reportLoop()
await this.tickLoop()
}

private async tickLoop() {
while (true) {
await new Promise(resolve => setTimeout(resolve, this.tickDelay))

try {
await this.tick()
} catch (e) {
Logger.error("Error occured in tick loop!")
Logger.error(e)
Logger.info("Waiting 20 seconds before the next tick.")
await new Promise(resolve => setTimeout(resolve, 20 * 1000))
}
}
}

private async reportLoop() {
while (true) {
await new Promise(resolve => setTimeout(resolve, 10 * 1000))
Logger.info("Checked %c%d users%c.", "color: yellow", this.processedUsers, "color: unset")
this.processedUsers = 0
}
}

private async tick(processUsers = 20) {
const promises: Promise<unknown>[] = []
const users = await this.radar.find(processUsers)
this.processedUsers += users.length

if (!Array.isArray(users)) {
// what??
Logger.warn("Got invalid response from radar:")
Logger.warn(users)
return
}

for (const user of users) {
promises.push(this.judge.isBadUser(user).then(isBad => {
if (isBad) {
this.terminator.terminate(user)
}
}))
}

await Promise.all(promises)
}
}
Loading

0 comments on commit c28721c

Please sign in to comment.