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
4 changes: 2 additions & 2 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
import { registerReactionSpamHandler } from "./handlers/reactionSpam";
import { registerRestrictionHandlers } from "./handlers/restrictions";
import { registerRoleHandlers } from "./handlers/roles";
import { registerSpamPatternHandlers } from "./handlers/spamPatterns";
import { registerSpamReactHandlers } from "./handlers/spamReacts";
import { registerViolationHandlers } from "./handlers/violations";
import { messageFilterMiddleware } from "./middleware/messageFilter";
import { ChatIndexerService } from "./services/chatIndexerService";
Expand Down Expand Up @@ -169,7 +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
registerSpamReactHandlers(bot); // Spam reaction pattern management
registerReactionSpamHandler(bot); // Reaction-based spam detection

// Session text handler for multi-step interactive flows
Expand Down
55 changes: 16 additions & 39 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|spampatterns)$/,
/^help_(wallet|shared|user|giveaways|payments|elevated|admin|owner)$/,
async (ctx) => {
const category = ctx.match[1];
const userId = ctx.from?.id;
Expand Down Expand Up @@ -248,10 +248,7 @@ function buildHelpMenu(role: string): InlineKeyboardMarkup {
}

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

return { inline_keyboard: buttons };
Expand Down Expand Up @@ -506,38 +503,6 @@ 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 @@ -594,7 +559,20 @@ const helpContent: Record<string, FmtString> = {
bold("Moderation:"),
"\n",
"/clearviolations <user>\n",
" Clear all violations for a user.",
" Clear all violations for a user.\n\n",
bold("Spam Reaction Patterns:"),
"\n",
"Manage patterns matched against profiles of users who react to messages. Matching users are permanently banned.\n\n",
"/addspamreact [pattern] [bio|channel|both]\n",
" Add a spam reaction pattern. No args for interactive mode.\n\n",
"/removespamreact <id>\n",
" Remove a pattern by its ID.\n\n",
"/listspamreacts\n",
" View all active custom patterns.\n\n",
"/testspamreact <pattern> <sample>\n",
" Test a pattern against sample text without saving.\n\n",
"/spamreacthelp\n",
" Detailed guide with examples.",
]),
};

Expand All @@ -609,7 +587,6 @@ const categoryRoleRequirements: Record<string, string[]> = {
payments: ["pleb", "elevated", "admin", "owner"],
elevated: ["elevated", "admin", "owner"],
admin: ["admin", "owner"],
spampatterns: ["owner"],
owner: ["owner"],
};

Expand Down
180 changes: 136 additions & 44 deletions src/handlers/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +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";
import { addPattern, type SpamReactField } from "./spamReacts";

interface Giveaway {
id: number;
Expand Down Expand Up @@ -294,6 +294,68 @@ ${bold("Select auto-jail settings:")}
);
}

/**
* Applies a restriction to a user and sends confirmation.
* Shared by the auto-jail callback (non-regex) and the regex pattern text handler.
*/
async function applyRestriction(
ctx: Context,
adminId: number,
targetId: number,
restrictionType: string,
action: string | undefined,
severity: "delete" | "mute" | "jail",
autoJailSetting: string,
threshold: number,
jailDuration: number,
jailFine: number,
): Promise<void> {
addUserRestriction(
targetId,
restrictionType,
action,
undefined,
undefined,
severity,
threshold,
jailDuration,
jailFine,
);

StructuredLogger.logSecurityEvent("Restriction added via interactive flow", {
adminId,
userId: targetId,
operation: "add_restriction",
restriction: restrictionType,
action,
severity,
autoJailSetting,
threshold,
jailDuration,
jailFine,
});

const targetDisplay = formatUserIdDisplay(targetId);
const autoJailText =
autoJailSetting === "disabled"
? "Auto-jail: Disabled"
: `Auto-jail: After ${threshold} violations (${Math.round(jailDuration / 1440)} day(s), ${jailFine.toFixed(1)} JUNO fine)`;

await ctx.reply(
fmt`${bold("Restriction Applied")}

Type: ${restrictionType}
Target: ${targetDisplay}
${action ? fmt`Pattern: ${code(action)}` : ""}
Severity: ${severity}
${autoJailText}

Use ${code(`/listrestrictions ${targetId}`)} to view all restrictions.`,
);

clearSession(adminId);
}

/**
* Handles auto-jail settings selection and applies the restriction.
* This is the final step (step 3) of the interactive restriction creation flow.
Expand Down Expand Up @@ -362,49 +424,41 @@ async function handleAutoJailCallback(
break;
}

// Apply the restriction
addUserRestriction(
// For regex_block, we need to ask for the pattern before applying
if (restrictionType === "regex_block") {
session.data.threshold = threshold;
session.data.jailDuration = jailDuration;
session.data.jailFine = jailFine;
session.data.autoJailSetting = autoJailSetting;
setSession(userId, "add_restriction", 4, session.data);

await ctx.editMessageText(
fmt`${bold("Regex Block Pattern")}

Reply with the pattern to block. Examples:
${code('"fa99ot"')} - simple text match
${code('"spam*here"')} - wildcard match
${code("/\\b(word1|word2)\\b/i")} - regex pattern

Multiple words can be combined with | in regex:
${code("/\\b(fa99ot|fa990t)\\b/i")}`,
);
return;
}

// Apply the restriction (non-regex types)
applyRestriction(
ctx,
userId,
targetId,
restrictionType,
undefined,
undefined,
undefined,
severity,
threshold,
jailDuration,
jailFine,
);

StructuredLogger.logSecurityEvent("Restriction added via interactive flow", {
adminId: userId,
userId: targetId,
operation: "add_restriction",
restriction: restrictionType,
severity,
autoJailSetting,
threshold,
jailDuration,
jailFine,
});

const targetDisplay = formatUserIdDisplay(targetId);
const autoJailText =
autoJailSetting === "disabled"
? "Auto-jail: Disabled"
: `Auto-jail: After ${threshold} violations (${Math.round(jailDuration / 1440)} day(s), ${jailFine.toFixed(1)} JUNO fine)`;

await ctx.editMessageText(
fmt`${bold("Restriction Applied")}

Type: ${restrictionType}
Target: ${targetDisplay}
Severity: ${severity}
${autoJailText}

Use ${code(`/listrestrictions ${targetId}`)} to view all restrictions.`,
);

clearSession(userId);
}

/**
Expand Down Expand Up @@ -1180,8 +1234,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);
case "add_spam_react":
return await processAddSpamReactSession(ctx, session, text);
default:
return false;
}
Expand Down Expand Up @@ -1228,6 +1282,44 @@ async function processAddRestrictionSession(

const { restrictionType } = session.data;

// Step 4: Capture regex pattern for regex_block type
if (session.step === 4 && restrictionType === "regex_block") {
const pattern = text.trim();
if (!pattern) {
await ctx.reply("Please provide a non-empty pattern.");
return true;
}

const {
targetId,
severity,
threshold,
jailDuration,
jailFine,
autoJailSetting,
} = session.data;

// Strip surrounding quotes if present
let action = pattern;
if (action.startsWith('"') && action.endsWith('"')) {
action = action.slice(1, -1);
}

await applyRestriction(
ctx,
userId,
targetId,
restrictionType,
action,
severity,
autoJailSetting,
threshold,
jailDuration,
jailFine,
);
return true;
}

// Step 1: Resolve the target user from text input
const targetId = resolveUserId(text.trim());
if (!targetId) {
Expand Down Expand Up @@ -1598,7 +1690,7 @@ async function processListSession(
}

/**
* Handles spam pattern field selection from the inline keyboard.
* Handles spam react field selection from the inline keyboard.
* Stores the selected field in session and prompts for the pattern text.
*
* @param ctx - Telegraf callback query context
Expand All @@ -1617,14 +1709,14 @@ async function handleSpamFieldCallback(
return;
}

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

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

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

Reply with the pattern to match against user profiles.

Expand All @@ -1641,15 +1733,15 @@ ${code("*crypto*giveaway*")} - matches "Free Crypto Giveaway"`,
}

/**
* Processes text input for the add_spam_pattern interactive session.
* Processes text input for the add_spam_react 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(
async function processAddSpamReactSession(
ctx: Context,
session: SessionData,
text: string,
Expand All @@ -1663,7 +1755,7 @@ async function processAddSpamPatternSession(
return true;
}

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

clearSession(userId);
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/reactionSpam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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";
import { getDbSpamReacts } from "./spamReacts";

/** Minimum messages a user must have sent before being exempt from spam checks */
const MIN_MESSAGES_FOR_EXEMPTION = 10;
Expand Down Expand Up @@ -115,7 +115,7 @@ function detectSpamProfile(
}

// Check DB-managed patterns with field targeting
const dbPatterns = getDbSpamPatterns();
const dbPatterns = getDbSpamReacts();
for (const dbPattern of dbPatterns) {
const fieldsToCheck: [string, string | undefined][] = [];
if (dbPattern.matchField === "bio" || dbPattern.matchField === "both") {
Expand Down
Loading
Loading