From c28721c5233e20c98f6bf0b90be0091b99d8f79b Mon Sep 17 00:00:00 2001 From: nulta Date: Tue, 5 Nov 2024 07:10:38 +0900 Subject: [PATCH] SpamChaser v1.0 PR: #1 --- .gitignore | 2 + .vscode/settings.json | 3 ++ README.md | 34 ++++++++++++- deno.json | 8 +++ src/cfg.ts | 69 ++++++++++++++++++++++++++ src/core/conf.ts | 5 ++ src/core/judge.ts | 109 +++++++++++++++++++++++++++++++++++++++++ src/core/radar.ts | 19 +++++++ src/core/spamChaser.ts | 87 ++++++++++++++++++++++++++++++++ src/core/terminator.ts | 100 +++++++++++++++++++++++++++++++++++++ src/lib/logger.ts | 74 ++++++++++++++++++++++++++++ src/lib/misskeyObj.ts | 53 ++++++++++++++++++++ src/lib/misskeyReq.ts | 74 ++++++++++++++++++++++++++++ src/main.ts | 13 +++++ 14 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 deno.json create mode 100644 src/cfg.ts create mode 100644 src/core/conf.ts create mode 100644 src/core/judge.ts create mode 100644 src/core/radar.ts create mode 100644 src/core/spamChaser.ts create mode 100644 src/core/terminator.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/misskeyObj.ts create mode 100644 src/lib/misskeyReq.ts create mode 100644 src/main.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d18a52f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.json +bin/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b943dbc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} \ No newline at end of file diff --git a/README.md b/README.md index a3b376b..28bbf43 100644 --- a/README.md +++ b/README.md @@ -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개)을 우회하기 위함입니다. +- 설정하지 않아도 일단 스팸봇 정지에는 문제가 없습니다. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..101eaeb --- /dev/null +++ b/deno.json @@ -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" + } +} \ No newline at end of file diff --git a/src/cfg.ts b/src/cfg.ts new file mode 100644 index 0000000..cddf6ea --- /dev/null +++ b/src/cfg.ts @@ -0,0 +1,69 @@ +import { Config } from "./core/conf.ts" + +export function loadConfig() { + let json: Record + 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() + } +} \ No newline at end of file diff --git a/src/core/conf.ts b/src/core/conf.ts new file mode 100644 index 0000000..5f30014 --- /dev/null +++ b/src/core/conf.ts @@ -0,0 +1,5 @@ +export type Config = { + host: string + apiKey: string + dryRun: boolean +} \ No newline at end of file diff --git a/src/core/judge.ts b/src/core/judge.ts new file mode 100644 index 0000000..01b9da5 --- /dev/null +++ b/src/core/judge.ts @@ -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() + + 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) + } + +} \ No newline at end of file diff --git a/src/core/radar.ts b/src/core/radar.ts new file mode 100644 index 0000000..3a8417d --- /dev/null +++ b/src/core/radar.ts @@ -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) + } +} \ No newline at end of file diff --git a/src/core/spamChaser.ts b/src/core/spamChaser.ts new file mode 100644 index 0000000..80e5a82 --- /dev/null +++ b/src/core/spamChaser.ts @@ -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[] = [] + 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) + } +} \ No newline at end of file diff --git a/src/core/terminator.ts b/src/core/terminator.ts new file mode 100644 index 0000000..54e6012 --- /dev/null +++ b/src/core/terminator.ts @@ -0,0 +1,100 @@ +import { MiUser } from "../lib/misskeyObj.ts" +import { MiRequester } from "../lib/misskeyReq.ts" +import { Logger } from "../lib/logger.ts" + +export class Terminator { + private instanceKillCounts = new Map() + private temporaryBlockedInstances = new Map() + private suspendedUserIds = new Set() + + constructor( + private requester: MiRequester, + private dryRun = false, + private canBlockInstance = false, + ) {} + + terminate(user: MiUser) { + this.terminateUser(user) + this.addKillCount(user) + this.shouldBlockInstance(user.host).then(shouldBlock => { + if (shouldBlock) { + this.blockInstance(user.host) + } + }) + } + + private async terminateUser(user: MiUser) { + const acct = `@${user.username}@${user.host}` + if (this.suspendedUserIds.has(user.id)) { + return + } + + Logger.info("Terminating user %c%s", "color: yellow", acct) + if (this.dryRun) { + Logger.info("-> Dry run enabled, skipping.") + this.suspendedUserIds.add(user.id) + return + } + + const notes = await this.requester.getUserNotes(user.id, 10) + for (const note of notes) { + Logger.info("-> Deleted note %c%s", "color: yellow", note.id) + this.requester.deleteNote(note.id) + } + + Logger.info("-> Suspended user %c%s", "color: yellow", acct) + await this.requester.suspendUser(user.id) + this.suspendedUserIds.add(user.id) + } + + private addKillCount(user: MiUser) { + const key = user.host + if (!key) { return } + const count = this.instanceKillCounts.get(key) ?? 0 + this.instanceKillCounts.set(key, count + 1) + } + + private async shouldBlockInstance(host: string | null) { + if (!this.canBlockInstance || !host || this.temporaryBlockedInstances.has(host)) { + return false + } + + const kills = this.instanceKillCounts.get(host) ?? 0 + if (kills < 4) { + return false + } + + const info = await this.requester.getInstanceInfo(host) + const [pub, sub] = [info.followingCount, info.followersCount] + if (info.isBlocked) { + return false + } + + return sub == 0 && [ + kills >= 20, + kills >= 16 && pub <= 8, + kills >= 12 && pub <= 4, + kills >= 8 && pub <= 2, + kills >= 4 && pub == 0, + ].some(Boolean) + } + + private async blockInstance(host: string | null) { + if (!this.canBlockInstance || !host) { + return + } + + Logger.info("Blocking instance %c%s for 8 hours", "color: yellow", host) + if (this.dryRun) { + Logger.info("-> Dry run enabled, skipping.") + return + } + + this.registerBlockedInstance(host, Date.now() + 8 * 60 * 60 * 1000) + await this.requester.blockInstance(host) + } + + private async registerBlockedInstance(instance: string, blockUntil: number) { + // TODO + } +} \ No newline at end of file diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..cbdbd8a --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,74 @@ +// deno-lint-ignore-file no-explicit-any + +export class Logger { + private static lastDatestamp = "" + + private static getTimezoneStr() { + // e.g. `-540` -> `UTC+9` (which is Asia/Seoul) + const tz = new Date().getTimezoneOffset() + const tzAbs = Math.abs(tz) + const tzSign = tz <= 0 ? "+" : "-" + const tzHr = Math.floor(tzAbs / 60) + const tzMin = tzAbs % 60 + + if (tzMin == 0) { + return `UTC${tzSign}${tzHr}` + } else { + return `UTC${tzSign}${tzHr}:${tzMin}` + } + } + + private static getDatestamp() { + const d = new Date() + const tz = this.getTimezoneStr() + + const [yyyy, mm, dd] = [ + d.getFullYear(), d.getMonth(), d.getDate(), + ].map(x => x.toString().padStart(2, "0")) + + return `${yyyy}-${mm}-${dd} (${tz})` + } + + private static getTimestamp() { + const d = new Date() + + const [hour, min, sec] = [ + d.getHours(), d.getMinutes(), d.getSeconds(), + ].map(x => x.toString().padStart(2, "0")) + + return `${hour}:${min}:${sec}` + } + + private static updateDatestamp() { + const datestamp = this.getDatestamp() + if (this.lastDatestamp != datestamp) { + this.lastDatestamp = datestamp + console.log("%c== %s ==", "color: grey", datestamp) + } + } + + private static display(color: string, prefix: string, data: any[]) { + this.updateDatestamp() + + const prefixData = [`color: grey`, this.getTimestamp(), `color: ${color}`, prefix] + const [fmtStr, ...fmtData] = data + if (typeof fmtStr == "string") { + console.log("%c%s %c%s %c" + fmtStr, ...prefixData, "color: initial", ...fmtData) + } else { + console.log("%c%s %c%s " + fmtStr, ...prefixData, ...data) + } + } + + + static info(...fmt: any[]) { + this.display("#0fc", "[info]", fmt) + } + + static error(...fmt: any[]) { + this.display("#f06", "[err] ", fmt) + } + + static warn(...fmt: any[]) { + this.display("#f80", "[warn]", fmt) + } +} diff --git a/src/lib/misskeyObj.ts b/src/lib/misskeyObj.ts new file mode 100644 index 0000000..d5ec087 --- /dev/null +++ b/src/lib/misskeyObj.ts @@ -0,0 +1,53 @@ +export type MiUser = { + id: string + name: string + username: string + host: string | null + isSuspended: boolean + + avatarUrl: string + avatarBlurhash: string + avatarDecorations?: string[] + isCat: boolean + isBot: boolean + description: string + + createdAt: string + updatedAt: string | null + followersCount: number + followingCount: number + notesCount: number +} + +export type MiNote = { + id: string + text: string | null + cw: string | null + mentions: string[] // userid[] + + userId: string + user: MiUser + + visibility: "public" | "home" | "followers" | "specified" + repliesCount: number + renoteCount: number + reactionsCount: number + + createdAt: string + reply: MiNote | null + renote: MiNote | null +} + +export type MiInstance = { + id: string + host: string + name: string + + followersCount: number + followingCount: number + usersCount: number + + isBlocked: boolean + isSilenced: boolean + moderationNote: string | null +} \ No newline at end of file diff --git a/src/lib/misskeyReq.ts b/src/lib/misskeyReq.ts new file mode 100644 index 0000000..20b8aba --- /dev/null +++ b/src/lib/misskeyReq.ts @@ -0,0 +1,74 @@ +import { Logger } from "./logger.ts"; +import { MiInstance, MiNote, MiUser } from "./misskeyObj.ts" + +export class MiRequester { + constructor( + private host: string, + private token: string + ) {} + + async getUser(userId: string): Promise { + return await this.fetch("users/show", { userId }) + } + + async getNote(noteId: string): Promise { + return await this.fetch("notes/show", { noteId }) + } + + async getUserNotes(userId: string, limit: number): Promise { + return await this.fetch("users/notes", { userId, limit, allowPartial: true }) + } + + async getUserFollowers(userId: string, limit: number, onlyLocal: boolean): Promise { + if (onlyLocal) { + return await this.fetch("users/followers", { userId, limit, host: null }) + } else { + return await this.fetch("users/followers", { userId, limit }) + } + } + + async getRecentUsers(limit: number): Promise { + return await this.fetch("admin/show-users", { + sort: "+updatedAt", + state: "available", + origin: "remote", + username: "", + hostname: "", + limit: limit, + allowPartial: true, + }) + } + + async getInstanceInfo(host: string): Promise { + return await this.fetch("federation/show-instance", { host }) + } + + async suspendUser(userId: string): Promise { + await this.fetch("admin/suspend-user", { userId }, true).catch((reason) => { + Logger.warn("Failed to suspend user %s: %s", userId, reason) + }) + } + + async deleteNote(noteId: string): Promise { + await this.fetch("notes/delete", { noteId }, true).catch((reason) => { + Logger.warn("Failed to delete note %s: %s", noteId, reason) + }) + } + + // TODO + async blockInstance(host: string): Promise {} + + // TODO + async unblockInstance(host: string): Promise {} + + private async fetch(path: string, body: Record, ignoreRes = false): Promise { + body.i = this.token + return await fetch("https://" + this.host + "/api/" + path, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }).then(res => !ignoreRes ? res.json() : res) + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..afd9587 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,13 @@ +import { loadConfig } from "./cfg.ts" +import { SpamChaser } from "./core/spamChaser.ts" + +console.log(" == ") +console.log(" == SpamChaser") +console.log(" == github.com/nulta/spam-chaser") +console.log(" == ") +console.log(" -- Version: 1.0") +console.log("") + +const config = loadConfig() +const spamChaser = new SpamChaser(config) +await spamChaser.begin()