Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/banhammer-bot/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@allinbits/banhammer-bot",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc --noEmit && tsdown",
"dev": "tsx watch src/index.ts",
"lint": "eslint",
"start": "node dist/index.js"
},
"dependencies": {
"@cosmjs/proto-signing": "^0.36.0",
"@cosmjs/stargate": "^0.36.0",
"node-telegram-bot-api": "^0.66.0"
}
}
133 changes: 133 additions & 0 deletions packages/banhammer-bot/src/BanhammerBot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import TelegramBot from "node-telegram-bot-api";

import {
BanhammerDB,
} from "./db.ts";

export type BanhammerBotOptions = {
token: string
owners: string[]
mnemonic: string
databasePath: string
};

type Command = {
command: string
description: string
regex: RegExp
function: (msg: TelegramBot.Message, match: RegExpExecArray | null) => void
usage: string
ownerOnly?: boolean
};

export class BanhammerBot {
private bot: TelegramBot;
private owners: Set<string>;
private commands: Command[];
private banhammerDB: BanhammerDB;

constructor(options: BanhammerBotOptions) {
this.bot = new TelegramBot(options.token, {
polling: true,
});
this.owners = new Set(options.owners);
this.banhammerDB = new BanhammerDB(options.databasePath);

this.commands = [
{
command: "ban",
description: "Ban a user (owners only)",
regex: /^\/ban(@.*)? (.*)/,
ownerOnly: true,
usage: "Usage: /ban <reason> as reply to a user's message (owners only)",
function: this.onBanUser,
},
{
command: "add",
description: "Add Chat to federated ban list (owners only)",
regex: /^\/add(@.*)?/,
ownerOnly: true,
usage: "Usage: /add (owners only)",
function: this.onAddChat,
},
];
}

public async start(): Promise<void> {
await this.registerCommands();

this.commands.forEach(cmd => this.bot.onText(cmd.regex, async (msg: TelegramBot.Message, match: RegExpExecArray | null) => {
console.log(`[INFO]: msg received ${msg.text} from ${msg.from?.username ?? msg.from?.id?.toString()}`);

if (cmd.ownerOnly && !this.isOwner(msg.from?.username ?? "")) {
this.bot.sendMessage(msg.chat.id, "This command is only available to owners", {
protect_content: true,
});
return;
}

try {
if (!match) {
throw new Error("Message don't match regex");
}

await cmd.function(msg, match);
}
catch (error) {
console.error(`[ERROR] ${cmd.command}: ${msg.text}`, error);
this.bot.sendMessage(msg.chat.id, `${cmd.usage}\n\nError: ${(error as Error).message}`);
}
}));
}

private isOwner = (username?: string | null): boolean => {
if (!username) return false;
return this.owners.has(username);
};

// Command registration
private async registerCommands(): Promise<void> {
const commandsForTelegram: TelegramBot.BotCommand[] = this.commands.map(c => ({
command: c.command,
description: c.description,
}));

try {
await this.bot.setMyCommands(commandsForTelegram);
await this.bot.setMyCommands(commandsForTelegram, {
scope: {
type: "all_private_chats",
} as TelegramBot.BotCommandScope,
});
await this.bot.setMyCommands(commandsForTelegram, {
scope: {
type: "all_group_chats",
} as TelegramBot.BotCommandScope,
});
}
catch (err) {
console.error("Failed to set bot commands:", err);
}
}

private onBanUser = (msg: TelegramBot.Message, match: RegExpExecArray | null) => {
const reason = match?.[1];
const id = this.banhammerDB.ban(msg.reply_to_message?.from?.id.toString() ?? "", reason ?? "", msg.from?.id.toString() ?? "");
if (id == null) {
throw new Error("Failed to create ban");
}
};

private onAddChat = async (msg: TelegramBot.Message, _match: RegExpExecArray | null) => {
this.banhammerDB.addChat(msg.chat.id.toString());
await this.bot.sendMessage(msg.chat.id, "Chat added to federated ban list", {
protect_content: true,
});
const fedBans = this.banhammerDB.getBans();
for (const ban of fedBans) {
await this.bot.banChatMember(msg.chat.id, parseInt(ban.user_id, 10), {
revoke_messages: true,
});
}
};
}
108 changes: 108 additions & 0 deletions packages/banhammer-bot/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
mkdirSync,
} from "node:fs";
import path from "node:path";
import {
DatabaseSync,
} from "node:sqlite";

export type Ban = {
id: number
user_id: string
reason: string
banned_at: number
unbanned_at: number | null
moderator_id: string
};

export class BanhammerDB {
private database: DatabaseSync;

constructor(databasePath: string) {
mkdirSync(path.dirname(databasePath), {
recursive: true,
});

this.database = new DatabaseSync(databasePath);
const initDatabase = `
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
reason TEXT NOT NULL,
banned_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
unbanned_at TIMESTAMPTZ,
ban_moderator_id TEXT NOT NULL,
unban_moderator_id TEXT NOT NULL,
);
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY,
chat_id TEXT NOT NULL,
);
`;

this.database.exec(initDatabase);
}

public addChat(chat_id: string): void {
this.database.prepare("INSERT INTO chats (chat_id) VALUES (?)").run(chat_id);
}

public ban(user_id: string, reason: string, moderator_id: string): number | null {
const bannedAt = Date.now();
const row = this.database.prepare(
"INSERT INTO bans (user_id, reason, banned_at, unbanned_at, ban_moderator_id) VALUES (?, ?, ?, ?, ?) RETURNING id",
).get(user_id, reason, bannedAt, null, moderator_id) as {
id?: unknown
} | undefined;
const idValue = row?.id;
return idValue == null ? null : Number(idValue);
}

public unBan(id: number, moderator_id: string): void {
const unbannedAt = Date.now();
this.database.prepare("UPDATE bans SET unbanned_at = ?, unban_moderator_id = ? WHERE id = ?").run(unbannedAt, moderator_id, id);
}

public getBan(user_id: string): Ban | null {
const row = this.database.prepare("SELECT * FROM bans WHERE user_id = ? AND unbanned_at IS NULL").get(user_id) as
| {
id: number
user_id: string
reason: string
banned_at: number
unbanned_at: number | null
moderator_id: string
}
| undefined;
if (row == null) {
return null;
}
return {
id: row.id,
user_id: row.user_id,
reason: row.reason,
banned_at: row.banned_at,
unbanned_at: row.unbanned_at,
moderator_id: row.moderator_id,
};
}

public getBans(): Ban[] {
const rows = this.database.prepare("SELECT * FROM bans WHERE unbanned_at IS NULL").all() as {
id: number
user_id: string
reason: string
banned_at: number
unbanned_at: number | null
moderator_id: string
}[];
return rows.map(row => ({
id: row.id,
user_id: row.user_id,
reason: row.reason,
banned_at: row.banned_at,
unbanned_at: row.unbanned_at,
moderator_id: row.moderator_id,
}));
}
}
21 changes: 21 additions & 0 deletions packages/banhammer-bot/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
BanhammerBot,
} from "./BanhammerBot.ts";

async function main() {
const token = process.env.TG_TOKEN ?? "";
const owners = process.env.OWNERS?.split(",") ?? [];
const mnemonic = process.env.MNEMONIC ?? "";
const databasePath = process.env.DATABASE_PATH ?? "data/banhammer.db";

const bot = new BanhammerBot({
token: token,
owners: owners,
mnemonic: mnemonic,
databasePath: databasePath,
});

await bot.start();
}

main();
23 changes: 23 additions & 0 deletions packages/banhammer-bot/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"include": [
"src/**/*"
],
"compilerOptions": {
"target": "es2020",
"module": "es2020",
"rootDir": "./src/",
"moduleResolution": "bundler",
"types": ["node"],
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./dist/",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true
}
}
32 changes: 32 additions & 0 deletions packages/banhammer-bot/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* eslint-disable @stylistic/no-multi-spaces */
import {
defineConfig,
} from "tsdown";

export default defineConfig([
{
entry: ["./src/index.ts"], // Entry point
unbundle: true, // Keep modules separate (don't bundle)
attw: true, // Run @arethetypeswrong/core checks
platform: "node", // Target Node.js environment
nodeProtocol: "strip", // Remove "node:" protocol prefix
target: "es2020", // Target ES2020 JavaScript
outDir: "./dist", // Output directory
clean: true, // Clean output directory before build
sourcemap: true, // Generate source maps
dts: true, // Generate TypeScript declaration files
format: ["cjs"], // Generate CommonJS format
},
{
entry: ["./src/index.ts"],
unbundle: true,
attw: true,
platform: "node",
target: "es2020",
outDir: "./dist",
clean: true,
sourcemap: true,
dts: true,
format: ["esm"],
},
]);
Loading
Loading