Skip to content

Commit 973fd81

Browse files
authored
Add bot-managed spam profile pattern system (#65)
Spam bio/channel patterns can now be added, removed, listed, and tested via bot commands instead of requiring code changes and redeployment. Patterns are stored in SQLite with field targeting (bio, channel, both) and integrate with the existing reaction spam detection pipeline. New commands: /addspampattern, /removespampattern, /listspampatterns, /testspampattern, /spampatternhelp. Supports interactive keyboard flow and single-command invocation. Built-in patterns remain as fallbacks.
1 parent e64aa8b commit 973fd81

7 files changed

Lines changed: 746 additions & 11 deletions

File tree

src/bot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { registerReactionSpamHandler } from "./handlers/reactionSpam";
3636
import { registerRestrictionHandlers } from "./handlers/restrictions";
3737
import { registerRoleHandlers } from "./handlers/roles";
38+
import { registerSpamPatternHandlers } from "./handlers/spamPatterns";
3839
import { registerViolationHandlers } from "./handlers/violations";
3940
import { messageFilterMiddleware } from "./middleware/messageFilter";
4041
import { ChatIndexerService } from "./services/chatIndexerService";
@@ -168,6 +169,7 @@ async function main() {
168169
registerGamblingCommands(bot); // Roll gambling game
169170
registerDuelCommands(bot); // Duel 2-player game
170171
registerCallbackHandlers(bot); // Inline keyboard callback handlers
172+
registerSpamPatternHandlers(bot); // Spam profile pattern management
171173
registerReactionSpamHandler(bot); // Reaction-based spam detection
172174

173175
// Session text handler for multi-step interactive flows

src/commands/help.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export function registerHelpCommand(bot: Telegraf<Context>): void {
128128

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

250250
if (role === "owner") {
251-
buttons.push([{ text: "Owner", callback_data: "help_owner" }]);
251+
buttons.push([
252+
{ text: "Spam Patterns", callback_data: "help_spampatterns" },
253+
{ text: "Owner", callback_data: "help_owner" },
254+
]);
252255
}
253256

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

509+
spampatterns: fmt([
510+
bold("Spam Pattern Management"),
511+
"\n\n",
512+
"Manage patterns matched against profiles of users who react to messages. Low-message users matching a pattern are permanently banned.\n\n",
513+
bold("Profile Fields:"),
514+
"\n",
515+
bold("bio"),
516+
" - The user's biography text\n",
517+
bold("channel"),
518+
" - The user's linked personal channel title\n",
519+
bold("both"),
520+
" (default) - Match against both fields\n\n",
521+
"/addspampattern [pattern] [bio|channel|both]\n",
522+
" Add a spam profile pattern. No args for interactive mode.\n\n",
523+
"/removespampattern <id>\n",
524+
" Remove a pattern by its ID.\n\n",
525+
"/listspampatterns\n",
526+
" View all active custom patterns.\n\n",
527+
"/testspampattern <pattern> <sample>\n",
528+
" Test a pattern against sample text without saving.\n\n",
529+
"/spampatternhelp\n",
530+
" Detailed guide with examples.\n\n",
531+
bold("Examples:"),
532+
"\n",
533+
code('/addspampattern "bonus" channel'),
534+
'\n Bans users with "BONUS" in their channel title\n\n',
535+
code("/addspampattern /elon\\s*musk/i channel"),
536+
'\n Bans users with "Elon Musk" scam channels\n\n',
537+
code('/addspampattern "18+" bio'),
538+
'\n Bans users with "18+" in their bio',
539+
]),
540+
506541
owner: fmt([
507542
bold("Owner Commands"),
508543
"\n\n",
@@ -574,6 +609,7 @@ const categoryRoleRequirements: Record<string, string[]> = {
574609
payments: ["pleb", "elevated", "admin", "owner"],
575610
elevated: ["elevated", "admin", "owner"],
576611
admin: ["admin", "owner"],
612+
spampatterns: ["owner"],
577613
owner: ["owner"],
578614
};
579615

src/database.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,18 @@ export const initDb = (): void => {
510510
);
511511
`);
512512

513+
// Spam profile pattern management table
514+
db.exec(`
515+
CREATE TABLE IF NOT EXISTS spam_patterns (
516+
id INTEGER PRIMARY KEY AUTOINCREMENT,
517+
pattern TEXT NOT NULL UNIQUE,
518+
match_field TEXT NOT NULL DEFAULT 'both',
519+
description TEXT,
520+
added_by INTEGER,
521+
created_at INTEGER DEFAULT (strftime('%s', 'now'))
522+
);
523+
`);
524+
513525
// Create indexes for performance
514526
db.exec(`
515527
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
@@ -557,6 +569,9 @@ export const initDb = (): void => {
557569
CREATE INDEX IF NOT EXISTS idx_duels_opponent ON duels(opponent_id);
558570
CREATE INDEX IF NOT EXISTS idx_duels_status ON duels(status);
559571
CREATE INDEX IF NOT EXISTS idx_duels_expires ON duels(expires_at);
572+
573+
-- Spam pattern indexes
574+
CREATE INDEX IF NOT EXISTS idx_spam_patterns_field ON spam_patterns(match_field);
560575
`);
561576

562577
logger.info("Database schema initialized");

src/handlers/callbacks.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { AmountPrecision } from "../utils/precision";
3636
import { checkIsElevated, isImmuneToModeration } from "../utils/roles";
3737
import { formatUserIdDisplay, resolveUserId } from "../utils/userResolver";
38+
import { addPattern, type SpamPatternField } from "./spamPatterns";
3839

3940
interface Giveaway {
4041
id: number;
@@ -149,6 +150,7 @@ const callbackHandlers: Array<{ prefix: string; handler: CallbackHandler }> = [
149150
{ prefix: "confirm_", handler: handleConfirmationCallback },
150151
{ prefix: "menu_", handler: handleMenuCallback },
151152
{ prefix: "select_user_", handler: handleUserSelectionCallback },
153+
{ prefix: "spamfield_", handler: handleSpamFieldCallback },
152154
];
153155

154156
/**
@@ -1178,6 +1180,8 @@ export async function handleSessionText(ctx: Context): Promise<boolean> {
11781180
case "list_remove_white":
11791181
case "list_remove_black":
11801182
return await processListSession(ctx, session, text);
1183+
case "add_spam_pattern":
1184+
return await processAddSpamPatternSession(ctx, session, text);
11811185
default:
11821186
return false;
11831187
}
@@ -1592,3 +1596,77 @@ async function processListSession(
15921596
clearSession(adminId);
15931597
return true;
15941598
}
1599+
1600+
/**
1601+
* Handles spam pattern field selection from the inline keyboard.
1602+
* Stores the selected field in session and prompts for the pattern text.
1603+
*
1604+
* @param ctx - Telegraf callback query context
1605+
* @param data - Callback data in format "spamfield_<field>"
1606+
* @param userId - ID of the user who clicked the button
1607+
*/
1608+
async function handleSpamFieldCallback(
1609+
ctx: Context,
1610+
data: string,
1611+
userId: number,
1612+
): Promise<void> {
1613+
if (!verifyAdminRole(userId)) {
1614+
await ctx.editMessageText(
1615+
"Your privileges have been revoked. Action cancelled.",
1616+
);
1617+
return;
1618+
}
1619+
1620+
const field = data.replace("spamfield_", "") as SpamPatternField;
1621+
const fieldLabel =
1622+
field === "bio" ? "Bio" : field === "channel" ? "Channel Title" : "Both";
1623+
1624+
setSession(userId, "add_spam_pattern", 1, { field });
1625+
1626+
await ctx.editMessageText(
1627+
fmt`${bold(`Add Spam Pattern [${fieldLabel}]`)}
1628+
1629+
Reply with the pattern to match against user profiles.
1630+
1631+
${bold("Pattern formats:")}
1632+
${code('"simple text"')} - case-insensitive substring
1633+
${code("*wild*card*")} - wildcard matching
1634+
${code("/regex/i")} - full regex
1635+
1636+
${bold("Examples:")}
1637+
${code("bonus 1000")} - matches "BONUS 1000$"
1638+
${code("/elon\\s*musk/i")} - matches "Elon Musk"
1639+
${code("*crypto*giveaway*")} - matches "Free Crypto Giveaway"`,
1640+
);
1641+
}
1642+
1643+
/**
1644+
* Processes text input for the add_spam_pattern interactive session.
1645+
* Called when a user replies with a pattern after selecting a field.
1646+
*
1647+
* @param ctx - Telegraf context
1648+
* @param session - Current session data containing the selected field
1649+
* @param text - User's text input (the pattern)
1650+
* @returns True if handled
1651+
*/
1652+
async function processAddSpamPatternSession(
1653+
ctx: Context,
1654+
session: SessionData,
1655+
text: string,
1656+
): Promise<boolean> {
1657+
const userId = ctx.from?.id;
1658+
if (!userId) return false;
1659+
1660+
if (!verifyAdminRole(userId)) {
1661+
await ctx.reply("Your privileges have been revoked. Action cancelled.");
1662+
clearSession(userId);
1663+
return true;
1664+
}
1665+
1666+
const field = session.data.field as SpamPatternField;
1667+
const pattern = text.trim();
1668+
1669+
clearSession(userId);
1670+
await addPattern(ctx, userId, pattern, field);
1671+
return true;
1672+
}

src/handlers/reactionSpam.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { execute, get } from "../database";
1313
import { scheduleDelete } from "../utils/autoDelete";
1414
import { logger, StructuredLogger } from "../utils/logger";
1515
import { isAdmin, isOwner } from "../utils/roles";
16+
import { getDbSpamPatterns } from "./spamPatterns";
1617

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

5154
/**
@@ -91,22 +94,54 @@ interface UserProfile {
9194
}
9295

9396
/**
94-
* Checks if any profile text matches spam patterns.
97+
* Checks if any profile text matches spam patterns (built-in or DB-managed).
9598
*
9699
* @param profile - The user's profile info (bio and/or personal chat title)
97-
* @returns The matched field name, or null if no match
100+
* @returns Object with matched field and pattern info, or null if no match
98101
*/
99-
function detectSpamProfile(profile: UserProfile): string | null {
102+
function detectSpamProfile(
103+
profile: UserProfile,
104+
): { field: string; patternSource: string } | null {
100105
const fields: [string, string | undefined][] = [
101106
["bio", profile.bio],
102107
["personal_chat", profile.personalChatTitle],
103108
];
104109

110+
// Check built-in patterns (always match both fields)
105111
for (const [field, value] of fields) {
106112
if (value && SPAM_BIO_PATTERNS.some((pattern) => pattern.test(value))) {
107-
return field;
113+
return { field, patternSource: "builtin" };
108114
}
109115
}
116+
117+
// Check DB-managed patterns with field targeting
118+
const dbPatterns = getDbSpamPatterns();
119+
for (const dbPattern of dbPatterns) {
120+
const fieldsToCheck: [string, string | undefined][] = [];
121+
if (dbPattern.matchField === "bio" || dbPattern.matchField === "both") {
122+
fieldsToCheck.push(["bio", profile.bio]);
123+
}
124+
if (dbPattern.matchField === "channel" || dbPattern.matchField === "both") {
125+
fieldsToCheck.push(["personal_chat", profile.personalChatTitle]);
126+
}
127+
128+
for (const [field, value] of fieldsToCheck) {
129+
if (value) {
130+
try {
131+
dbPattern.compiled.regex.lastIndex = 0;
132+
if (dbPattern.compiled.regex.test(value)) {
133+
return {
134+
field,
135+
patternSource: `db#${dbPattern.id}:${dbPattern.raw}`,
136+
};
137+
}
138+
} catch {
139+
// Skip broken patterns
140+
}
141+
}
142+
}
143+
}
144+
110145
return null;
111146
}
112147

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

394-
const spamField = detectSpamProfile(profile);
395-
if (spamField) {
429+
const spamMatch = detectSpamProfile(profile);
430+
if (spamMatch) {
396431
const matchedValue =
397-
spamField === "bio" ? profile.bio : profile.personalChatTitle;
432+
spamMatch.field === "bio" ? profile.bio : profile.personalChatTitle;
398433

399434
StructuredLogger.logSecurityEvent("Spam bot detected via profile", {
400435
userId: user.id,
401436
username: user.username,
402-
matchedField: spamField,
437+
matchedField: spamMatch.field,
403438
matchedValue: matchedValue?.substring(0, 200),
439+
patternSource: spamMatch.patternSource,
404440
chatId: chat.id,
405441
operation: "reaction_spam_profile",
406442
});
@@ -423,7 +459,8 @@ export function registerReactionSpamHandler(bot: Telegraf<Context>): void {
423459
username: user.username,
424460
firstName: user.first_name,
425461
chatId: chat.id,
426-
reason: `spam_${spamField}`,
462+
reason: `spam_${spamMatch.field}`,
463+
patternSource: spamMatch.patternSource,
427464
matchedValue: matchedValue?.substring(0, 100),
428465
});
429466
} catch (banError) {

0 commit comments

Comments
 (0)