diff --git a/src/bot.ts b/src/bot.ts index dd10f84..049004f 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -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"; @@ -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 diff --git a/src/commands/help.ts b/src/commands/help.ts index 2e658fe..517c307 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -128,7 +128,7 @@ export function registerHelpCommand(bot: Telegraf): 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; @@ -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 }; @@ -503,6 +506,38 @@ const helpContent: Record = { " 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 \n", + " Remove a pattern by its ID.\n\n", + "/listspampatterns\n", + " View all active custom patterns.\n\n", + "/testspampattern \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", @@ -574,6 +609,7 @@ const categoryRoleRequirements: Record = { payments: ["pleb", "elevated", "admin", "owner"], elevated: ["elevated", "admin", "owner"], admin: ["admin", "owner"], + spampatterns: ["owner"], owner: ["owner"], }; diff --git a/src/database.ts b/src/database.ts index eb78873..8b326ad 100644 --- a/src/database.ts +++ b/src/database.ts @@ -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); @@ -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"); diff --git a/src/handlers/callbacks.ts b/src/handlers/callbacks.ts index 89c98d5..7e4f2db 100644 --- a/src/handlers/callbacks.ts +++ b/src/handlers/callbacks.ts @@ -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; @@ -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 }, ]; /** @@ -1178,6 +1180,8 @@ export async function handleSessionText(ctx: Context): Promise { 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; } @@ -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_" + * @param userId - ID of the user who clicked the button + */ +async function handleSpamFieldCallback( + ctx: Context, + data: string, + userId: number, +): Promise { + 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 { + 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; +} diff --git a/src/handlers/reactionSpam.ts b/src/handlers/reactionSpam.ts index caa333a..2a7db2e 100644 --- a/src/handlers/reactionSpam.ts +++ b/src/handlers/reactionSpam.ts @@ -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; @@ -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 ]; /** @@ -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; } @@ -391,16 +426,17 @@ export function registerReactionSpamHandler(bot: Telegraf): 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", }); @@ -423,7 +459,8 @@ export function registerReactionSpamHandler(bot: Telegraf): 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) { diff --git a/src/handlers/spamPatterns.ts b/src/handlers/spamPatterns.ts new file mode 100644 index 0000000..319176e --- /dev/null +++ b/src/handlers/spamPatterns.ts @@ -0,0 +1,552 @@ +/** + * Spam profile pattern management handlers for the CAC Admin Bot. + * Provides commands for adding, removing, listing, and testing spam patterns + * that are matched against user bios and personal channel titles during + * reaction-based spam detection. + * + * Patterns are stored in the database and loaded with caching to avoid + * repeated DB queries on every reaction event. + * + * @module handlers/spamPatterns + */ + +import type { Context, Telegraf } from "telegraf"; +import { bold, code, fmt } from "telegraf/format"; +import { execute, get, query } from "../database"; +import { adminOrHigher, ownerOnly } from "../middleware"; +import { spamPatternFieldKeyboard } from "../utils/keyboards"; +import { logger, StructuredLogger } from "../utils/logger"; +import { + type CompiledPattern, + compileSafeRegex, + validatePattern, +} from "../utils/safeRegex"; + +/** Valid match field values for spam patterns */ +export type SpamPatternField = "bio" | "channel" | "both"; + +/** Database row shape for a spam pattern */ +export interface SpamPatternRow { + id: number; + pattern: string; + match_field: SpamPatternField; + description: string | null; + added_by: number | null; + created_at: number; +} + +/** Compiled spam pattern with field targeting */ +export interface CompiledSpamPattern { + id: number; + raw: string; + compiled: CompiledPattern; + matchField: SpamPatternField; + description: string | null; +} + +// --- Pattern Cache --- + +/** Cached compiled patterns */ +let cachedPatterns: CompiledSpamPattern[] | null = null; + +/** Timestamp of last cache refresh */ +let cacheTimestamp = 0; + +/** Cache TTL in milliseconds (60 seconds) */ +const CACHE_TTL_MS = 60_000; + +/** + * Invalidates the spam pattern cache. + * Call after adding or removing patterns to ensure immediate effect. + */ +export function invalidateSpamPatternCache(): void { + cachedPatterns = null; + cacheTimestamp = 0; +} + +/** + * Returns compiled spam patterns from the database, with caching. + * Patterns are refreshed every 60 seconds or when the cache is explicitly invalidated. + * + * @returns Array of compiled spam patterns + */ +export function getDbSpamPatterns(): CompiledSpamPattern[] { + const now = Date.now(); + if (cachedPatterns && now - cacheTimestamp < CACHE_TTL_MS) { + return cachedPatterns; + } + + const rows = query( + "SELECT * FROM spam_patterns ORDER BY id", + [], + ); + + const compiled: CompiledSpamPattern[] = []; + for (const row of rows) { + try { + const pattern = compileSafeRegex(row.pattern); + compiled.push({ + id: row.id, + raw: row.pattern, + compiled: pattern, + matchField: row.match_field, + description: row.description, + }); + } catch (error) { + logger.error("Failed to compile DB spam pattern", { + patternId: row.id, + pattern: row.pattern, + error, + }); + } + } + + cachedPatterns = compiled; + cacheTimestamp = now; + return compiled; +} + +/** + * Registers all spam pattern management commands with the bot. + * + * Commands: + * - /addspampattern - Add a spam profile pattern (owner only) + * - /removespampattern - Remove a pattern by ID (owner only) + * - /listspampatterns - List all active patterns (admin or higher) + * - /spampatternhelp - Detailed usage guide with examples + * + * @param bot - Telegraf bot instance + */ +export function registerSpamPatternHandlers(bot: Telegraf): void { + /** + * Command: /addspampattern [pattern] [bio|channel|both] + * + * Interactive mode (no args): Shows field selection keyboard. + * Command mode: Adds pattern directly. + * + * Permission: Owner only + */ + bot.command("addspampattern", ownerOnly, async (ctx) => { + const userId = ctx.from?.id; + if (!userId) return; + + const rawArgs = ctx.message?.text.split(" ").slice(1).join(" ") || ""; + + // No args -> interactive keyboard flow + if (!rawArgs.trim()) { + return ctx.reply( + fmt`${bold("Add Spam Profile Pattern")} + +Select which profile field this pattern should match against: + +${bold("Bio")} - The user's biography text +${bold("Channel")} - The user's linked personal channel title +${bold("Both")} - Match against both fields + +After selecting, you'll be prompted to enter the pattern. + +Or use the command directly: +${code('/addspampattern "pattern" [bio|channel|both]')} + +For detailed help: ${code("/spampatternhelp")}`, + { + reply_markup: spamPatternFieldKeyboard, + }, + ); + } + + // Parse args: pattern (possibly quoted) followed by optional field + const { pattern, field, description } = parseSpamPatternArgs(rawArgs); + if (!pattern) { + return ctx.reply( + fmt`Invalid syntax. Usage: +${code('/addspampattern "pattern" [bio|channel|both]')} +${code("/addspampattern /regex/i [bio|channel|both]")} + +For help: ${code("/spampatternhelp")}`, + ); + } + + await addPattern(ctx, userId, pattern, field, description); + }); + + /** + * Command: /removespampattern + * Removes a spam pattern by its ID. + * + * Permission: Owner only + */ + bot.command("removespampattern", ownerOnly, async (ctx) => { + const userId = ctx.from?.id; + if (!userId) return; + + const args = ctx.message?.text.split(" ").slice(1) || []; + const idStr = args[0]; + + if (!idStr) { + return ctx.reply( + fmt`Usage: ${code("/removespampattern ")} + +Use ${code("/listspampatterns")} to see pattern IDs.`, + ); + } + + const patternId = parseInt(idStr, 10); + if (Number.isNaN(patternId)) { + return ctx.reply("Invalid pattern ID. Must be a number."); + } + + const existing = get( + "SELECT * FROM spam_patterns WHERE id = ?", + [patternId], + ); + if (!existing) { + return ctx.reply(`No spam pattern found with ID ${patternId}.`); + } + + execute("DELETE FROM spam_patterns WHERE id = ?", [patternId]); + invalidateSpamPatternCache(); + + StructuredLogger.logSecurityEvent("Spam pattern removed", { + userId, + operation: "remove_spam_pattern", + patternId, + pattern: existing.pattern, + matchField: existing.match_field, + }); + + await ctx.reply( + fmt`Spam pattern #${patternId} removed. +Pattern: ${code(existing.pattern)} +Field: ${existing.match_field}`, + ); + }); + + /** + * Command: /listspampatterns + * Lists all active spam profile patterns. + * + * Permission: Admin or higher + */ + bot.command("listspampatterns", adminOrHigher, async (ctx) => { + const rows = query( + "SELECT * FROM spam_patterns ORDER BY id", + [], + ); + + if (rows.length === 0) { + return ctx.reply( + "No custom spam patterns configured. Only built-in patterns are active.", + ); + } + + const lines = rows.map((r) => { + const desc = r.description ? ` -- ${r.description}` : ""; + const date = new Date(r.created_at * 1000).toISOString().split("T")[0]; + return `#${r.id} [${r.match_field}] ${r.pattern}${desc} (${date})`; + }); + + await ctx.reply( + fmt`${bold("Spam Profile Patterns")} + +${lines.join("\n")} + +${bold("Built-in patterns")} (always active): +18+, secret place, onlyfans, adult content, private video, hot content, free nudes, dating site, sexy content, bonus scam, elon musk + +Use ${code("/removespampattern ")} to remove a pattern.`, + ); + }); + + /** + * Command: /testspampattern + * Tests a pattern against sample text without saving it. + * + * Permission: Owner only + */ + bot.command("testspampattern", ownerOnly, async (ctx) => { + const rawArgs = ctx.message?.text.split(" ").slice(1).join(" ") || ""; + if (!rawArgs.trim()) { + return ctx.reply( + fmt`Usage: ${code('/testspampattern "pattern" sample text here')} + +Tests whether a pattern would match the given sample text.`, + ); + } + + const { pattern, remainder } = parsePatternAndRemainder(rawArgs); + if (!pattern || !remainder) { + return ctx.reply( + fmt`Usage: ${code('/testspampattern "pattern" sample text here')} +${code("/testspampattern /regex/i sample text here")}`, + ); + } + + const validation = validatePattern(pattern); + if (!validation.isValid || !validation.sanitized) { + return ctx.reply(`Invalid pattern: ${validation.error || "unknown"}`); + } + + try { + const compiled = compileSafeRegex(validation.sanitized); + const matches = compiled.regex.test(remainder); + await ctx.reply( + fmt`${bold("Pattern Test")} + +Pattern: ${code(pattern)} (${compiled.type}) +Sample: ${code(remainder)} +Result: ${matches ? "MATCH" : "no match"}`, + ); + } catch (error) { + await ctx.reply( + `Pattern compilation error: ${error instanceof Error ? error.message : "unknown"}`, + ); + } + }); + + /** + * Command: /spampatternhelp + * Displays detailed usage guide for spam pattern management. + * + * Permission: Admin or higher + */ + bot.command("spampatternhelp", adminOrHigher, async (ctx) => { + await ctx.reply( + fmt`${bold("Spam Profile Pattern Guide")} + +Spam patterns are matched against the profiles of users who react to messages. If a low-message user's profile matches, they are permanently banned. + +${bold("Profile Fields:")} + +${bold("bio")} - The "Bio" text on a user's profile +Example: A spammer with bio "Hot content 18+ click here" + +${bold("channel")} - The personal channel linked on a user's profile +Example: A scam bot with channel titled "BONUS 1000$" + +${bold("both")} (default) - Matches against both fields + +${bold("Pattern Types:")} + +${bold("Simple text")} (case-insensitive substring match): +${code('/addspampattern "bonus" channel')} +Matches: "BONUS 1000$", "Get your bonus now" + +${bold("Wildcards")} (* = any chars, ? = single char): +${code('/addspampattern "free*nudes" bio')} +Matches: "free nudes", "free hot nudes" + +${bold("Regex")} (/pattern/flags format): +${code("/addspampattern /bonus\\s*\\d+\\s*\\$/i channel")} +Matches: "BONUS 1000$", "bonus 500 $" + +${bold("Examples:")} + +Block "crypto giveaway" scam channels: +${code('/addspampattern "crypto giveaway" channel')} + +Block channels with dollar amounts: +${code("/addspampattern /\\d+\\s*\\$/i channel")} + +Block bio spam with adult content: +${code("/addspampattern /cam\\s*girl/i bio")} + +Block "Elon Musk" scam channels: +${code('/addspampattern "elon musk" channel')} + +${bold("Testing:")} +${code('/testspampattern "bonus" BONUS 1000$')} +Tests a pattern against sample text without saving. + +${bold("Management:")} +${code("/listspampatterns")} - View all patterns +${code("/removespampattern ")} - Remove by ID`, + ); + }); + + logger.info("Spam pattern management handlers registered"); +} + +// --- Internal helpers --- + +/** + * Adds a spam pattern to the database after validation. + * + * @param ctx - Telegraf context for replying + * @param userId - ID of the user adding the pattern + * @param pattern - Pattern string to add + * @param field - Which profile field to match + * @param description - Optional description + */ +export async function addPattern( + ctx: Context, + userId: number, + pattern: string, + field: SpamPatternField, + description?: string, +): Promise { + // Validate the pattern + const validation = validatePattern(pattern); + if (!validation.isValid || !validation.sanitized) { + await ctx.reply(`Invalid pattern: ${validation.error || "unknown"}`); + return; + } + + const sanitized = validation.sanitized; + + // Try to compile it + try { + compileSafeRegex(sanitized); + } catch (error) { + await ctx.reply( + `Pattern compilation failed: ${error instanceof Error ? error.message : "unknown"}`, + ); + return; + } + + // Check for duplicate + const existing = get( + "SELECT * FROM spam_patterns WHERE pattern = ?", + [sanitized], + ); + if (existing) { + await ctx.reply( + fmt`Pattern already exists as #${existing.id} [${existing.match_field}].`, + ); + return; + } + + // Insert + execute( + "INSERT INTO spam_patterns (pattern, match_field, description, added_by) VALUES (?, ?, ?, ?)", + [sanitized, field, description || null, userId], + ); + invalidateSpamPatternCache(); + + const inserted = get( + "SELECT * FROM spam_patterns WHERE pattern = ?", + [sanitized], + ); + + StructuredLogger.logSecurityEvent("Spam pattern added", { + userId, + operation: "add_spam_pattern", + patternId: inserted?.id, + pattern: sanitized, + matchField: field, + description, + }); + + await ctx.reply( + fmt`Spam pattern added (#${inserted?.id || "?"}). +Pattern: ${code(sanitized)} +Field: ${field}${description ? `\nDescription: ${description}` : ""} + +Bots matching this in their profile will be auto-banned on first reaction.`, + ); +} + +/** + * Parses command arguments for /addspampattern. + * Supports quoted patterns and optional field/description. + * + * @param rawArgs - Raw argument string after the command + * @returns Parsed pattern, field, and description + */ +function parseSpamPatternArgs(rawArgs: string): { + pattern: string | null; + field: SpamPatternField; + description?: string; +} { + let pattern: string | null = null; + let remaining = rawArgs.trim(); + let field: SpamPatternField = "both"; + + // Handle quoted pattern + if (remaining.startsWith('"')) { + const endQuote = remaining.indexOf('"', 1); + if (endQuote === -1) { + return { pattern: null, field }; + } + pattern = remaining.substring(1, endQuote); + remaining = remaining.substring(endQuote + 1).trim(); + } + // Handle regex pattern (/.../) + else if (remaining.startsWith("/")) { + const lastSlash = remaining.lastIndexOf("/"); + if (lastSlash <= 0) { + return { pattern: null, field }; + } + // Check for flags after the last slash + let endIdx = lastSlash + 1; + while (endIdx < remaining.length && /[gimsu]/.test(remaining[endIdx])) { + endIdx++; + } + pattern = remaining.substring(0, endIdx); + remaining = remaining.substring(endIdx).trim(); + } + // Unquoted single-word pattern + else { + const parts = remaining.split(/\s+/); + pattern = parts[0]; + remaining = parts.slice(1).join(" ").trim(); + } + + if (!pattern) return { pattern: null, field }; + + // Parse optional field + const parts = remaining.split(/\s+/); + if (parts[0] && ["bio", "channel", "both"].includes(parts[0])) { + field = parts[0] as SpamPatternField; + remaining = parts.slice(1).join(" ").trim(); + } + + return { + pattern, + field, + description: remaining || undefined, + }; +} + +/** + * Parses a pattern and remaining text from /testspampattern args. + * + * @param rawArgs - Raw argument string + * @returns Pattern and remainder text + */ +function parsePatternAndRemainder(rawArgs: string): { + pattern: string | null; + remainder: string | null; +} { + const remaining = rawArgs.trim(); + + // Quoted pattern + if (remaining.startsWith('"')) { + const endQuote = remaining.indexOf('"', 1); + if (endQuote === -1) return { pattern: null, remainder: null }; + const pattern = remaining.substring(1, endQuote); + const rest = remaining.substring(endQuote + 1).trim(); + return { pattern, remainder: rest || null }; + } + + // Regex pattern + if (remaining.startsWith("/")) { + const lastSlash = remaining.lastIndexOf("/"); + if (lastSlash <= 0) return { pattern: null, remainder: null }; + let endIdx = lastSlash + 1; + while (endIdx < remaining.length && /[gimsu]/.test(remaining[endIdx])) { + endIdx++; + } + const pattern = remaining.substring(0, endIdx); + const rest = remaining.substring(endIdx).trim(); + return { pattern, remainder: rest || null }; + } + + // First word as pattern + const spaceIdx = remaining.indexOf(" "); + if (spaceIdx === -1) return { pattern: null, remainder: null }; + return { + pattern: remaining.substring(0, spaceIdx), + remainder: remaining.substring(spaceIdx + 1).trim(), + }; +} diff --git a/src/utils/keyboards.ts b/src/utils/keyboards.ts index e77e85e..61278c8 100644 --- a/src/utils/keyboards.ts +++ b/src/utils/keyboards.ts @@ -241,6 +241,21 @@ export const mainMenuKeyboard: InlineKeyboardMarkup = { ], }; +/** + * Spam pattern field selection keyboard. + * Used in the interactive /addspampattern flow. + */ +export const spamPatternFieldKeyboard: InlineKeyboardMarkup = { + inline_keyboard: [ + [ + { text: "Bio", callback_data: "spamfield_bio" }, + { text: "Channel Title", callback_data: "spamfield_channel" }, + ], + [{ text: "Both", callback_data: "spamfield_both" }], + [{ text: "Cancel", callback_data: "cancel" }], + ], +}; + /** * Restriction severity level keyboard. * Used in step 2 of the interactive /addrestriction flow.