Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { registerReactionSpamHandler } from "./handlers/reactionSpam";
import { registerRestrictionHandlers } from "./handlers/restrictions";
import { registerRoleHandlers } from "./handlers/roles";
import { registerSpamPatternHandlers } from "./handlers/spamPatterns";
import { registerViolationHandlers } from "./handlers/violations";
import { messageFilterMiddleware } from "./middleware/messageFilter";
import { ChatIndexerService } from "./services/chatIndexerService";
Expand Down Expand Up @@ -168,6 +169,7 @@ async function main() {
registerGamblingCommands(bot); // Roll gambling game
registerDuelCommands(bot); // Duel 2-player game
registerCallbackHandlers(bot); // Inline keyboard callback handlers
registerSpamPatternHandlers(bot); // Spam profile pattern management
registerReactionSpamHandler(bot); // Reaction-based spam detection

// Session text handler for multi-step interactive flows
Expand Down
40 changes: 38 additions & 2 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function registerHelpCommand(bot: Telegraf<Context>): void {

// Handle help category callbacks - specific categories only, exclude 'menu' and 'games'
bot.action(
/^help_(wallet|shared|user|giveaways|payments|elevated|admin|owner)$/,
/^help_(wallet|shared|user|giveaways|payments|elevated|admin|owner|spampatterns)$/,
async (ctx) => {
const category = ctx.match[1];
const userId = ctx.from?.id;
Expand Down Expand Up @@ -248,7 +248,10 @@ function buildHelpMenu(role: string): InlineKeyboardMarkup {
}

if (role === "owner") {
buttons.push([{ text: "Owner", callback_data: "help_owner" }]);
buttons.push([
{ text: "Spam Patterns", callback_data: "help_spampatterns" },
{ text: "Owner", callback_data: "help_owner" },
]);
}

return { inline_keyboard: buttons };
Expand Down Expand Up @@ -503,6 +506,38 @@ const helpContent: Record<string, FmtString> = {
" Contribute JUNO from your balance to the game treasury. Helps fund game payouts like /roll wins.",
]),

spampatterns: fmt([
bold("Spam Pattern Management"),
"\n\n",
"Manage patterns matched against profiles of users who react to messages. Low-message users matching a pattern are permanently banned.\n\n",
bold("Profile Fields:"),
"\n",
bold("bio"),
" - The user's biography text\n",
bold("channel"),
" - The user's linked personal channel title\n",
bold("both"),
" (default) - Match against both fields\n\n",
"/addspampattern [pattern] [bio|channel|both]\n",
" Add a spam profile pattern. No args for interactive mode.\n\n",
"/removespampattern <id>\n",
" Remove a pattern by its ID.\n\n",
"/listspampatterns\n",
" View all active custom patterns.\n\n",
"/testspampattern <pattern> <sample>\n",
" Test a pattern against sample text without saving.\n\n",
"/spampatternhelp\n",
" Detailed guide with examples.\n\n",
bold("Examples:"),
"\n",
code('/addspampattern "bonus" channel'),
'\n Bans users with "BONUS" in their channel title\n\n',
code("/addspampattern /elon\\s*musk/i channel"),
'\n Bans users with "Elon Musk" scam channels\n\n',
code('/addspampattern "18+" bio'),
'\n Bans users with "18+" in their bio',
]),

owner: fmt([
bold("Owner Commands"),
"\n\n",
Expand Down Expand Up @@ -574,6 +609,7 @@ const categoryRoleRequirements: Record<string, string[]> = {
payments: ["pleb", "elevated", "admin", "owner"],
elevated: ["elevated", "admin", "owner"],
admin: ["admin", "owner"],
spampatterns: ["owner"],
owner: ["owner"],
};

Expand Down
15 changes: 15 additions & 0 deletions src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,18 @@ export const initDb = (): void => {
);
`);

// Spam profile pattern management table
db.exec(`
CREATE TABLE IF NOT EXISTS spam_patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pattern TEXT NOT NULL UNIQUE,
match_field TEXT NOT NULL DEFAULT 'both',
description TEXT,
added_by INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);
`);

// Create indexes for performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
Expand Down Expand Up @@ -557,6 +569,9 @@ export const initDb = (): void => {
CREATE INDEX IF NOT EXISTS idx_duels_opponent ON duels(opponent_id);
CREATE INDEX IF NOT EXISTS idx_duels_status ON duels(status);
CREATE INDEX IF NOT EXISTS idx_duels_expires ON duels(expires_at);

-- Spam pattern indexes
CREATE INDEX IF NOT EXISTS idx_spam_patterns_field ON spam_patterns(match_field);
`);

logger.info("Database schema initialized");
Expand Down
78 changes: 78 additions & 0 deletions src/handlers/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { AmountPrecision } from "../utils/precision";
import { checkIsElevated, isImmuneToModeration } from "../utils/roles";
import { formatUserIdDisplay, resolveUserId } from "../utils/userResolver";
import { addPattern, type SpamPatternField } from "./spamPatterns";

interface Giveaway {
id: number;
Expand Down Expand Up @@ -149,6 +150,7 @@ const callbackHandlers: Array<{ prefix: string; handler: CallbackHandler }> = [
{ prefix: "confirm_", handler: handleConfirmationCallback },
{ prefix: "menu_", handler: handleMenuCallback },
{ prefix: "select_user_", handler: handleUserSelectionCallback },
{ prefix: "spamfield_", handler: handleSpamFieldCallback },
];

/**
Expand Down Expand Up @@ -1178,6 +1180,8 @@ export async function handleSessionText(ctx: Context): Promise<boolean> {
case "list_remove_white":
case "list_remove_black":
return await processListSession(ctx, session, text);
case "add_spam_pattern":
return await processAddSpamPatternSession(ctx, session, text);
default:
return false;
}
Expand Down Expand Up @@ -1592,3 +1596,77 @@ async function processListSession(
clearSession(adminId);
return true;
}

/**
* Handles spam pattern field selection from the inline keyboard.
* Stores the selected field in session and prompts for the pattern text.
*
* @param ctx - Telegraf callback query context
* @param data - Callback data in format "spamfield_<field>"
* @param userId - ID of the user who clicked the button
*/
async function handleSpamFieldCallback(
ctx: Context,
data: string,
userId: number,
): Promise<void> {
if (!verifyAdminRole(userId)) {
await ctx.editMessageText(
"Your privileges have been revoked. Action cancelled.",
);
return;
}

const field = data.replace("spamfield_", "") as SpamPatternField;
const fieldLabel =
field === "bio" ? "Bio" : field === "channel" ? "Channel Title" : "Both";

setSession(userId, "add_spam_pattern", 1, { field });

await ctx.editMessageText(
fmt`${bold(`Add Spam Pattern [${fieldLabel}]`)}

Reply with the pattern to match against user profiles.

${bold("Pattern formats:")}
${code('"simple text"')} - case-insensitive substring
${code("*wild*card*")} - wildcard matching
${code("/regex/i")} - full regex

${bold("Examples:")}
${code("bonus 1000")} - matches "BONUS 1000$"
${code("/elon\\s*musk/i")} - matches "Elon Musk"
${code("*crypto*giveaway*")} - matches "Free Crypto Giveaway"`,
);
}

/**
* Processes text input for the add_spam_pattern interactive session.
* Called when a user replies with a pattern after selecting a field.
*
* @param ctx - Telegraf context
* @param session - Current session data containing the selected field
* @param text - User's text input (the pattern)
* @returns True if handled
*/
async function processAddSpamPatternSession(
ctx: Context,
session: SessionData,
text: string,
): Promise<boolean> {
const userId = ctx.from?.id;
if (!userId) return false;

if (!verifyAdminRole(userId)) {
await ctx.reply("Your privileges have been revoked. Action cancelled.");
clearSession(userId);
return true;
}

const field = session.data.field as SpamPatternField;
const pattern = text.trim();

clearSession(userId);
await addPattern(ctx, userId, pattern, field);
return true;
}
55 changes: 46 additions & 9 deletions src/handlers/reactionSpam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { execute, get } from "../database";
import { scheduleDelete } from "../utils/autoDelete";
import { logger, StructuredLogger } from "../utils/logger";
import { isAdmin, isOwner } from "../utils/roles";
import { getDbSpamPatterns } from "./spamPatterns";

/** Minimum messages a user must have sent before being exempt from spam checks */
const MIN_MESSAGES_FOR_EXEMPTION = 10;
Expand Down Expand Up @@ -46,6 +47,8 @@ const SPAM_BIO_PATTERNS = [
/meet\s*single/i, // Dating spam
/sexy?\s*(girl|video|photo)/i, // Explicit content spam
/\uD83D\uDD1E/, // 18+ emoji
/bonus\s*\d+\s*\$/i, // "BONUS 1000$" scam channel spam
/elon\s*musk/i, // Elon Musk crypto scam channels
];

/**
Expand Down Expand Up @@ -91,22 +94,54 @@ interface UserProfile {
}

/**
* Checks if any profile text matches spam patterns.
* Checks if any profile text matches spam patterns (built-in or DB-managed).
*
* @param profile - The user's profile info (bio and/or personal chat title)
* @returns The matched field name, or null if no match
* @returns Object with matched field and pattern info, or null if no match
*/
function detectSpamProfile(profile: UserProfile): string | null {
function detectSpamProfile(
profile: UserProfile,
): { field: string; patternSource: string } | null {
const fields: [string, string | undefined][] = [
["bio", profile.bio],
["personal_chat", profile.personalChatTitle],
];

// Check built-in patterns (always match both fields)
for (const [field, value] of fields) {
if (value && SPAM_BIO_PATTERNS.some((pattern) => pattern.test(value))) {
return field;
return { field, patternSource: "builtin" };
}
}

// Check DB-managed patterns with field targeting
const dbPatterns = getDbSpamPatterns();
for (const dbPattern of dbPatterns) {
const fieldsToCheck: [string, string | undefined][] = [];
if (dbPattern.matchField === "bio" || dbPattern.matchField === "both") {
fieldsToCheck.push(["bio", profile.bio]);
}
if (dbPattern.matchField === "channel" || dbPattern.matchField === "both") {
fieldsToCheck.push(["personal_chat", profile.personalChatTitle]);
}

for (const [field, value] of fieldsToCheck) {
if (value) {
try {
dbPattern.compiled.regex.lastIndex = 0;
if (dbPattern.compiled.regex.test(value)) {
return {
field,
patternSource: `db#${dbPattern.id}:${dbPattern.raw}`,
};
}
} catch {
// Skip broken patterns
}
}
}
}

return null;
}

Expand Down Expand Up @@ -391,16 +426,17 @@ export function registerReactionSpamHandler(bot: Telegraf<Context>): void {
personalChat: profile.personalChatTitle ?? null,
});

const spamField = detectSpamProfile(profile);
if (spamField) {
const spamMatch = detectSpamProfile(profile);
if (spamMatch) {
const matchedValue =
spamField === "bio" ? profile.bio : profile.personalChatTitle;
spamMatch.field === "bio" ? profile.bio : profile.personalChatTitle;

StructuredLogger.logSecurityEvent("Spam bot detected via profile", {
userId: user.id,
username: user.username,
matchedField: spamField,
matchedField: spamMatch.field,
matchedValue: matchedValue?.substring(0, 200),
patternSource: spamMatch.patternSource,
chatId: chat.id,
operation: "reaction_spam_profile",
});
Expand All @@ -423,7 +459,8 @@ export function registerReactionSpamHandler(bot: Telegraf<Context>): void {
username: user.username,
firstName: user.first_name,
chatId: chat.id,
reason: `spam_${spamField}`,
reason: `spam_${spamMatch.field}`,
patternSource: spamMatch.patternSource,
matchedValue: matchedValue?.substring(0, 100),
});
} catch (banError) {
Expand Down
Loading