-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
PR: #1
- Loading branch information
Showing
14 changed files
with
648 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
config.json | ||
bin/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"deno.enable": true | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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개)을 우회하기 위함입니다. | ||
- 설정하지 않아도 일단 스팸봇 정지에는 문제가 없습니다. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export type Config = { | ||
host: string | ||
apiKey: string | ||
dryRun: boolean | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.