diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 829b8cf..d54f67e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,13 +11,17 @@ jobs: name: Lint Code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Setup Node + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: '16' - - name: Install Dependencies + node-version: '18.x' + + - name: Install dependencies run: yarn install + - name: Run ESLint run: yarn lint @@ -25,12 +29,16 @@ jobs: name: Audit Dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Setup Node + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: '16' - - name: Install Dependencies + node-version: '18.x' + + - name: Install dependencies run: yarn install - - name: Run Yarn Audit + + - name: Run yarn audit run: yarn audit --level moderate diff --git a/.gitignore b/.gitignore index e0c2742..085aa4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,36 @@ +# Environment variables .env + +# Local configuration with sensitive data +config.js + +# Runtime data and settings +config/ +data/ + +# Dependencies node_modules/ -update.sh + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity \ No newline at end of file diff --git a/README.md b/README.md index 7723433..b64c701 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,212 @@ # Telegram Ban Bot -## Overview +Automated user filtering bot for Telegram groups that monitors usernames and display names against configurable patterns. -This bot automatically triggers actions against users whose usernames match banned patterns. It monitors: -1. New users joining a group -2. Username changes after joining -3. Messages sent by users +## Features + +- **Group-specific pattern management** - Each group maintains separate filter lists +- **Real-time monitoring** - Checks users on join, name changes (30s window), and messages +- **Pattern types** - Text, wildcards (`*`, `?`), and regex patterns +- **Flexible actions** - Ban (permanent) or kick (temporary) per group +- **Hit tracking** - Statistics on pattern matches +- **Admin interface** - DM menu system for pattern management +- **Pattern sharing** - Browse and copy patterns between groups ## Installation -1. **Clone and Install:** +```bash +git clone +cd telegram-ban-bot +yarn install +``` + +## Configuration + +1. **Environment file** - Copy and configure: + + ```bash + cp example.env .env + ``` + +2. **Bot token** - Add your Telegram bot token to `.env`: + ```bash - git clone https://github.com/yourusername/telegram-ban-bot.git - cd telegram-ban-bot - yarn install + BOT_TOKEN=your_bot_token_here ``` -2. **Configure:** - - Create `.env` file: - ``` - BOT_TOKEN=your_bot_token_here - BANNED_PATTERNS_FILE=banned_patterns.toml - DEFAULT_ACTION=ban # or 'kick' - SETTINGS_FILE=settings.json - ``` - - Edit `config.js` with your user IDs and group IDs - - Create initial `banned_patterns.toml` - -3. **Start:** +3. **Configuration file** - Copy and configure: + ```bash - yarn start + cp config.example.js config.js ``` -## Configuration Files +4. **User/Group IDs** - Edit `config.js`: + + ```javascript + export const WHITELISTED_USER_IDS = [123456789]; // Global admins + export const WHITELISTED_GROUP_IDS = [-1001234567890]; // Monitored groups + ``` + +5. **Create directories**: + + ```bash + mkdir -p config data/banned_patterns + ``` -### config.js -```js -import dotenv from 'dotenv'; -dotenv.config(); +## Usage -export const BOT_TOKEN = process.env.BOT_TOKEN; -export const BANNED_PATTERNS_FILE = process.env.BANNED_PATTERNS_FILE || 'banned_patterns.toml'; -export const DEFAULT_ACTION = process.env.DEFAULT_ACTION || 'ban'; -export const SETTINGS_FILE = process.env.SETTINGS_FILE || 'settings.json'; -export const WHITELISTED_USER_IDS = [123456789, 987654321]; -export const WHITELISTED_GROUP_IDS = [-1001111111111]; +```bash +yarn start ``` -### banned_patterns.toml -```toml -patterns = [ - "spam", - "/^bad.*user$/i", - "*malicious*" -] +## Commands + +### Private Chat (Authorized Users) + +- `/start` - Initialize bot and show welcome +- `/menu` - Interactive configuration interface +- `/addFilter ` - Add pattern to selected group +- `/removeFilter ` - Remove pattern from selected group +- `/listFilters` - Show all patterns for selected group +- `/setaction ` - Set action for selected group +- `/testpattern ` - Test pattern matching +- `/hits [pattern]` - Show hit statistics +- `/help` - Command reference + +### Any Chat + +- `/chatinfo` - Display chat ID and configuration status + +## Pattern Formats + +| Type | Format | Example | Matches | +|------|--------|---------|---------| +| Text | `pattern` | `spam` | "SPAM", "spammer", "123spam" | +| Wildcard | `*pattern*` | `*bot*` | "testbot", "bot_user", "mybot123" | +| Wildcard | `pattern*` | `evil*` | "evil", "eviluser", "evil123" | +| Wildcard | `test?` | `test?` | "test1", "testa", "tests" | +| Regex | `/pattern/flags` | `/^bad.*$/i` | Lines starting with "bad" (case-insensitive) | + +## Authorization Levels + +1. **Global Admins** - Users in `WHITELISTED_USER_IDS` + - Manage all whitelisted groups + - Full configuration access + +2. **Group Admins** - Telegram group administrators + - Manage only their own groups + - Group must be in `WHITELISTED_GROUP_IDS` + +## File Structure + +```sh +. +├── bot.js # Main bot logic +├── security.js # Pattern validation and matching +├── config.js # User/group configuration and paths +├── config/ +│ ├── settings.json # Runtime settings (auto-generated) +│ └── hit_counters.json # Statistics (auto-generated) +├── data/ +│ └── banned_patterns/ # Pattern storage (auto-generated) +│ └── patterns_.toml +└── tests/ # Test suite ``` -## Features +## Security Features + +- Pattern validation with length limits (500 chars) +- Regex timeout protection (100ms) +- Control character filtering +- Dangerous regex detection +- Safe compilation with error handling + +## Monitoring Triggers -### Patterns +The bot checks users when they: -Supports three matching modes: -- **Plain text:** Case-insensitive substring match (e.g., `spam`) -- **Wildcards:** `*` for any sequence, `?` for one character (e.g., `*bad*`) -- **Regex:** Custom regex patterns (e.g., `/^evil.*$/i`) +1. Join a group (immediate check) +2. Change username/display name (monitored for 30 seconds) +3. Send messages (ongoing check) -### Actions +## Pattern Management -Two configurable actions when a user matches patterns: -- **Ban:** Permanently bans the user from the group -- **Kick:** Removes the user but allows them to rejoin +### Interactive Menu -### User Commands +Access via `/menu` in private chat: -Available in private chat for authorized users: +- Select target group +- Add/remove patterns +- Toggle ban/kick actions +- Browse patterns from other groups +- Copy patterns between groups -- `/start` - Begin configuration and show help -- `/help` - Show usage information -- `/menu` - Display the filter management menu -- `/addFilter ` - Add a filter pattern -- `/removeFilter ` - Remove a filter pattern -- `/listFilters` - Show all active filter patterns -- `/setaction ` - Change the action for matched usernames -- `/chatinfo` - Show chat information (works in groups too) +### Direct Commands -### Authorization +Use specific commands for scripting or quick changes: -Users can configure the bot if they: -- Are listed in `WHITELISTED_USER_IDS` -- Are admin in any whitelisted group -- Are admin in the current group (for group commands) +```bash +/addFilter *scam* +/setaction kick +/listFilters +``` + +## Testing + +```bash +yarn test +``` + +## Deployment + +### Environment Variables + +Optional environment variable overrides: + +- `BOT_TOKEN` - Telegram bot token (required) +- `BANNED_PATTERNS_DIR` - Pattern storage directory (default: `./data/banned_patterns`) +- `SETTINGS_FILE` - Settings file path (default: `./config/settings.json`) + +### Systemd Service + +Example service file: + +```ini +[Unit] +Description=Telegram Ban Bot +After=network.target + +[Service] +Type=simple +User=telegram +WorkingDirectory=/path/to/bot +ExecStart=/usr/bin/node bot.js +Restart=always +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target +``` ## Troubleshooting -- Use `/chatinfo` to verify group IDs and current settings -- For supergroups, IDs must have `-100` prefix in config.js -- Bot requires admin privileges with ban permissions -- Check console logs for detailed operation information +- **Group ID verification** - Use `/chatinfo` to confirm group IDs +- **Supergroup IDs** - Must include `-100` prefix in config +- **Bot permissions** - Requires admin privileges with ban permissions +- **Pattern testing** - Use `/testpattern` to verify regex/wildcard behavior +- **Logs** - Console output shows detailed operation information + +## Updates + +Use the included update script for production deployments: + +```bash +./update.sh +``` + +Script performs: + +- Git pull from main branch +- Dependency updates +- Service restart +- Preserves local configuration diff --git a/banned_patterns.toml b/banned_patterns.toml deleted file mode 100644 index ce3cce2..0000000 --- a/banned_patterns.toml +++ /dev/null @@ -1,8 +0,0 @@ -patterns = [ - "CHILD*PORN", - "CAZBIT", - "child*p*", - "/.*test[^A-Za-z]+filter.*/i", - "/.*wild[^A-Za-z]+horn.*/i", - "/wild.*horn/i" -] diff --git a/bot.js b/bot.js index 1488822..6e618fd 100644 --- a/bot.js +++ b/bot.js @@ -1,29 +1,39 @@ -// bot.js +// bot.js - Fixed imports import { Telegraf } from 'telegraf'; import dotenv from 'dotenv'; import fs from 'fs/promises'; import toml from 'toml'; + +// Import configuration constants import { BOT_TOKEN, - BANNED_PATTERNS_FILE, + BANNED_PATTERNS_DIR, WHITELISTED_USER_IDS, WHITELISTED_GROUP_IDS, - DEFAULT_ACTION, - SETTINGS_FILE -} from './config.js'; + SETTINGS_FILE, + HIT_COUNTER_FILE +} from './config/config.js'; + +// Import security functions +import { + createPatternObject, + matchesPattern +} from './security.js'; dotenv.config(); const bot = new Telegraf(BOT_TOKEN); // In-memory Data -let bannedPatterns = []; +const groupPatterns = new Map(); // Map of groupId -> patterns array const adminSessions = new Map(); const newJoinMonitors = {}; const knownGroupAdmins = new Set(); + +// Settings will be loaded from SETTINGS_FILE at startup let settings = { - action: DEFAULT_ACTION // 'ban' or 'kick' + groupActions: {} // Per-group actions (loaded from settings.json) }; // Ban messages @@ -46,825 +56,1766 @@ const kickMessages = [ "User {userId} needs to rethink their life choices." ]; +let hitCounters = {}; // Structure: { groupId: { pattern: count, ... }, ... } + + // Utility Functions function isChatAllowed(ctx) { - console.log(`Chat check: ${ctx.chat?.id} (type: ${ctx.chat?.type})`); const chatType = ctx.chat?.type; if (chatType === 'group' || chatType === 'supergroup') { const isAllowed = WHITELISTED_GROUP_IDS.includes(ctx.chat.id); - console.log(`Group ${ctx.chat.id} allowed: ${isAllowed}`); + console.log(`[CHAT_CHECK] Group ${ctx.chat.id} (${chatType}) - Allowed: ${isAllowed}`); return isAllowed; } + console.log(`[CHAT_CHECK] Non-group chat (${chatType}) - Always allowed`); return true; } -async function deleteUserMessage(ctx) { - if (ctx.chat.type === 'private') { - try { - await ctx.deleteMessage(); - } catch (error) { - console.error('Failed to delete user message:', error.description || error); - } +function canManageGroup(userId, groupId) { + // Global admins can manage any group + if (WHITELISTED_USER_IDS.includes(userId)) { + console.log(`[AUTH] User ${userId} can manage group ${groupId} - global admin`); + return true; + } + + // Check session for group-specific authorization + const session = adminSessions.get(userId); + if (session && session.authorizedGroupId === groupId) { + console.log(`[AUTH] User ${userId} can manage group ${groupId} - group admin`); + return true; } + + console.log(`[AUTH] User ${userId} cannot manage group ${groupId}`); + return false; +} + +function getRandomMessage(userId, isBan = true) { + const messageArray = isBan ? banMessages : kickMessages; + const randomIndex = Math.floor(Math.random() * messageArray.length); + const message = messageArray[randomIndex].replace('{userId}', userId); + console.log(`[MESSAGE] Generated ${isBan ? 'ban' : 'kick'} message for user ${userId}: "${message}"`); + return message; +} + +function getGroupAction(groupId) { + const action = settings.groupActions[groupId] || 'kick'; + console.log(`[ACTION] Group ${groupId} action: ${action.toUpperCase()}`); + return action; } async function checkAndCacheGroupAdmin(userId, bot) { - if (WHITELISTED_USER_IDS.includes(userId)) return true; + console.log(`[ADMIN_CHECK] Checking admin status for user ${userId}`); + + if (WHITELISTED_USER_IDS.includes(userId)) { + console.log(`[ADMIN_CHECK] User ${userId} is in whitelist - granted admin`); + return true; + } + for (const groupId of WHITELISTED_GROUP_IDS) { try { const user = await bot.telegram.getChatMember(groupId, userId); if (user.status === 'administrator' || user.status === 'creator') { knownGroupAdmins.add(userId); + console.log(`[ADMIN_CHECK] User ${userId} is admin in group ${groupId} - cached`); return true; } - } catch (error) { - // Ignore if user not in that group + } catch { + console.log(`[ADMIN_CHECK] User ${userId} not found in group ${groupId}`); } } + + console.log(`[ADMIN_CHECK] User ${userId} is not an admin in any group`); return false; } +// auth check async function isAuthorized(ctx) { - if (!isChatAllowed(ctx)) return false; const userId = ctx.from.id; - - if (WHITELISTED_USER_IDS.includes(userId) || knownGroupAdmins.has(userId)) { + const chatType = ctx.chat.type; + + console.log(`[AUTH] Checking authorization for user ${userId} in ${chatType} chat`); + + // allow only whitelisted groups + if (!isChatAllowed(ctx)) { + console.log(`[AUTH] Chat not allowed - denied`); + return false; + } + + // whitelisted - global admin level access + if (WHITELISTED_USER_IDS.includes(userId)) { + console.log(`[AUTH] User ${userId} authorized via global whitelist`); return true; } - - if (ctx.chat.type === 'private') { - return await checkAndCacheGroupAdmin(userId, bot); - } else if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') { + + // group admin - admin of whitelisted group + if (chatType === 'group' || chatType === 'supergroup') { + const groupId = ctx.chat.id; + if (!WHITELISTED_GROUP_IDS.includes(groupId)) { + console.log(`[AUTH] Group ${groupId} not whitelisted - denied`); + return false; + } try { const user = await ctx.getChatMember(userId); - const isGroupAdmin = (user.status === 'administrator' || user.status === 'creator'); - if (isGroupAdmin) { - knownGroupAdmins.add(userId); + if (user.status === 'administrator' || user.status === 'creator') { + let session = adminSessions.get(userId) || { chatId: ctx.chat.id }; + session.authorizedGroupId = groupId; + session.isGlobalAdmin = false; + adminSessions.set(userId, session); + console.log(`[AUTH] User ${userId} is admin in group ${groupId} - authorized`); return true; } - return false; } catch (e) { - console.error('Error checking group membership:', e); + console.error(`[AUTH] Error checking group membership: ${e.message}`); return false; } } - return false; -} - -function getRandomMessage(userId, isBan = true) { - const messageArray = isBan ? banMessages : kickMessages; - const randomIndex = Math.floor(Math.random() * messageArray.length); - return messageArray[randomIndex].replace('{userId}', userId); -} -/** - * Corrected to parse optional flags (like "/pattern/gi") properly. - */ -function patternToRegex(patternStr) { - // If wrapped in /.../, strip the slashes and parse any trailing flags - if (patternStr.startsWith('/') && patternStr.endsWith('/') && patternStr.length > 2) { - // e.g. patternStr = "/wild.*horn/i" - // inner => "wild.*horn/i" - const inner = patternStr.slice(1, -1); - // Attempt to split out trailing flags after the final slash - // Example: "wild.*horn/i" => patternBody: "wild.*horn", patternFlags: "i" - const match = inner.match(/^(.+?)(?:\/([a-zA-Z]*))?$/); - if (match) { - const patternBody = match[1]; - // If user provided flags, use them; otherwise default to "i" - const patternFlags = match[2] || 'i'; - return new RegExp(patternBody, patternFlags); - } else { - // Fallback: no trailing flags recognized, just force 'i' - return new RegExp(inner, 'i'); + // allow dm interaction only from approved + if (chatType === 'private') { + for (const groupId of WHITELISTED_GROUP_IDS) { + try { + const user = await bot.telegram.getChatMember(groupId, userId); + if (user.status === 'administrator' || user.status === 'creator') { + let session = adminSessions.get(userId) || { chatId: ctx.chat.id }; + session.authorizedGroupId = groupId; + session.isGlobalAdmin = false; + session.selectedGroupId = groupId; + adminSessions.set(userId, session); + console.log(`[AUTH] User ${userId} is admin in group ${groupId} - authorized for DM`); + return true; + } + } catch { + // Not admin in this group + } } } - - // Otherwise handle wildcard patterns or plain text - if (!patternStr.includes('*') && !patternStr.includes('?')) { - // Plain substring match (case-insensitive) - return new RegExp(patternStr, 'i'); - } - - // Convert wildcards (* => .*, ? => .) - const escaped = patternStr.replace(/[-\\/^$+?.()|[\]{}]/g, '\\$&'); - const wildcardRegex = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); - return new RegExp(wildcardRegex, 'i'); + + // deny all other users + console.log(`[AUTH] Authorization denied for user ${userId}`); + return false; } -/** - * Checks if the username or the combined display name (plus a few variations) - * matches any banned pattern. - */ -function isBanned(username, firstName, lastName) { - // 1) Check the username (if present) - if (username) { - const cleanUsername = username.toLowerCase(); - for (const pattern of bannedPatterns) { - if (pattern.regex.test(cleanUsername)) { - console.log(`Match found in username: "${cleanUsername}" matched pattern: ${pattern.raw}`); - return true; - } - } - } +// Pattern matching using security module +async function isBanned(username, firstName, lastName, groupId) { + console.log(`[BAN_CHECK] Checking user: @${username || 'no_username'}, Name: ${[firstName, lastName].filter(Boolean).join(' ')}, Group: ${groupId}`); - // 2) Check the display name - const displayName = [firstName, lastName].filter(Boolean).join(' '); - if (!displayName) return false; + const patterns = groupPatterns.get(groupId) || []; - const cleanName = displayName.toLowerCase(); - // Original name - for (const pattern of bannedPatterns) { - if (pattern.regex.test(cleanName)) { - console.log(`Match found in display name: "${cleanName}" matched pattern: ${pattern.raw}`); - return true; - } + // Quick exit if no patterns + if (patterns.length === 0) { + console.log(`[BAN_CHECK] No patterns configured for group ${groupId} - not banned`); + return false; } - // Name with quotes removed - const noQuotes = cleanName.replace(/["'`]/g, ''); - if (noQuotes !== cleanName) { - for (const pattern of bannedPatterns) { - if (pattern.regex.test(noQuotes)) { - console.log(`Match found in display name (no quotes): "${noQuotes}" matched pattern: ${pattern.raw}`); - return true; - } - } - } + console.log(`[BAN_CHECK] Testing against ${patterns.length} patterns`); - // Name with spaces removed - const noSpaces = cleanName.replace(/\s+/g, ''); - if (noSpaces !== cleanName) { - for (const pattern of bannedPatterns) { - if (pattern.regex.test(noSpaces)) { - console.log(`Match found in display name (no spaces): "${noSpaces}" matched pattern: ${pattern.raw}`); - return true; + // Test each pattern safely + for (let i = 0; i < patterns.length; i++) { + const pattern = patterns[i]; + console.log(`[BAN_CHECK] Testing pattern ${i + 1}/${patterns.length}: "${pattern.raw}"`); + + try { + // Test username + if (username) { + const usernameMatch = await matchesPattern(pattern.raw, username.toLowerCase()); + if (usernameMatch) { + incrementHitCounter(groupId, pattern.raw); // <--- ADD + console.log(`[BAN_CHECK] ✅ BANNED - Username "${username}" matched pattern "${pattern.raw}"`); + return true; + } } - } - } - - // Name with both quotes and spaces removed - const normalized = cleanName.replace(/["'`\s]/g, ''); - if (normalized !== cleanName && normalized !== noQuotes && normalized !== noSpaces) { - for (const pattern of bannedPatterns) { - if (pattern.regex.test(normalized)) { - console.log(`Match found in normalized name: "${normalized}" matched pattern: ${pattern.raw}`); - return true; + + + // Test display name variations + const displayName = [firstName, lastName].filter(Boolean).join(' '); + if (displayName) { + const variations = [ + displayName, + displayName.replace(/["'`]/g, ''), + displayName.replace(/\s+/g, ''), + displayName.replace(/["'`\s]/g, '') + ]; + + for (const variation of variations) { + const nameMatch = await matchesPattern(pattern.raw, variation.toLowerCase()); + if (nameMatch) { + incrementHitCounter(groupId, pattern.raw); // <--- ADD + console.log(`[BAN_CHECK] ✅ BANNED - Display name "${variation}" matched pattern "${pattern.raw}"`); + return true; + } } + + } + } catch (err) { + console.error(`[BAN_CHECK] Error testing pattern "${pattern.raw}": ${err.message}`); + continue; } } + console.log(`[BAN_CHECK] User not banned - no pattern matches`); return false; } // Persistence Functions -async function loadBannedPatterns() { +async function ensureBannedPatternsDirectory() { + console.log(`[INIT] Creating patterns directory: ${BANNED_PATTERNS_DIR}`); + try { + await fs.mkdir(BANNED_PATTERNS_DIR, { recursive: true }); + console.log(`[INIT] Patterns directory ready`); + } catch (err) { + console.error(`[INIT] Error creating directory ${BANNED_PATTERNS_DIR}:`, err); + } +} + +async function getGroupPatternFilePath(groupId) { + const path = `${BANNED_PATTERNS_DIR}/patterns_${groupId}.toml`; + console.log(`[FILE] Pattern file path for group ${groupId}: ${path}`); + return path; +} + +async function loadGroupPatterns(groupId) { + console.log(`[LOAD] Loading patterns for group ${groupId}`); + try { - const data = await fs.readFile(BANNED_PATTERNS_FILE, 'utf-8'); + const filePath = await getGroupPatternFilePath(groupId); + const data = await fs.readFile(filePath, 'utf-8'); const parsed = toml.parse(data); - if (parsed.patterns && Array.isArray(parsed.patterns)) { - bannedPatterns = parsed.patterns.map(pt => ({ - raw: pt, - regex: patternToRegex(pt) - })); + + if (!parsed.patterns || !Array.isArray(parsed.patterns)) { + console.log(`[LOAD] No patterns array found in file for group ${groupId}`); + return []; } - console.log(`Loaded ${bannedPatterns.length} banned patterns`); + + console.log(`[LOAD] Found ${parsed.patterns.length} patterns in file`); + + const validatedPatterns = []; + for (let i = 0; i < parsed.patterns.length; i++) { + const pt = parsed.patterns[i]; + try { + // Use security module to validate and create pattern objects + const patternObj = createPatternObject(pt); + validatedPatterns.push(patternObj); + console.log(`[LOAD] ✅ Pattern ${i + 1}: "${pt}" - validated`); + + // Safety limit + if (validatedPatterns.length >= 100) { + console.warn(`[LOAD] Reached maximum patterns (100) for group ${groupId}`); + break; + } + } catch (err) { + console.warn(`[LOAD] ❌ Pattern ${i + 1}: "${pt}" - skipped: ${err.message}`); + } + } + + console.log(`[LOAD] Loaded ${validatedPatterns.length} valid patterns for group ${groupId}`); + return validatedPatterns; } catch (err) { - console.error(`Error reading ${BANNED_PATTERNS_FILE}. Starting with empty list.`, err); - bannedPatterns = []; + if (err.code !== 'ENOENT') { + console.error(`[LOAD] Error reading patterns for group ${groupId}:`, err); + } else { + console.log(`[LOAD] No pattern file exists for group ${groupId}`); + } + return []; } } -async function saveBannedPatterns() { - const lines = bannedPatterns.map(({ raw }) => ` "${raw}"`).join(',\n'); +async function saveGroupPatterns(groupId, patterns) { + console.log(`[SAVE] Saving ${patterns.length} patterns for group ${groupId}`); + + const lines = patterns.map(({ raw }) => ` "${raw}"`).join(',\n'); const content = `patterns = [\n${lines}\n]\n`; + try { - await fs.writeFile(BANNED_PATTERNS_FILE, content); + const filePath = await getGroupPatternFilePath(groupId); + await fs.writeFile(filePath, content); + console.log(`[SAVE] ✅ Successfully saved patterns to ${filePath}`); } catch (err) { - console.error(`Error writing to ${BANNED_PATTERNS_FILE}`, err); + console.error(`[SAVE] ❌ Error writing patterns for group ${groupId}:`, err); + } +} + +async function loadAllGroupPatterns() { + console.log(`[INIT] Loading patterns for all whitelisted groups`); + await ensureBannedPatternsDirectory(); + + for (const groupId of WHITELISTED_GROUP_IDS) { + const patterns = await loadGroupPatterns(groupId); + groupPatterns.set(groupId, patterns); + console.log(`[INIT] Group ${groupId}: loaded ${patterns.length} patterns`); } + + console.log(`[INIT] Pattern loading complete - ${groupPatterns.size} groups configured`); } async function loadSettings() { + console.log(`[SETTINGS] Loading settings from ${SETTINGS_FILE}`); + try { const data = await fs.readFile(SETTINGS_FILE, 'utf-8'); const loadedSettings = JSON.parse(data); settings = { - ...settings, + groupActions: {}, ...loadedSettings }; - // Validate the action setting - if (settings.action !== 'ban' && settings.action !== 'kick') { - settings.action = DEFAULT_ACTION; + + console.log(`[SETTINGS] Loaded existing settings:`, settings); + } catch { + console.log(`[SETTINGS] No settings file found or error reading - creating new settings`); + settings = { + groupActions: {} + }; + } + + // Ensure all whitelisted groups have settings entries + let settingsChanged = false; + WHITELISTED_GROUP_IDS.forEach(groupId => { + if (!settings.groupActions[groupId]) { + settings.groupActions[groupId] = 'kick'; + settingsChanged = true; + console.log(`[SETTINGS] Created default action for group ${groupId}: ${'kick'}`); } - console.log(`Loaded settings: action=${settings.action}`); - } catch (err) { - console.log(`No settings file found or error reading. Using defaults: action=${settings.action}`); - // Create the settings file if it doesn't exist - try { - await saveSettings(); - } catch (saveErr) { - console.error(`Failed to create initial settings file:`, saveErr); + }); + + // Remove settings for groups no longer whitelisted + Object.keys(settings.groupActions).forEach(groupId => { + const numericGroupId = parseInt(groupId); + if (!WHITELISTED_GROUP_IDS.includes(numericGroupId)) { + delete settings.groupActions[groupId]; + settingsChanged = true; + console.log(`[SETTINGS] Removed settings for non-whitelisted group ${groupId}`); } + }); + + if (settingsChanged) { + await saveSettings(); } + + console.log(`[SETTINGS] Final settings:`, settings.groupActions); } async function saveSettings() { + console.log(`[SETTINGS] Saving settings to ${SETTINGS_FILE}`); + try { await fs.writeFile(SETTINGS_FILE, JSON.stringify(settings, null, 2)); - console.log(`Settings saved: action=${settings.action}`); + console.log(`[SETTINGS] ✅ Settings saved successfully`); return true; } catch (err) { - console.error(`Error writing to ${SETTINGS_FILE}`, err); + console.error(`[SETTINGS] ❌ Error writing settings:`, err); return false; } } -// Action handlers +async function loadHitCounters() { + try { + const data = await fs.readFile(HIT_COUNTER_FILE, 'utf-8'); + hitCounters = JSON.parse(data); + console.log(`[HITCOUNTER] Loaded hit counters from disk.`); + } catch (err) { + hitCounters = {}; + if (err.code !== 'ENOENT') console.error(`[HITCOUNTER] Failed to load:`, err); + else console.log(`[HITCOUNTER] No hit counter file found. Starting fresh.`); + } +} + +async function saveHitCounters() { + try { + await fs.writeFile(HIT_COUNTER_FILE, JSON.stringify(hitCounters, null, 2)); + console.log(`[HITCOUNTER] Saved hit counters to disk.`); + } catch (err) { + console.error(`[HITCOUNTER] Failed to save hit counters:`, err); + } +} + +function incrementHitCounter(groupId, patternRaw) { + if (!groupId || !patternRaw) return; + if (!hitCounters[groupId]) hitCounters[groupId] = {}; + if (!hitCounters[groupId][patternRaw]) hitCounters[groupId][patternRaw] = 0; + hitCounters[groupId][patternRaw] += 1; + saveHitCounters(); +} + +function getHitStatsForGroup(groupId, topN = 5) { + const groupStats = hitCounters[groupId] || {}; + // Sort by count descending + return Object.entries(groupStats) + .sort((a, b) => b[1] - a[1]) + .slice(0, topN) + .map(([pattern, count]) => ({ pattern, count })); +} + +function getHitStatsForPattern(patternRaw) { + // Return all group stats for this pattern + const results = []; + for (const [groupId, patterns] of Object.entries(hitCounters)) { + if (patterns[patternRaw]) { + results.push({ groupId, count: patterns[patternRaw] }); + } + } + return results; +} + +// Action Handlers async function takePunishmentAction(ctx, userId, username, chatId) { - const isBan = settings.action === 'ban'; + const action = getGroupAction(chatId); + const isBan = action === 'ban'; + + console.log(`[PUNISH] Taking ${action.toUpperCase()} action against user ${userId} (@${username}) in chat ${chatId}`); + try { if (isBan) { - // Ban the user permanently await ctx.banChatMember(userId); } else { - // Kick the user (they can rejoin) await ctx.banChatMember(userId, { until_date: Math.floor(Date.now() / 1000) + 35 }); } - const message = getRandomMessage(userId, isBan); await ctx.reply(message); - - console.log(`${isBan ? 'Banned' : 'Kicked'} user: @${username} in chat ${chatId}`); + console.log(`[PUNISH] ✅ ${isBan ? 'Banned' : 'Kicked'} user ${userId} successfully`); return true; } catch (error) { - console.error(`Failed to ${isBan ? 'ban' : 'kick'} @${username}:`, error); + console.error(`[PUNISH] ❌ Failed to ${isBan ? 'ban' : 'kick'} user ${userId}:`, error); return false; } } -// User monitoring +// Get all patterns from all groups for browsing/copying +function getAllGroupPatterns() { + const allPatterns = new Map(); + + WHITELISTED_GROUP_IDS.forEach(groupId => { + const patterns = groupPatterns.get(groupId) || []; + if (patterns.length > 0) { + allPatterns.set(groupId, patterns); + } + }); + + return allPatterns; +} + +// Copy patterns from one group to another +async function copyPatternsToGroup(sourceGroupId, targetGroupId, patternIndices = null) { + console.log(`[COPY] Copying patterns from group ${sourceGroupId} to group ${targetGroupId}`); + + const sourcePatterns = groupPatterns.get(sourceGroupId) || []; + let targetPatterns = groupPatterns.get(targetGroupId) || []; + + if (sourcePatterns.length === 0) { + console.log(`[COPY] No patterns to copy from group ${sourceGroupId}`); + return { success: false, message: `No patterns found in source group ${sourceGroupId}` }; + } + + let patternsToCopy = []; + + if (patternIndices === null) { + // Copy all patterns + patternsToCopy = sourcePatterns; + console.log(`[COPY] Copying all ${sourcePatterns.length} patterns`); + } else { + // Copy specific patterns by index + patternsToCopy = patternIndices.map(index => sourcePatterns[index]).filter(Boolean); + console.log(`[COPY] Copying ${patternsToCopy.length} selected patterns`); + } + + let addedCount = 0; + let skippedCount = 0; + + for (const pattern of patternsToCopy) { + // Check if pattern already exists + if (!targetPatterns.some(p => p.raw === pattern.raw)) { + // Check if we're at the limit + if (targetPatterns.length >= 100) { + console.log(`[COPY] Maximum patterns (100) reached for group ${targetGroupId}`); + break; + } + + targetPatterns.push(pattern); + addedCount++; + console.log(`[COPY] Added pattern: "${pattern.raw}"`); + } else { + skippedCount++; + console.log(`[COPY] Skipped duplicate pattern: "${pattern.raw}"`); + } + } + + if (addedCount > 0) { + groupPatterns.set(targetGroupId, targetPatterns); + await saveGroupPatterns(targetGroupId, targetPatterns); + } + + console.log(`[COPY] Copy complete: ${addedCount} added, ${skippedCount} skipped`); + + return { + success: true, + added: addedCount, + skipped: skippedCount, + message: `Copied ${addedCount} patterns (${skippedCount} duplicates skipped)` + }; +} + +// User Monitoring function monitorNewUser(chatId, user) { const key = `${chatId}_${user.id}`; - console.log(`Started monitoring new user: ${user.id} in chat ${chatId}`); + console.log(`[MONITOR] Starting name change monitoring for user ${user.id} in chat ${chatId}`); + let attempts = 0; const interval = setInterval(async () => { attempts++; + console.log(`[MONITOR] Check ${attempts}/6 for user ${user.id}`); + try { const chatMember = await bot.telegram.getChatMember(chatId, user.id); const username = chatMember.user.username; const firstName = chatMember.user.first_name; const lastName = chatMember.user.last_name; - - // Log the user's current name information const displayName = [firstName, lastName].filter(Boolean).join(' '); - console.log(`Checking user ${user.id}: @${username || 'no_username'}, Name: ${displayName}`); - if (isBanned(username, firstName, lastName)) { - const isBan = settings.action === 'ban'; + console.log(`[MONITOR] Current name: @${username || 'no_username'}, Display: ${displayName}`); + + if (await isBanned(username, firstName, lastName, chatId)) { + const action = getGroupAction(chatId); + const isBan = action === 'ban'; + + console.log(`[MONITOR] 🚫 User ${user.id} matched pattern - taking action: ${action.toUpperCase()}`); + if (isBan) { await bot.telegram.banChatMember(chatId, user.id); } else { await bot.telegram.banChatMember(chatId, user.id, { until_date: Math.floor(Date.now() / 1000) + 35 }); } - const message = getRandomMessage(user.id, isBan); await bot.telegram.sendMessage(chatId, message); - console.log(`${isBan ? 'Banned' : 'Kicked'} user after name check: ID ${user.id} in chat ${chatId}`); clearInterval(interval); delete newJoinMonitors[key]; + console.log(`[MONITOR] Monitoring stopped - user ${user.id} was ${action}ned`); return; } if (attempts >= 6) { - console.log(`Stopped monitoring user: ${user.id} after ${attempts} attempts`); + console.log(`[MONITOR] Monitoring completed for user ${user.id} - no violations`); clearInterval(interval); delete newJoinMonitors[key]; } } catch (error) { - console.error(`Error monitoring user: ${user.id}`, error); + console.error(`[MONITOR] Error checking user ${user.id}:`, error); clearInterval(interval); delete newJoinMonitors[key]; } }, 5000); + newJoinMonitors[key] = interval; } -// Helper to show main menu +// --- Admin Menu Functions --- +// Show the main admin menu (updates an existing menu message if available) async function showMainMenu(ctx) { - const text = "Filter Management Menu\n\nChoose an action from the buttons below:"; - const keyboard = { - reply_markup: { - inline_keyboard: [ - [{ text: 'Add Filter', callback_data: 'menu_addFilter' }], - [{ text: 'Remove Filter', callback_data: 'menu_removeFilter' }], - [{ text: 'List Filters', callback_data: 'menu_listFilters' }], - [{ text: `Action: ${settings.action === 'ban' ? 'BAN' : 'KICK'}`, callback_data: 'menu_toggleAction' }] - ] + console.log(`[MENU] Showing main menu for admin ${ctx.from.id}`); + + const adminId = ctx.from.id; + let session = adminSessions.get(adminId) || { chatId: ctx.chat.id }; + + // Determine which groups this user can manage + const isGlobalAdmin = WHITELISTED_USER_IDS.includes(adminId); + let manageableGroups = []; + + if (isGlobalAdmin) { + manageableGroups = WHITELISTED_GROUP_IDS; + session.isGlobalAdmin = true; + console.log(`[MENU] Global admin - can manage all groups: ${manageableGroups.join(', ')}`); + } else { + // Group admin - can only manage their authorized group + if (session.authorizedGroupId && WHITELISTED_GROUP_IDS.includes(session.authorizedGroupId)) { + manageableGroups = [session.authorizedGroupId]; + console.log(`[MENU] Group admin - can manage group: ${session.authorizedGroupId}`); + } else { + console.log(`[MENU] No manageable groups found for user ${adminId}`); + await ctx.reply("You don't have permission to manage any groups."); + return; } - }; - return await ctx.reply(text, keyboard); -} + } + + // Auto-select first manageable group if none selected + if (!session.selectedGroupId || !manageableGroups.includes(session.selectedGroupId)) { + session.selectedGroupId = manageableGroups[0]; + console.log(`[MENU] Auto-selected group: ${session.selectedGroupId}`); + } -// Menu Helpers -async function sendPersistentExplainer(ctx) { - if (ctx.chat.type !== 'private') return; + const selectedGroupId = session.selectedGroupId; + const patterns = groupPatterns.get(selectedGroupId) || []; + const groupAction = getGroupAction(selectedGroupId); + + let text = `🛡️ Admin Menu\n`; - try { - const htmlLines = [ - "Welcome to the Filter Configuration!", - "", - "You can use the menu below or direct commands to manage banned username filters.", - "Filters can be plain text, include wildcards (* and ?) or be defined as a /regex/ literal.", - "", - "Examples:", - "- spam matches any username containing 'spam'", - "- *bad* matches any username containing 'bad'", - "- /^bad.*user$/i matches usernames starting with 'bad' and ending with 'user'", - "", - `Current action for matched usernames: ${settings.action.toUpperCase()}` - ]; - - await ctx.reply(htmlLines.join('\n'), { - parse_mode: 'HTML', - disable_web_page_preview: true - }); - } catch (error) { - console.error("Failed to send explainer message:", error); - try { - await ctx.reply("Welcome to the Filter Configuration! Use the menu below to manage username filters."); - } catch (err) { - console.error("Failed to send simplified explainer:", err); - } + if (isGlobalAdmin) { + text += `👑 Global Admin Access\n`; + } else { + text += `👮 Group Admin Access\n`; } -} + + text += `📍 Selected Group: ${selectedGroupId}\n`; + text += `📋 Patterns: ${patterns.length}/100\n`; + text += `⚔️ Action: ${groupAction.toUpperCase()}\n\n`; + text += `Use the buttons below to manage filters.`; -async function showOrEditMenu(ctx, text, extra) { - if (ctx.chat.type !== 'private') return; + // Create group selection buttons (only for groups user can manage) + const keyboard = { reply_markup: { inline_keyboard: [] } }; - const adminId = ctx.from.id; - let session = adminSessions.get(adminId) || { chatId: ctx.chat.id }; + if (manageableGroups.length > 1) { + const groupButtons = manageableGroups.map(groupId => ({ + text: `${groupId === selectedGroupId ? '✅ ' : ''}Group ${groupId} (${getGroupAction(groupId).toUpperCase()})`, + callback_data: `select_group_${groupId}` + })); + + // Split group buttons into rows of 2 + const groupRows = []; + for (let i = 0; i < groupButtons.length; i += 2) { + groupRows.push(groupButtons.slice(i, i + 2)); + } + keyboard.reply_markup.inline_keyboard.push(...groupRows); + } + + // Add management buttons + keyboard.reply_markup.inline_keyboard.push( + [ + { text: '➕ Add Filter', callback_data: 'menu_addFilter' }, + { text: '➖ Remove Filter', callback_data: 'menu_removeFilter' } + ], + [ + { text: '📋 List Filters', callback_data: 'menu_listFilters' }, + { text: '📥 Browse & Copy', callback_data: 'menu_browsePatterns' } + ], + [ + { text: `⚔️ Action: ${groupAction.toUpperCase()}`, callback_data: 'menu_toggleAction' }, + { text: '❓ Pattern Help', callback_data: 'menu_patternHelp' } + ] + ); + + adminSessions.set(adminId, session); + try { if (session.menuMessageId) { - await ctx.telegram.editMessageText( - session.chatId, - session.menuMessageId, - null, - text, - extra - ); + try { + await ctx.telegram.editMessageText( + session.chatId, + session.menuMessageId, + undefined, + text, + { parse_mode: 'HTML', ...keyboard } + ); + console.log(`[MENU] Updated existing menu message`); + } catch (err) { + if (!err.description || !err.description.includes("message is not modified")) { + throw err; + } + } } else { - const sent = await ctx.reply(text, extra); - session.menuMessageId = sent.message_id; + const message = await ctx.reply(text, { parse_mode: 'HTML', ...keyboard }); + session.menuMessageId = message.message_id; session.chatId = ctx.chat.id; + adminSessions.set(adminId, session); + console.log(`[MENU] Created new menu message ${message.message_id}`); } - } catch (err) { - console.error("Error showing/editing menu:", err); - const sent = await ctx.reply(text, extra); - session.menuMessageId = sent.message_id; - session.chatId = ctx.chat.id; + } catch (e) { + console.error(`[MENU] Error showing main menu:`, e); } - adminSessions.set(adminId, session); } -async function deleteMenu(ctx, confirmationMessage) { - if (ctx.chat.type !== 'private') return; +async function showPatternBrowsingMenu(ctx) { + console.log(`[MENU] Showing pattern browsing menu for admin ${ctx.from.id}`); const adminId = ctx.from.id; const session = adminSessions.get(adminId); - if (session && session.menuMessageId) { - try { - await ctx.telegram.deleteMessage(session.chatId, session.menuMessageId); - } catch (e) { - console.error("Failed to delete menu message:", e); - } - session.menuMessageId = null; - adminSessions.set(adminId, session); - } - if (confirmationMessage) { - await ctx.reply(confirmationMessage); + const currentGroupId = session.selectedGroupId; + + const allPatterns = getAllGroupPatterns(); + + if (allPatterns.size === 0) { + await showOrEditMenu(ctx, + `📥 Browse & Copy Patterns\n\nNo patterns found in any groups.`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[{ text: '⬅️ Back to Menu', callback_data: 'menu_back' }]] + } + } + ); + return; } -} - -async function promptForPattern(ctx, actionLabel) { - if (ctx.chat.type !== 'private') return; - const text = - `Please enter the pattern to ${actionLabel}.\n\n` + - "You can use wildcards (* and ?), or /regex/ syntax.\n\n" + - "Send `/cancel` to abort."; + let text = `📥 Browse & Copy Patterns\n`; + text += `Your Selected Group: ${currentGroupId}\n\n`; + text += `Select any group to view and copy patterns:\n\n`; - let session = adminSessions.get(ctx.from.id) || {}; - session.action = actionLabel; - adminSessions.set(ctx.from.id, session); - await showOrEditMenu(ctx, text, {}); -} - -// Debug middleware -bot.use((ctx, next) => { - const now = new Date().toISOString(); - const updateType = ctx.updateType || 'unknown'; - const chatId = ctx.chat?.id || 'unknown'; - const chatType = ctx.chat?.type || 'unknown'; - const fromId = ctx.from?.id || 'unknown'; - const username = ctx.from?.username || 'no_username'; + const keyboard = { reply_markup: { inline_keyboard: [] } }; - console.log(`[${now}] Update: type=${updateType}, chat=${chatId} (${chatType}), from=${fromId} (@${username})`); - - if (ctx.message?.new_chat_members) { - const newUsers = ctx.message.new_chat_members; - console.log(`New users: ${newUsers.map(u => `${u.id} (@${u.username || 'no_username'})`).join(', ')}`); + // Add buttons for ALL groups that have patterns (including current group for viewing) + for (const [groupId, patterns] of allPatterns) { + const buttonText = groupId === currentGroupId + ? `📍 Group ${groupId} (${patterns.length} patterns) - YOUR GROUP` + : `Group ${groupId} (${patterns.length} patterns)`; + + keyboard.reply_markup.inline_keyboard.push([{ + text: buttonText, + callback_data: `browse_group_${groupId}` + }]); + + // Add sample patterns to the text + if (groupId === currentGroupId) { + text += `📍 Group ${groupId} (Your Group): ${patterns.length} patterns\n`; + } else { + text += `Group ${groupId}: ${patterns.length} patterns\n`; + } + const samplePatterns = patterns.slice(0, 3).map(p => `${p.raw}`).join(', '); + text += `${samplePatterns}${patterns.length > 3 ? '...' : ''}\n\n`; } - if (ctx.updateType === 'message' && ctx.message?.text) { - console.log(`Message text: ${ctx.message.text.substring(0, 50)}${ctx.message.text.length > 50 ? '...' : ''}`); + // If no other groups have patterns, show a note + if (allPatterns.size === 1 && allPatterns.has(currentGroupId)) { + text += `💡 Only your group has patterns. Other groups will appear here once they add patterns.\n\n`; } - return next(); -}); + keyboard.reply_markup.inline_keyboard.push([ + { text: '⬅️ Back to Menu', callback_data: 'menu_back' } + ]); + + await showOrEditMenu(ctx, text, { + parse_mode: 'HTML', + ...keyboard + }); +} -// Admin cache middleware -bot.use(async (ctx, next) => { - if (ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') { - if (WHITELISTED_GROUP_IDS.includes(ctx.chat.id)) { - try { - const userId = ctx.from?.id; - if (userId && !WHITELISTED_USER_IDS.includes(userId) && !knownGroupAdmins.has(userId)) { - checkAndCacheGroupAdmin(userId, bot).catch(err => { - console.error('Error checking admin status:', err); - }); +async function showGroupPatternsForCopy(ctx, sourceGroupId) { + console.log(`[MENU] Showing patterns from group ${sourceGroupId} for viewing/copying`); + + const adminId = ctx.from.id; + const session = adminSessions.get(adminId); + const targetGroupId = session.selectedGroupId; + + const sourcePatterns = groupPatterns.get(sourceGroupId) || []; + + if (sourcePatterns.length === 0) { + await showOrEditMenu(ctx, + `📥 Group ${sourceGroupId} Patterns\n\nNo patterns found in this group.`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[{ text: '⬅️ Back', callback_data: 'menu_browsePatterns' }]] } - } catch (error) { - console.error('Error in admin cache middleware:', error); } - } - } - return next(); -}); - -// New users handler -bot.on('new_chat_members', async (ctx) => { - console.log('New user event triggered'); - if (!isChatAllowed(ctx)) { - console.log(`Group not allowed: ${ctx.chat.id}`); + ); return; } - const chatId = ctx.chat.id; - const newUsers = ctx.message.new_chat_members; - console.log(`Processing ${newUsers.length} new users in chat ${chatId}`); + const isOwnGroup = sourceGroupId === targetGroupId; + const canManageTarget = canManageGroup(adminId, targetGroupId); - for (const user of newUsers) { - const username = user.username; - const firstName = user.first_name; - const lastName = user.last_name; - const displayName = [firstName, lastName].filter(Boolean).join(' '); - - console.log(`Checking user: ${user.id} (@${username || 'no_username'}) Name: ${displayName}`); - - if (isBanned(username, firstName, lastName)) { - await takePunishmentAction(ctx, user.id, displayName || username || user.id, chatId); - } else { - monitorNewUser(chatId, user); + let text = `📥 Group ${sourceGroupId} Patterns\n`; + + if (isOwnGroup) { + text += `📍 This is your selected group\n\n`; + } else { + text += `To: Group ${targetGroupId} ${canManageTarget ? '✅' : '❌'}\n\n`; + if (!canManageTarget) { + text += `⚠️ You cannot copy to Group ${targetGroupId}\n`; + text += `You can only view these patterns.\n\n`; } } -}); - -// Message handler for banning -bot.on('message', async (ctx, next) => { - if (!isChatAllowed(ctx)) return next(); - const username = ctx.from?.username; - const firstName = ctx.from?.first_name; - const lastName = ctx.from?.last_name; - const displayName = [firstName, lastName].filter(Boolean).join(' '); + text += `Available Patterns (${sourcePatterns.length}):\n\n`; - console.log(`Processing message from: ${ctx.from.id} (@${username || 'no_username'}) Name: ${displayName}`); + // Show all patterns with numbers + sourcePatterns.forEach((pattern, index) => { + text += `${index + 1}. ${pattern.raw}\n`; + }); + + const keyboard = { reply_markup: { inline_keyboard: [] } }; - if (isBanned(username, firstName, lastName)) { - await takePunishmentAction(ctx, ctx.from.id, displayName || username || ctx.from.id, ctx.chat.id); + // Only show copy buttons if not own group and can manage target + if (!isOwnGroup && canManageTarget) { + text += `\nChoose what to copy:`; + keyboard.reply_markup.inline_keyboard.push([ + { text: '📋 Copy All', callback_data: `copy_all_${sourceGroupId}` }, + { text: '🎯 Select Specific', callback_data: `copy_select_${sourceGroupId}` } + ]); + } else if (isOwnGroup) { + text += `\n💡 This is your group. Use the main menu to manage these patterns.`; } else { - return next(); + text += `\n💡 You can view these patterns but cannot copy them to Group ${targetGroupId}.`; } -}); - -// Chat info command -bot.command('chatinfo', async (ctx) => { - const chatId = ctx.chat.id; - const chatType = ctx.chat.type; - const chatTitle = ctx.chat.title || 'Private Chat'; - const isAllowed = isChatAllowed(ctx); - const isAuth = await isAuthorized(ctx); - let reply = `Chat: "${chatTitle}"\n`; - reply += `ID: ${chatId}\n`; - reply += `Type: ${chatType}\n`; - reply += `Bot can operate here: ${isAllowed ? 'Yes' : 'No'}\n`; - reply += `You can configure bot: ${isAuth ? 'Yes' : 'No'}\n`; - reply += `Current action: ${settings.action.toUpperCase()}\n\n`; + keyboard.reply_markup.inline_keyboard.push([ + { text: '⬅️ Back to Browse', callback_data: 'menu_browsePatterns' } + ]); - if (chatType === 'group' || chatType === 'supergroup') { - reply += `Whitelisted group IDs: ${WHITELISTED_GROUP_IDS.join(', ')}\n`; - const match = WHITELISTED_GROUP_IDS.includes(chatId); - reply += `ID match: ${match ? 'Yes' : 'No'}\n`; - - if (!match) { - reply += `\nThis group's ID is not in the whitelist!`; - } - } + await showOrEditMenu(ctx, text, { + parse_mode: 'HTML', + ...keyboard + }); +} + +// Show or edit a menu-like message (used for prompts) +async function showOrEditMenu(ctx, text, extra) { + if (ctx.chat.type !== 'private') return; + + console.log(`[MENU] Showing/editing prompt for admin ${ctx.from.id}`); + const adminId = ctx.from.id; + let session = adminSessions.get(adminId) || { chatId: ctx.chat.id }; try { - await ctx.reply(reply); - console.log(`Chat info provided for ${chatId} (${chatType})`); - } catch (error) { - console.error('Failed to send chat info:', error); + if (session.menuMessageId) { + await ctx.telegram.editMessageText( + session.chatId, + session.menuMessageId, + undefined, + text, + extra + ); + console.log(`[MENU] Updated prompt message`); + } else { + const msg = await ctx.reply(text, extra); + session.menuMessageId = msg.message_id; + session.chatId = ctx.chat.id; + adminSessions.set(adminId, session); + console.log(`[MENU] Created new prompt message ${msg.message_id}`); + } + } catch (e) { + console.error(`[MENU] Error showing/editing prompt:`, e); } -}); +} -// Set action command -bot.command('setaction', async (ctx) => { - if (!(await isAuthorized(ctx))) return; +// Delete the current admin menu message and optionally send a confirmation +async function deleteMenu(ctx, confirmationMessage) { + if (ctx.chat.type !== 'private') return; - const args = ctx.message.text.split(' '); - if (args.length < 2) { - return ctx.reply(`Current action: ${settings.action.toUpperCase()}\n\nUsage: /setaction `); - } + console.log(`[MENU] Deleting menu for admin ${ctx.from.id}`); - const action = args[1].toLowerCase(); - if (action !== 'ban' && action !== 'kick') { - return ctx.reply('Invalid action. Use "ban" or "kick".'); + const adminId = ctx.from.id; + let session = adminSessions.get(adminId); + if (session && session.menuMessageId) { + try { + await ctx.telegram.deleteMessage(session.chatId, session.menuMessageId); + console.log(`[MENU] Deleted menu message ${session.menuMessageId}`); + } catch (e) { + console.error(`[MENU] Error deleting menu:`, e); + } + session.menuMessageId = null; + adminSessions.set(adminId, session); } - - settings.action = action; - const success = await saveSettings(); - - if (success) { - return ctx.reply(`Action updated to: ${action.toUpperCase()}`); - } else { - return ctx.reply('Failed to save settings. Check logs for details.'); + if (confirmationMessage) { + await ctx.reply(confirmationMessage); + console.log(`[MENU] Sent confirmation: "${confirmationMessage}"`); } -}); +} -// Command to show menu directly -bot.command('menu', async (ctx) => { +// Prompt the admin for a pattern, setting the session action accordingly +async function promptForPattern(ctx, actionLabel) { if (ctx.chat.type !== 'private') return; - if (!(await isAuthorized(ctx))) { - return ctx.reply('You are not authorized to configure the bot.'); - } - await showMainMenu(ctx); -}); - -// Help command -bot.command('help', async (ctx) => { - if (ctx.chat.type !== 'private') return; - if (!(await isAuthorized(ctx))) return; + console.log(`[MENU] Prompting for pattern: ${actionLabel}`); - await sendPersistentExplainer(ctx); - await showMainMenu(ctx); -}); + const adminId = ctx.from.id; + let session = adminSessions.get(adminId) || {}; + const groupId = session.selectedGroupId; -// Start command -bot.command('start', async (ctx) => { - if (ctx.chat.type !== 'private') return; - if (!(await isAuthorized(ctx))) { - return ctx.reply('You are not authorized to configure this bot.'); - } - - await sendPersistentExplainer(ctx); - await showMainMenu(ctx); -}); + const promptText = + `✨ Add Pattern for Group ${groupId} ✨\n\n` + + + `📝 Pattern Types:\n\n` + + + `1. Simple Text - Case-insensitive match\n` + + ` • spam matches "SPAM", "Spam", "spam"\n\n` + + + `2. Wildcards\n` + + ` • * = any characters\n` + + ` • ? = single character\n` + + ` • spam* matches "spam123", "spammer", etc.\n` + + ` • *bot* matches "testbot", "bot_user", etc.\n` + + ` • test? matches "test1", "testa", etc.\n\n` + + + `3. Regular Expressions - Advanced patterns\n` + + ` • Format: /pattern/flags\n` + + ` • /^spam.*$/i starts with "spam"\n` + + ` • /\\d{5,}/ 5+ digits in a row\n` + + ` • /ch[!1i]ld/i "child", "ch!ld", "ch1ld"\n\n` + + + `💡 Examples:\n` + + `• ranger - blocks "ranger"\n` + + `• *porn* - blocks anything with "porn"\n` + + `• /❤.*ch.ld.*p.rn/i - blocks heart+variations\n\n` + + + `Send your pattern or /cancel to abort.`; + + session.action = actionLabel; + adminSessions.set(adminId, session); + await showOrEditMenu(ctx, promptText, { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[{ text: 'Cancel', callback_data: 'menu_back' }]] + } + }); +} + +// --- Admin Command and Callback Handlers --- -// Process any text message in private chat (for admin menu) +// Text handler bot.on('text', async (ctx, next) => { - if (ctx.chat.type !== 'private') return next(); - if (!(await isAuthorized(ctx))) return next(); + if (ctx.chat.type !== 'private' || !(await isAuthorized(ctx))) return next(); + + console.log(`[ADMIN_TEXT] Received text from admin ${ctx.from.id}: "${ctx.message.text}"`); const adminId = ctx.from.id; let session = adminSessions.get(adminId) || { chatId: ctx.chat.id }; const input = ctx.message.text.trim(); - // Handle cancel command if (input.toLowerCase() === '/cancel') { - if (session.action) { - session.action = undefined; - adminSessions.set(adminId, session); - await deleteMenu(ctx, "Action cancelled."); - await showMainMenu(ctx); - } else { - await ctx.reply("No action in progress to cancel."); - } + console.log(`[ADMIN_TEXT] Admin ${adminId} cancelled current action`); + session.action = undefined; + session.copySourceGroupId = undefined; + adminSessions.set(adminId, session); + await deleteMenu(ctx, "Action cancelled."); + await showMainMenu(ctx); return; } - // Handle pattern input if an action is active if (session.action) { + const groupId = session.selectedGroupId; + + // Verify user can manage this group + if (!groupId || !canManageGroup(adminId, groupId)) { + console.log(`[ADMIN_TEXT] Admin ${adminId} cannot manage group ${groupId}`); + await ctx.reply("You don't have permission to manage this group."); + await showMainMenu(ctx); + return; + } + + let patterns = groupPatterns.get(groupId) || []; + if (session.action === 'Add Filter') { - try { - const regex = patternToRegex(input); - if (bannedPatterns.some(p => p.raw === input)) { - await ctx.reply(`Pattern "${input}" is already in the filter list.`); + console.log(`[ADMIN_TEXT] Adding filter for group ${groupId}: "${input}"`); + try { + const patternObj = createPatternObject(input); + + if (patterns.some(p => p.raw === patternObj.raw)) { + console.log(`[ADMIN_TEXT] Pattern already exists: "${patternObj.raw}"`); + await ctx.reply(`Pattern "${patternObj.raw}" is already in the list for Group ${groupId}.`); + } else if (patterns.length >= 100) { + console.log(`[ADMIN_TEXT] Maximum patterns reached for group ${groupId}`); + await ctx.reply(`Maximum patterns (100) reached for Group ${groupId}.`); } else { - bannedPatterns.push({ raw: input, regex }); - await saveBannedPatterns(); - await ctx.reply(`Filter added: "${input}"`); + patterns.push(patternObj); + groupPatterns.set(groupId, patterns); + await saveGroupPatterns(groupId, patterns); + console.log(`[ADMIN_TEXT] ✅ Added pattern "${patternObj.raw}" to group ${groupId}`); + await ctx.reply(`Filter "${patternObj.raw}" added to Group ${groupId}.`); } - } catch (error) { - await ctx.reply('Invalid pattern. Please try again.'); - } + } catch (e) { + await ctx.reply(`Invalid pattern: ${e.message}`); + return; + } } else if (session.action === 'Remove Filter') { - const index = bannedPatterns.findIndex(p => p.raw === input); + console.log(`[ADMIN_TEXT] Removing filter for group ${groupId}: "${input}"`); + const index = patterns.findIndex(p => p.raw === input); if (index !== -1) { - bannedPatterns.splice(index, 1); - await saveBannedPatterns(); - await ctx.reply(`Filter removed: "${input}"`); + patterns.splice(index, 1); + groupPatterns.set(groupId, patterns); + await saveGroupPatterns(groupId, patterns); + console.log(`[ADMIN_TEXT] ✅ Removed pattern "${input}" from group ${groupId}`); + await ctx.reply(`Filter "${input}" removed from Group ${groupId}.`); + } else { + console.log(`[ADMIN_TEXT] Pattern not found: "${input}"`); + await ctx.reply(`Pattern "${input}" not found in Group ${groupId}.`); + } + } else if (session.action === 'Select Patterns') { + // Handle pattern selection for copying + const sourceGroupId = session.copySourceGroupId; + const sourcePatterns = groupPatterns.get(sourceGroupId) || []; + + console.log(`[ADMIN_TEXT] Selecting patterns to copy: "${input}"`); + + let patternIndices = []; + + if (input.toLowerCase() === 'all') { + patternIndices = sourcePatterns.map((_, index) => index); + } else { + // Parse comma-separated numbers + const numbers = input.split(',').map(s => parseInt(s.trim()) - 1); // Convert to 0-based + patternIndices = numbers.filter(n => !isNaN(n) && n >= 0 && n < sourcePatterns.length); + + if (patternIndices.length === 0) { + await ctx.reply(`Invalid selection. Please enter pattern numbers (1-${sourcePatterns.length}) separated by commas, or "all".`); + return; + } + } + + console.log(`[ADMIN_TEXT] Selected pattern indices: ${patternIndices.join(', ')}`); + + const result = await copyPatternsToGroup(sourceGroupId, groupId, patternIndices); + + if (result.success) { + await ctx.reply(`✅ ${result.message}`); } else { - await ctx.reply(`Filter "${input}" not found.`); + await ctx.reply(`❌ ${result.message}`); } } - - // Clear action and show menu again + session.action = undefined; + session.copySourceGroupId = undefined; adminSessions.set(adminId, session); await showMainMenu(ctx); return; } - - // If no action and not a command, show the menu + if (!input.startsWith('/')) { + console.log(`[ADMIN_TEXT] Non-command text - showing main menu`); await showMainMenu(ctx); } }); -// Callback query handler +// Enhanced callback handler with browsing functionality (FIXED - no duplicates) bot.on('callback_query', async (ctx) => { - if (ctx.chat?.type !== 'private') { - return ctx.answerCbQuery('This action is only available in private chat.'); - } - - if (!(await isAuthorized(ctx))) { + if (ctx.chat?.type !== 'private' || !(await isAuthorized(ctx))) { return ctx.answerCbQuery('Not authorized.'); } + + console.log(`[CALLBACK] Admin ${ctx.from.id} pressed: ${ctx.callbackQuery.data}`); - const data = ctx.callbackQuery.data; - - // Always acknowledge the callback to remove loading indicator await ctx.answerCbQuery(); - - if (data === 'menu_addFilter') { - const text = "Please enter the pattern to add.\n\nExamples:\n- spam (matches any username containing 'spam')\n- *bad* (wildcards: matches usernames with 'bad')\n- /^evil.*$/i (regex: matches usernames starting with 'evil')"; - - let session = adminSessions.get(ctx.from.id) || {}; - session.action = 'Add Filter'; - adminSessions.set(ctx.from.id, session); + const data = ctx.callbackQuery.data; + const adminId = ctx.from.id; + let session = adminSessions.get(adminId) || { chatId: ctx.chat.id }; + + // Handle group selection (only allow if user can manage that group) + if (data.startsWith('select_group_')) { + const groupId = parseInt(data.replace('select_group_', '')); - await ctx.editMessageText(text, { parse_mode: 'HTML' }); - } else if (data === 'menu_removeFilter') { - // If no patterns exist, just say so - if (bannedPatterns.length === 0) { - await ctx.editMessageText("No filter patterns exist to remove. Use 'Add Filter' to create patterns first."); + if (canManageGroup(adminId, groupId)) { + session.selectedGroupId = groupId; + adminSessions.set(adminId, session); + console.log(`[CALLBACK] Admin ${adminId} selected group: ${groupId}`); + await ctx.answerCbQuery(`Selected Group: ${groupId}`); + await showMainMenu(ctx); + return; + } else { + console.log(`[CALLBACK] Admin ${adminId} denied access to group ${groupId}`); + await ctx.answerCbQuery('You cannot manage this group.'); return; } + } + + // Handle pattern browsing - allow any authorized user to browse all patterns + if (data === 'menu_browsePatterns') { + console.log(`[CALLBACK] Admin ${adminId} wants to browse patterns`); + await showPatternBrowsingMenu(ctx); + return; + } + + // Allow browsing any group's patterns - authorization check is only for copying + if (data.startsWith('browse_group_')) { + const sourceGroupId = parseInt(data.replace('browse_group_', '')); + console.log(`[CALLBACK] Admin ${adminId} browsing patterns from group ${sourceGroupId}`); + await showGroupPatternsForCopy(ctx, sourceGroupId); + return; + } + + // Copy operations require permission check for target group only + if (data.startsWith('copy_all_')) { + const sourceGroupId = parseInt(data.replace('copy_all_', '')); + const targetGroupId = session.selectedGroupId; - const text = "Please enter the pattern to remove.\n\nCurrent patterns:\n" + - bannedPatterns.map(p => `- ${p.raw}`).join('\n'); + if (!canManageGroup(adminId, targetGroupId)) { + await ctx.answerCbQuery('You cannot manage the target group.'); + return; + } - let session = adminSessions.get(ctx.from.id) || {}; - session.action = 'Remove Filter'; - adminSessions.set(ctx.from.id, session); + console.log(`[CALLBACK] Copying all patterns from ${sourceGroupId} to ${targetGroupId}`); + const result = await copyPatternsToGroup(sourceGroupId, targetGroupId); - await ctx.editMessageText(text, { parse_mode: 'HTML' }); - } else if (data === 'menu_listFilters') { - if (bannedPatterns.length === 0) { - await ctx.editMessageText("No filter patterns are currently set.", { + if (result.success) { + await ctx.answerCbQuery(`Success! ${result.message}`); + // Update the browsing menu to show the result + let resultText = `✅ Copy Complete!\n\n`; + resultText += `From: Group ${sourceGroupId}\n`; + resultText += `To: Group ${targetGroupId}\n\n`; + resultText += `${result.message}\n\n`; + resultText += `Use the button below to return to the main menu.`; + + await showOrEditMenu(ctx, resultText, { + parse_mode: 'HTML', reply_markup: { - inline_keyboard: [ - [{ text: 'Back to Menu', callback_data: 'menu_back' }] - ] + inline_keyboard: [[{ text: '🏠 Back to Main Menu', callback_data: 'menu_back' }]] } }); } else { - const list = bannedPatterns.map(p => `- ${p.raw}`).join('\n'); - await ctx.editMessageText(`Current filter patterns:\n${list}`, { + await ctx.answerCbQuery(`Error: ${result.message}`); + } + return; + } + + if (data.startsWith('copy_select_')) { + const sourceGroupId = parseInt(data.replace('copy_select_', '')); + const targetGroupId = session.selectedGroupId; + + // Check permission for target group + if (!canManageGroup(adminId, targetGroupId)) { + await ctx.answerCbQuery('You cannot manage the target group.'); + return; + } + + // Store the source group for pattern selection + session.copySourceGroupId = sourceGroupId; + session.action = 'Select Patterns'; + adminSessions.set(adminId, session); + + const sourcePatterns = groupPatterns.get(sourceGroupId) || []; + let text = `🎯 Select Patterns to Copy\n\n`; + text += `From: Group ${sourceGroupId}\n`; + text += `To: Group ${targetGroupId}\n\n`; + text += `Send pattern numbers separated by commas (e.g., "1,3,5") or "all" for all patterns:\n\n`; + + sourcePatterns.forEach((pattern, index) => { + text += `${index + 1}. ${pattern.raw}\n`; + }); + + await showOrEditMenu(ctx, text, { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[{ text: '❌ Cancel', callback_data: 'menu_browsePatterns' }]] + } + }); + return; + } + + const groupId = session.selectedGroupId; + + // Verify user can manage the selected group (only for management operations, not browsing) + if (!groupId || !canManageGroup(adminId, groupId)) { + console.log(`[CALLBACK] Admin ${adminId} cannot manage selected group ${groupId}`); + await ctx.answerCbQuery('You cannot manage this group.'); + await showMainMenu(ctx); + return; + } + + // Existing callback handlers... + if (data === 'menu_addFilter') { + console.log(`[CALLBACK] Admin ${adminId} wants to add filter for group ${groupId}`); + await promptForPattern(ctx, 'Add Filter'); + } else if (data === 'menu_removeFilter') { + console.log(`[CALLBACK] Admin ${adminId} wants to remove filter from group ${groupId}`); + const patterns = groupPatterns.get(groupId) || []; + if (patterns.length === 0) { + await ctx.editMessageText(`No filters to remove for Group ${groupId}.`, { + reply_markup: { inline_keyboard: [[{ text: 'Back to Menu', callback_data: 'menu_back' }]] } + }); + } else { + const list = patterns.map((p, index) => `${index + 1}. ${p.raw}`).join('\n'); + await showOrEditMenu(ctx, `Current filters for Group ${groupId}:\n${list}\n\nEnter filter to remove (exact text):`, { + parse_mode: 'HTML', + reply_markup: { inline_keyboard: [[{ text: 'Back to Menu', callback_data: 'menu_back' }]] } + }); + session.action = 'Remove Filter'; + adminSessions.set(adminId, session); + } + } else if (data === 'menu_listFilters') { + console.log(`[CALLBACK] Admin ${adminId} listing filters for group ${groupId}`); + const patterns = groupPatterns.get(groupId) || []; + if (patterns.length === 0) { + await ctx.editMessageText(`No filters currently set for Group ${groupId}.`, { + reply_markup: { inline_keyboard: [[{ text: 'Back to Menu', callback_data: 'menu_back' }]] } + }); + } else { + const list = patterns.map((p, index) => `${index + 1}. ${p.raw}`).join('\n'); + await ctx.editMessageText(`Current filters for Group ${groupId} (${patterns.length}/100):\n${list}`, { parse_mode: 'HTML', - reply_markup: { - inline_keyboard: [ - [{ text: 'Back to Menu', callback_data: 'menu_back' }] - ] - } + reply_markup: { inline_keyboard: [[{ text: 'Back to Menu', callback_data: 'menu_back' }]] } }); } } else if (data === 'menu_toggleAction') { - // Toggle between ban and kick - settings.action = settings.action === 'ban' ? 'kick' : 'ban'; + const currentAction = getGroupAction(groupId); + const newAction = currentAction === 'ban' ? 'kick' : 'ban'; + settings.groupActions[groupId] = newAction; await saveSettings(); - - // Update menu to show new action + console.log(`[CALLBACK] Admin ${adminId} toggled action for group ${groupId}: ${currentAction} -> ${newAction}`); await showMainMenu(ctx); - - // Show a confirmation message - await ctx.answerCbQuery(`Action changed to: ${settings.action.toUpperCase()}`); + await ctx.answerCbQuery(`Action now: ${newAction.toUpperCase()} for Group ${groupId}`); + } else if (data === 'menu_patternHelp') { + console.log(`[CALLBACK] Admin ${adminId} requested pattern help`); + const helpText = + `✨ Pattern Types Guide ✨\n\n` + + + `🔤 Simple Text\n` + + `Case-insensitive match\n` + + `Example: spam\n` + + `Matches: "SPAM", "Spam", "spam123", etc.\n\n` + + + `⭐ Wildcards\n` + + `• * = zero or more characters\n` + + `• ? = exactly one character\n\n` + + `Examples:\n` + + `• spam* → "spam", "spammer", "spam123"\n` + + `• *bot → "mybot", "testbot", "123bot"\n` + + `• *bad* → "baduser", "this_is_bad"\n` + + `• test? → "test1", "testa", "tests"\n\n` + + + `🔧 Regular Expressions\n` + + `Format: /pattern/flags\n\n` + + `Useful flags:\n` + + `• i = case-insensitive\n` + + `• g = global match\n\n` + + `Examples:\n` + + `• /^spam/i → starts with "spam"\n` + + `• /user$/i → ends with "user"\n` + + `• /\\d{5,}/ → 5+ digits\n` + + `• /ch[!1i]ld/i → "child", "ch!ld", "ch1ld"\n` + + `• /❤.*p.rn/i → heart + porn variations\n\n` + + + `💡 Tips:\n` + + `• Test patterns with /testpattern\n` + + `• Start simple, then get complex\n` + + `• Patterns are checked against usernames AND display names\n` + + `• Use Browse & Copy to share patterns between groups`; + + await ctx.editMessageText(helpText, { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[{ text: '⬅️ Back to Menu', callback_data: 'menu_back' }]] + } + }); } else if (data === 'menu_back') { - // Go back to main menu + console.log(`[CALLBACK] Admin ${adminId} returning to main menu`); await showMainMenu(ctx); } }); -// Direct command handlers +// Direct command handlers for /addFilter, /removeFilter, /listFilters bot.command('addFilter', async (ctx) => { - if (ctx.chat.type !== 'private') return; - if (!(await isAuthorized(ctx))) return; + if (ctx.chat.type !== 'private' || !(await isAuthorized(ctx))) return; + + console.log(`[COMMAND] /addFilter from admin ${ctx.from.id}: "${ctx.message.text}"`); + const adminId = ctx.from.id; + let session = adminSessions.get(adminId) || { chatId: ctx.chat.id }; + const groupId = session.selectedGroupId; + + if (!groupId || !canManageGroup(adminId, groupId)) { + console.log(`[COMMAND] No manageable group selected for addFilter`); + return ctx.reply('No group selected or permission denied. Use /menu to select a group first.'); + } + const parts = ctx.message.text.split(' '); if (parts.length < 2) { - return ctx.reply('Usage: /addFilter \n\nExamples:\n- /addFilter spam\n- /addFilter *bad*\n- /addFilter /^evil.*$/i'); + console.log(`[COMMAND] addFilter usage help requested`); + return ctx.reply( + `Usage: /addFilter <pattern>\n\n` + + + `Examples:\n` + + `/addFilter spam\n` + + `/addFilter *bitcoin*\n` + + `/addFilter /^evil.*user$/i\n\n` + + + `Current Group: ${groupId}\n` + + `Use /menu for more help and examples.`, + { parse_mode: 'HTML' } + ); } - + const pattern = parts.slice(1).join(' ').trim(); + try { - const regex = patternToRegex(pattern); - if (bannedPatterns.some(p => p.raw === pattern)) { - return ctx.reply(`Pattern "${pattern}" is already in the list.`); + const patternObj = createPatternObject(pattern); + let patterns = groupPatterns.get(groupId) || []; + + if (patterns.some(p => p.raw === patternObj.raw)) { + console.log(`[COMMAND] Pattern already exists: "${patternObj.raw}"`); + return ctx.reply(`Pattern "${patternObj.raw}" already exists.`); + } + + if (patterns.length >= 100) { + console.log(`[COMMAND] Maximum patterns reached for group ${groupId}`); + return ctx.reply(`Maximum patterns reached (100 per group).`); } - bannedPatterns.push({ raw: pattern, regex }); - await saveBannedPatterns(); - return ctx.reply(`Filter added: "${pattern}"`); + + patterns.push(patternObj); + groupPatterns.set(groupId, patterns); + await saveGroupPatterns(groupId, patterns); + + console.log(`[COMMAND] ✅ Added pattern "${patternObj.raw}" to group ${groupId}`); + return ctx.reply(`✅ Added filter: "${patternObj.raw}"`); } catch (error) { - return ctx.reply('Invalid pattern format.'); + console.error(`[COMMAND] addFilter error:`, error); + return ctx.reply(`❌ Error: ${error.message}`); } }); bot.command('removeFilter', async (ctx) => { - if (ctx.chat.type !== 'private') return; - if (!(await isAuthorized(ctx))) return; + if (ctx.chat.type !== 'private' || !(await isAuthorized(ctx))) return; + + console.log(`[COMMAND] /removeFilter from admin ${ctx.from.id}: "${ctx.message.text}"`); + const adminId = ctx.from.id; + let session = adminSessions.get(adminId) || { chatId: ctx.chat.id }; + const groupId = session.selectedGroupId; + + if (!groupId || !canManageGroup(adminId, groupId)) { + console.log(`[COMMAND] No manageable group selected for removeFilter`); + return ctx.reply('No group selected or permission denied. Use /menu to select a group first.'); + } + + let patterns = groupPatterns.get(groupId) || []; + const parts = ctx.message.text.split(' '); if (parts.length < 2) { - if (bannedPatterns.length === 0) { - return ctx.reply('No patterns exist to remove.'); + if (patterns.length === 0) { + console.log(`[COMMAND] No patterns to remove for group ${groupId}`); + return ctx.reply(`No patterns exist to remove for Group ${groupId}.`); } - - const patterns = bannedPatterns.map(p => `- ${p.raw}`).join('\n'); - return ctx.reply(`Usage: /removeFilter \n\nCurrent patterns:\n${patterns}`); + console.log(`[COMMAND] removeFilter usage help requested`); + const patternsList = patterns.map(p => `- ${p.raw}`).join('\n'); + return ctx.reply(`Usage: /removeFilter \nCurrent patterns for Group ${groupId}:\n${patternsList}`); } - + const pattern = parts.slice(1).join(' ').trim(); - const index = bannedPatterns.findIndex(p => p.raw === pattern); + const index = patterns.findIndex(p => p.raw === pattern); + if (index !== -1) { - bannedPatterns.splice(index, 1); - await saveBannedPatterns(); - return ctx.reply(`Filter removed: "${pattern}"`); + patterns.splice(index, 1); + groupPatterns.set(groupId, patterns); + await saveGroupPatterns(groupId, patterns); + console.log(`[COMMAND] ✅ Removed pattern "${pattern}" from group ${groupId}`); + return ctx.reply(`Filter removed: "${pattern}" from Group ${groupId}`); } else { - return ctx.reply(`Filter "${pattern}" not found.`); + console.log(`[COMMAND] Pattern not found: "${pattern}" in group ${groupId}`); + return ctx.reply(`Filter "${pattern}" not found in Group ${groupId}.`); } }); bot.command('listFilters', async (ctx) => { - if (ctx.chat.type !== 'private') return; + if (ctx.chat.type !== 'private' || !(await isAuthorized(ctx))) return; + + console.log(`[COMMAND] /listFilters from admin ${ctx.from.id}`); + + const adminId = ctx.from.id; + let session = adminSessions.get(adminId) || { chatId: ctx.chat.id }; + const groupId = session.selectedGroupId; + + if (!groupId || !canManageGroup(adminId, groupId)) { + console.log(`[COMMAND] No manageable group selected for listFilters`); + return ctx.reply('No group selected or permission denied. Use /menu to select a group first.'); + } + + const patterns = groupPatterns.get(groupId) || []; + + if (patterns.length === 0) { + console.log(`[COMMAND] No patterns for group ${groupId}`); + return ctx.reply(`No filter patterns are currently set for Group ${groupId}.`); + } + + console.log(`[COMMAND] Listing ${patterns.length} patterns for group ${groupId}`); + const list = patterns.map(p => `- ${p.raw}`).join('\n'); + return ctx.reply(`Current filter patterns for Group ${groupId}:\n${list}`); +}); + +// Chat info command +bot.command('chatinfo', async (ctx) => { + console.log(`[COMMAND] /chatinfo from user ${ctx.from.id} in chat ${ctx.chat.id}`); + + const chatId = ctx.chat.id; + const chatType = ctx.chat.type; + const chatTitle = ctx.chat.title || 'Private Chat'; + const isAllowed = isChatAllowed(ctx); + const isAuth = await isAuthorized(ctx); + const groupAction = getGroupAction(chatId); + + let reply = `Chat: "${chatTitle}"\nID: ${chatId}\nType: ${chatType}\nBot allowed: ${isAllowed ? 'Yes' : 'No'}\nCan configure: ${isAuth ? 'Yes' : 'No'}\nCurrent action: ${groupAction.toUpperCase()}\n\n`; + + if (chatType === 'group' || chatType === 'supergroup') { + reply += `Whitelisted group IDs: ${WHITELISTED_GROUP_IDS.join(', ')}\nID match: ${WHITELISTED_GROUP_IDS.includes(chatId) ? 'Yes' : 'No'}\n`; + + if (WHITELISTED_GROUP_IDS.includes(chatId)) { + const patterns = groupPatterns.get(chatId) || []; + reply += `\nThis group has ${patterns.length} banned patterns.`; + } else { + reply += `\nThis group's ID is not whitelisted!`; + } + } + + try { + await ctx.reply(reply); + console.log(`[COMMAND] Sent chatinfo for ${chatId}`); + } catch (error) { + console.error(`[COMMAND] Failed to send chatinfo:`, error); + } +}); + +// Set action command with enhanced group permission checks +bot.command('setaction', async (ctx) => { if (!(await isAuthorized(ctx))) return; - if (bannedPatterns.length === 0) { - return ctx.reply('No filter patterns are currently set.'); + console.log(`[COMMAND] /setaction from user ${ctx.from.id}: "${ctx.message.text}"`); + + const args = ctx.message.text.split(' '); + const userId = ctx.from.id; + + // If in group, check if user can manage that specific group + if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') { + const groupId = ctx.chat.id; + + if (!canManageGroup(userId, groupId)) { + console.log(`[COMMAND] User ${userId} cannot manage group ${groupId}`); + return ctx.reply('You do not have permission to configure this group.'); + } + + const currentAction = getGroupAction(groupId); + + if (args.length < 2) { + console.log(`[COMMAND] setaction usage help for group ${groupId}`); + return ctx.reply(`Current action for this group: ${currentAction.toUpperCase()}\nUsage: /setaction `); + } + + const action = args[1].toLowerCase(); + if (action !== 'ban' && action !== 'kick') { + console.log(`[COMMAND] Invalid action: ${action}`); + return ctx.reply('Invalid action. Use "ban" or "kick".'); + } + + settings.groupActions[groupId] = action; + const success = await saveSettings(); + if (success) { + console.log(`[COMMAND] ✅ Action updated for group ${groupId}: ${action.toUpperCase()}`); + return ctx.reply(`Action updated to: ${action.toUpperCase()} for this group`); + } else { + console.log(`[COMMAND] ❌ Failed to save settings for group ${groupId}`); + return ctx.reply('Failed to save settings. Check logs for details.'); + } + } + + // If in private chat, use selected group from session + else { + console.log(`[COMMAND] setaction in private chat`); + const session = adminSessions.get(userId) || {}; + const groupId = session.selectedGroupId; + + if (!groupId || !canManageGroup(userId, groupId)) { + console.log(`[COMMAND] User ${userId} cannot manage selected group ${groupId}`); + return ctx.reply('You do not have permission to manage the selected group. Use /menu to see available options.'); + } + + const currentAction = getGroupAction(groupId); + + if (args.length < 2) { + console.log(`[COMMAND] setaction usage help for group ${groupId}`); + return ctx.reply(`Current action for Group ${groupId}: ${currentAction.toUpperCase()}\nUsage: /setaction `); + } + + const action = args[1].toLowerCase(); + if (action !== 'ban' && action !== 'kick') { + console.log(`[COMMAND] Invalid action: ${action}`); + return ctx.reply('Invalid action. Use "ban" or "kick".'); + } + + settings.groupActions[groupId] = action; + const success = await saveSettings(); + if (success) { + console.log(`[COMMAND] ✅ Action updated for group ${groupId}: ${action.toUpperCase()}`); + return ctx.reply(`Action updated to: ${action.toUpperCase()} for Group ${groupId}`); + } else { + console.log(`[COMMAND] ❌ Failed to save settings for group ${groupId}`); + return ctx.reply('Failed to save settings. Check logs for details.'); + } } +}); + +// Test pattern command using security functions +bot.command('testpattern', async (ctx) => { + if (ctx.chat.type !== 'private' || !(await isAuthorized(ctx))) return; + + console.log(`[COMMAND] /testpattern from admin ${ctx.from.id}: "${ctx.message.text}"`); - const list = bannedPatterns.map(p => `- ${p.raw}`).join('\n'); - return ctx.reply(`Current filter patterns:\n${list}`); + const parts = ctx.message.text.split(' '); + if (parts.length < 3) { + console.log(`[COMMAND] testpattern usage help requested`); + return ctx.reply('Usage: /testpattern '); + } + + const pattern = parts[1]; + const testString = parts.slice(2).join(' '); + + try { + const result = await matchesPattern(pattern, testString); + console.log(`[COMMAND] Pattern test: "${pattern}" ${result ? 'matches' : 'does not match'} "${testString}"`); + return ctx.reply(`Pattern "${pattern}" ${result ? 'matches' : 'does not match'} "${testString}"`); + } catch (err) { + console.error(`[COMMAND] testpattern error:`, err); + return ctx.reply(`Error testing pattern: ${err.message}`); + } }); -// Start the bot -async function startBot() { - await loadSettings(); - await loadBannedPatterns(); +// Command to show menu directly +bot.command('menu', async (ctx) => { + if (ctx.chat.type !== 'private' || !(await isAuthorized(ctx))) { + console.log(`[COMMAND] /menu denied for user ${ctx.from.id}`); + return ctx.reply('You are not authorized to configure the bot.'); + } - const launchOptions = { + console.log(`[COMMAND] /menu from admin ${ctx.from.id}`); + await showMainMenu(ctx); +}); + +bot.command('hits', async (ctx) => { + const isPrivate = ctx.chat.type === 'private'; + const isAdmin = isPrivate && await isAuthorized(ctx); + const args = ctx.message.text.split(' ').slice(1); + + // Only allow in whitelisted groups or admin DMs + if (!isPrivate && !WHITELISTED_GROUP_IDS.includes(ctx.chat.id)) { + return ctx.reply('This group is not authorized for stats.'); + } + + // Pattern-specific (admin/DM only) + if (isAdmin && args.length > 0) { + const patternRaw = args.join(' ').trim(); + const stats = getHitStatsForPattern(patternRaw); + if (stats.length === 0) { + return ctx.reply(`No recorded hits for pattern:\n${patternRaw}`, { parse_mode: 'HTML' }); + } + let reply = `📊 Hit counts for pattern ${patternRaw}:\n`; + for (const { groupId, count } of stats) { + reply += `• Group ${groupId}: ${count} hit(s)\n`; + } + return ctx.reply(reply, { parse_mode: 'HTML' }); + } + + // Group stats (group or DM) + const groupId = isPrivate ? (adminSessions.get(ctx.from.id)?.selectedGroupId) : ctx.chat.id; + if (!groupId || !hitCounters[groupId] || Object.keys(hitCounters[groupId]).length === 0) { + return ctx.reply(`No pattern hits recorded for this group yet.`); + } + const stats = getHitStatsForGroup(groupId, 10); + let reply = `📈 Top Pattern Hits in Group ${groupId}:\n`; + for (const { pattern, count } of stats) { + reply += `• ${pattern}: ${count}\n`; + } + const total = Object.values(hitCounters[groupId]).reduce((a, b) => a + b, 0); + reply += `\nTotal matches: ${total}`; + + return ctx.reply(reply, { parse_mode: 'HTML' }); +}); + +// Help and Start commands +bot.command('help', async (ctx) => { + if (ctx.chat.type !== 'private' || !(await isAuthorized(ctx))) return; + + console.log(`[COMMAND] /help from admin ${ctx.from.id}`); + + const helpText = + `Telegram Ban Bot Help\n\n` + + `Admin Commands:\n` + + `• /menu - Open the interactive configuration menu\n` + + `• /addFilter - Add a filter pattern\n` + + `• /removeFilter - Remove a filter pattern\n` + + `• /listFilters - List all filter patterns\n` + + `• /setaction - Set action for matches\n` + + `• /chatinfo - Show information about current chat\n` + + `• /testpattern - Test a pattern\n` + + `• /cancel - Cancel current operation\n\n` + + + `Pattern Formats:\n` + + `• Simple text: "spam"\n` + + `• Wildcards: "spam*site", "*bad*user*"\n` + + `• Regex: "/^bad.*user$/i"\n\n` + + + `Features:\n` + + `• Group-specific pattern management\n` + + `• Browse and copy patterns between groups\n` + + `• Per-group ban/kick settings\n` + + `• Real-time name change monitoring\n\n` + + + `The bot checks user names when they:\n` + + `1. Join a group\n` + + `2. Change their name/username (monitored for 30 sec)\n` + + `3. Send messages\n\n` + + + `Use /menu to configure banned patterns for each group.`; + + await ctx.reply(helpText); +}); + +bot.command('start', async (ctx) => { + if (ctx.chat.type !== 'private' || !(await isAuthorized(ctx))) { + console.log(`[COMMAND] /start denied for user ${ctx.from.id}`); + return ctx.reply('You are not authorized to configure this bot.'); + } + + console.log(`[COMMAND] /start from admin ${ctx.from.id}`); + + const welcomeText = + `🛡️ Welcome to the Telegram Ban Bot!\n\n` + + + `This bot helps protect your groups by automatically removing users whose names match specific patterns.\n\n` + + + `Quick Start:\n` + + `1. Use /menu to configure patterns\n` + + `2. Select your group\n` + + `3. Add patterns (text, wildcards, or regex)\n` + + `4. Choose ban or kick action\n\n` + + + `New Features:\n` + + `• Browse & copy patterns between groups\n` + + `• Per-group settings management\n` + + `• Enhanced admin controls\n\n` + + + `Pattern Examples:\n` + + `• spam - blocks exact text\n` + + `• *bot* - blocks anything with "bot"\n` + + `• /^evil/i - blocks names starting with "evil"\n\n` + + + `Ready to get started?`; + + await ctx.reply(welcomeText, { parse_mode: 'HTML' }); + await showMainMenu(ctx); +}); + +// Admin cache and debug middleware +bot.use((ctx, next) => { + const now = new Date().toISOString(); + const updateType = ctx.updateType || 'unknown'; + const chatId = ctx.chat?.id || 'unknown'; + const chatType = ctx.chat?.type || 'unknown'; + const fromId = ctx.from?.id || 'unknown'; + const username = ctx.from?.username || 'no_username'; + + console.log(`[UPDATE] [${now}] type=${updateType}, chat=${chatId} (${chatType}), from=${fromId} (@${username})`); + + if (ctx.message?.new_chat_members) { + const newUsers = ctx.message.new_chat_members; + console.log(`[UPDATE] New users: ${newUsers.map(u => `${u.id} (@${u.username || 'no_username'})`).join(', ')}`); + } + + if (ctx.updateType === 'message' && ctx.message?.text) { + console.log(`[UPDATE] Message: "${ctx.message.text.substring(0, 50)}${ctx.message.text.length > 50 ? '...' : ''}"`); + } + + return next(); +}); + +bot.use(async (ctx, next) => { + if (ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') { + if (WHITELISTED_GROUP_IDS.includes(ctx.chat.id)) { + try { + const userId = ctx.from?.id; + if (userId && !WHITELISTED_USER_IDS.includes(userId) && !knownGroupAdmins.has(userId)) { + console.log(`[MIDDLEWARE] Checking admin status for user ${userId} in background`); + checkAndCacheGroupAdmin(userId, bot).catch(err => { + console.error(`[MIDDLEWARE] Error checking admin status: ${err.message}`); + }); + } + } catch (error) { + console.error(`[MIDDLEWARE] Error in admin cache middleware: ${error.message}`); + } + } + } + return next(); +}); + +// New users handler +bot.on('new_chat_members', async (ctx) => { + console.log(`[EVENT] New chat members event in chat ${ctx.chat.id}`); + + if (!isChatAllowed(ctx)) { + console.log(`[EVENT] Group ${ctx.chat.id} not allowed - skipping`); + return; + } + + const chatId = ctx.chat.id; + const newUsers = ctx.message.new_chat_members; + console.log(`[EVENT] Processing ${newUsers.length} new users in chat ${chatId}`); + + for (const user of newUsers) { + const username = user.username; + const firstName = user.first_name; + const lastName = user.last_name; + const displayName = [firstName, lastName].filter(Boolean).join(' '); + console.log(`[EVENT] Checking new user: ${user.id} (@${username || 'no_username'}) Name: ${displayName}`); + + if (await isBanned(username, firstName, lastName, chatId)) { + console.log(`[EVENT] 🚫 New user ${user.id} is banned - taking action`); + await takePunishmentAction(ctx, user.id, displayName || username || user.id, chatId); + } else { + console.log(`[EVENT] New user ${user.id} passed initial check - starting monitoring`); + monitorNewUser(chatId, user); + } + } +}); + +// Message handler for banning users +bot.on('message', async (ctx, next) => { + if (!isChatAllowed(ctx)) return next(); + + const chatId = ctx.chat.id; + const username = ctx.from?.username; + const firstName = ctx.from?.first_name; + const lastName = ctx.from?.last_name; + const displayName = [firstName, lastName].filter(Boolean).join(' '); + + console.log(`[MESSAGE] User ${ctx.from.id} (@${username || 'no_username'}) sending message in chat ${chatId}`); + + if (await isBanned(username, firstName, lastName, chatId)) { + console.log(`[MESSAGE] 🚫 User ${ctx.from.id} is banned - taking action`); + await takePunishmentAction(ctx, ctx.from.id, displayName || username || ctx.from.id, chatId); + } else { + console.log(`[MESSAGE] User ${ctx.from.id} passed check - allowing message`); + return next(); + } +}); + +// Startup and cleanup +async function startup() { + console.log(`[STARTUP] Starting bot initialization...`); + + await ensureBannedPatternsDirectory(); + await loadSettings(); + await loadAllGroupPatterns(); + await loadHitCounters(); + + // Ensure all whitelisted groups have an action setting + WHITELISTED_GROUP_IDS.forEach(groupId => { + if (!settings.groupActions[groupId]) { + settings.groupActions[groupId] = 'kick'; + console.log(`[STARTUP] Set default action for group ${groupId}: ${'kick'}`); + } + }); + await saveSettings(); + + bot.launch({ allowedUpdates: ['message', 'callback_query', 'chat_member', 'my_chat_member'], timeout: 30 - }; - - bot.launch(launchOptions) - .then(() => { - console.log('\n=============================='); - console.log('Bot Started'); - console.log('=============================='); - console.log(`Loaded ${bannedPatterns.length} banned patterns`); - console.log(`Current action: ${settings.action.toUpperCase()}`); - console.log('Bot is running. Press Ctrl+C to stop.'); - }) - .catch(err => console.error('Bot launch error:', err)); + }) + .then(() => { + console.log('\n=============================='); + console.log('Bot Started'); + console.log('=============================='); + console.log(`Loaded patterns for ${groupPatterns.size} groups`); + console.log(`Group actions:`, settings.groupActions); + console.log(`✅ Security module active`); + console.log(`✅ Pattern validation enabled`); + console.log(`✅ Regex timeout protection enabled`); + console.log(`✅ Enhanced group management enabled`); + console.log(`✅ Pattern browsing & copying enabled`); + console.log(`✅ Comprehensive logging enabled`); + console.log('Bot is running. Press Ctrl+C to stop.'); + console.log('==============================\n'); + }) + .catch(err => { + console.error(`[STARTUP] Bot launch error:`, err); + process.exit(1); + }); } -startBot(); - -// Graceful shutdown const cleanup = (signal) => { - console.log(`\nReceived ${signal}. Shutting down gracefully...`); - Object.values(newJoinMonitors).forEach(interval => { - clearInterval(interval); - }); + console.log(`\n[CLEANUP] Received ${signal}. Shutting down gracefully...`); + Object.values(newJoinMonitors).forEach(interval => clearInterval(interval)); bot.stop(signal); setTimeout(() => { - console.log('Forcing exit...'); + console.log('[CLEANUP] Forcing exit...'); process.exit(0); }, 1000); }; @@ -872,3 +1823,18 @@ const cleanup = (signal) => { process.once('SIGINT', () => cleanup('SIGINT')); process.once('SIGTERM', () => cleanup('SIGTERM')); process.once('SIGUSR2', () => cleanup('SIGUSR2')); + +// Start the bot +// Only start the bot if not in test mode +if (process.env.NODE_ENV !== "test" && !process.argv.includes("--test")) { + startup(); +} else { + console.log(`[TEST] Bot loaded in test mode - not starting`); +} + +// Export functions for testing +export { + incrementHitCounter, + getHitStatsForGroup, + getHitStatsForPattern +}; diff --git a/config.example.js b/config.example.js new file mode 100644 index 0000000..d810595 --- /dev/null +++ b/config.example.js @@ -0,0 +1,21 @@ +// config.example.js - Template configuration file +import dotenv from 'dotenv'; +dotenv.config(); + +export const BOT_TOKEN = process.env.BOT_TOKEN; +export const BANNED_PATTERNS_DIR = './data/banned_patterns'; +export const SETTINGS_FILE = './config/settings.json'; +export const HIT_COUNTER_FILE = './data/hit_counters.json'; + +// List of user IDs explicitly allowed to configure the filters +// Add your Telegram user IDs here +export const WHITELISTED_USER_IDS = [ + // 123456789, // Example user ID + // 987654321, // Another user ID +]; + +// List of group IDs where the bot is allowed to operate +// Group IDs typically need to be prefixed with '-100' +export const WHITELISTED_GROUP_IDS = [ + // -1001234567890, // Example group ID +]; \ No newline at end of file diff --git a/config.js b/config.js deleted file mode 100644 index ba0df0d..0000000 --- a/config.js +++ /dev/null @@ -1,13 +0,0 @@ -// config.js -import dotenv from 'dotenv'; -dotenv.config(); - -export const BOT_TOKEN = process.env.BOT_TOKEN; -export const BANNED_PATTERNS_FILE = process.env.BANNED_PATTERNS_FILE || 'banned_patterns.toml'; - -// List of user IDs explicitly allowed to configure the filters -export const WHITELISTED_USER_IDS = [1705203106, 1721840238, 5689314455, 951943232, 878263003, 413184612]; - -// List of group IDs where the bot is allowed to operate. -// Group IDs typically need to be prefixed with '-100'. -export const WHITELISTED_GROUP_IDS = [-1001540576068]; diff --git a/example.env b/example.env index 383b37c..69c2963 100644 --- a/example.env +++ b/example.env @@ -1,2 +1 @@ BOT_TOKEN=your_bot_token_here -BANNED_PATTERNS_FILE=banned_patterns.toml diff --git a/package.json b/package.json index f0fe930..bebb674 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "banBaby", - "version": "1.2.0", + "name": "namebanbot", + "version": "2.0.0", "main": "bot.js", "type": "module", "license": "MIT", @@ -9,17 +9,19 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "audit": "yarn audit --level moderate", - "preversion": "yarn lint && yarn audit" + "preversion": "yarn lint && yarn audit", + "test": "NODE_ENV=test uvu tests" }, "dependencies": { "dotenv": "^16.5.0", - "fs": "^0.0.1-security", "telegraf": "^4.16.3", "toml": "^3.0.0" }, "devDependencies": { "@eslint/js": "^9.24.0", + "c8": "^10.1.3", "eslint": "^9.24.0", - "globals": "^16.0.0" + "globals": "^16.0.0", + "uvu": "^0.5.6" } } diff --git a/security.js b/security.js new file mode 100644 index 0000000..0306a28 --- /dev/null +++ b/security.js @@ -0,0 +1,229 @@ +// security.js - Pattern security functions + +/** + * Safe regex compilation with basic protections + * @param {string} patternStr - The pattern string to compile + * @returns {RegExp} - Compiled regex object + * @throws {Error} - If pattern is invalid + */ +export function compileSafeRegex(patternStr) { + if (typeof patternStr !== 'string') { + throw new Error('Pattern must be a string'); + } + + // Handle regex format: /pattern/flags + if (patternStr.startsWith('/') && patternStr.length > 2) { + const lastSlash = patternStr.lastIndexOf('/'); + if (lastSlash > 0) { + const pattern = patternStr.slice(1, lastSlash); + const flags = patternStr.slice(lastSlash + 1); + + // Sanitize flags - only allow safe flags + const safeFlags = flags.replace(/[^gimsu]/g, ''); + + try { + // Compile and test the regex + const regex = new RegExp(pattern, safeFlags); + + // Quick test to ensure regex doesn't crash + 'test'.match(regex); + + return regex; + } catch (err) { + throw new Error(`Invalid regex pattern: ${err.message}`); + } + } + } + + // Handle wildcard patterns with proper word boundaries + if (patternStr.includes('*') || patternStr.includes('?')) { + // Escape regex special characters except * and ? + let escaped = patternStr.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + + // Convert wildcards to regex with word boundaries + // *word should match "anything ending with word" + // word* should match "word followed by anything" + // *word* should match "anything containing word" + + if (patternStr.startsWith('*') && patternStr.endsWith('*')) { + // *word* -> match anywhere (current behavior is correct) + escaped = escaped.replace(/\*/g, '.*'); + } else if (patternStr.startsWith('*')) { + // *word -> match ending with word + escaped = escaped.replace(/\*/g, '.*') + '$'; + } else if (patternStr.endsWith('*')) { + // word* -> match starting with word + escaped = '^' + escaped.replace(/\*/g, '.*'); + } else { + // no leading/trailing * -> convert normally + escaped = escaped.replace(/\*/g, '.*'); + } + + // Convert ? to single character + escaped = escaped.replace(/\?/g, '.'); + + return new RegExp(escaped, 'i'); + } + + // Plain text - escape all special characters and match as substring + const escaped = patternStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(escaped, 'i'); +} + +/** + * Validate pattern input to prevent malicious content + * @param {string} pattern - Pattern to validate + * @returns {string} - Cleaned and validated pattern + * @throws {Error} - If pattern is invalid + */ +export function validatePattern(pattern) { + if (typeof pattern !== 'string') { + throw new Error('Pattern must be a string'); + } + + // Remove control characters + // eslint-disable-next-line no-control-regex + const cleaned = pattern.replace(/[\x00-\x1F\x7F]/g, ''); + + // Check maximum length + if (cleaned.length > 500) { + throw new Error('Pattern too long (max 500 characters)'); + } + + if (cleaned.length === 0) { + throw new Error('Pattern cannot be empty'); + } + + // Test regex compilation to catch syntax errors early + try { + compileSafeRegex(cleaned); + } catch (err) { + throw new Error(`Pattern validation failed: ${err.message}`); + } + + return cleaned; +} + +/** + * Test pattern matching with timeout protection + * @param {RegExp} regex - Compiled regex pattern + * @param {string} testString - String to test against + * @param {number} timeoutMs - Timeout in milliseconds (default: 100) + * @returns {Promise} - Whether the pattern matches + */ +export function testPatternSafely(regex, testString, timeoutMs = 100) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Pattern matching timeout')); + }, timeoutMs); + + try { + const result = regex.test(testString); + clearTimeout(timeout); + resolve(result); + } catch (err) { + clearTimeout(timeout); + reject(err); + } + }); +} + +/** + * Match a pattern against a test string safely + * @param {string} pattern - Raw pattern string + * @param {string} testString - String to test + * @returns {Promise} - Whether the pattern matches + */ +export async function matchesPattern(pattern, testString) { + try { + // Return false for invalid inputs + if (!testString || typeof testString !== 'string') { + return false; + } + + // Ignore log-like sequences in brackets [TEXT] + if (/^\[.*\]$/.test(testString.trim())) { + return false; + } + + // Check for control characters + // eslint-disable-next-line no-control-regex + const hasControlChars = /[\x00-\x1F\x7F]/.test(testString); + + if (hasControlChars) { + // Only allow control chars if followed by log markers like [INFO], [DEBUG], etc. + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1F\x7F]\s*\[(?:INFO|DEBUG|ERROR|WARN|LOG)\]/.test(testString)) { + // This looks like genuine log content - allow matching the part before control chars + const regex = compileSafeRegex(pattern); + // eslint-disable-next-line no-control-regex + const cleanPart = testString.split(/[\x00-\x1F\x7F]/)[0]; + return cleanPart && regex.test(cleanPart); + } + + // Otherwise reject all strings with control characters + return false; + } + + // Compile the pattern safely + const regex = compileSafeRegex(pattern); + + // Test with timeout protection + return await testPatternSafely(regex, testString); + } catch (err) { + console.warn(`Pattern matching error for "${pattern}": ${err.message}`); + return false; + } +} + +/** + * Create a safe regex object with metadata + * @param {string} patternStr - Raw pattern string + * @returns {Object} - Object with raw pattern and compiled regex + */ +export function createPatternObject(rawPattern) { + const validated = validatePattern(rawPattern); + const regex = compileSafeRegex(validated); + return { + raw: validated, + regex + }; +} + +/** + * Batch validate and compile multiple patterns + * @param {string[]} patterns - Array of pattern strings + * @returns {Object[]} - Array of pattern objects + */ +export function validatePatterns(patterns) { + if (!Array.isArray(patterns)) { + throw new Error('Patterns must be an array'); + } + + const validatedPatterns = []; + const errors = []; + + for (let i = 0; i < patterns.length; i++) { + try { + const patternObj = createPatternObject(patterns[i]); + validatedPatterns.push(patternObj); + } catch (err) { + errors.push({ index: i, pattern: patterns[i], error: err.message }); + } + } + + return { + valid: validatedPatterns, + errors: errors + }; +} + +// Export all functions as a default object as well +export default { + compileSafeRegex, + validatePattern, + testPatternSafely, + matchesPattern, + createPatternObject, + validatePatterns +}; \ No newline at end of file diff --git a/settings.json b/settings.json deleted file mode 100644 index 67565be..0000000 --- a/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "action": "kick" -} \ No newline at end of file diff --git a/tests/debug-pattern-matching.js b/tests/debug-pattern-matching.js new file mode 100644 index 0000000..dc35ee4 --- /dev/null +++ b/tests/debug-pattern-matching.js @@ -0,0 +1,44 @@ +// debug_patterns.js - Quick debug script +import { createPatternObject, matchesPattern } from '../security.js'; + +async function debugTest() { + console.log("=== Debugging Pattern Matching ===\n"); + + // Test 1: Wildcard patterns work correctly + console.log("Test 1: Wildcard patterns"); + const pt1 = createPatternObject('*power'); + const pt2 = createPatternObject('max*'); + + console.log(`Pattern 1: ${pt1.raw} -> Regex: ${pt1.regex}`); + console.log(`Pattern 2: ${pt2.raw} -> Regex: ${pt2.regex}`); + + const test1a = await matchesPattern(pt1.raw, 'testpower'); + const test1b = await matchesPattern(pt1.raw, 'testpowerz'); + const test2a = await matchesPattern(pt2.raw, 'maxwell'); + + console.log(`'*power' vs 'testpower': ${test1a} (should be true)`); + console.log(`'*power' vs 'testpowerz': ${test1b} (should be false)`); + console.log(`'max*' vs 'maxwell': ${test2a} (should be true)`); + console.log(""); + + // Test 2: Control characters + console.log("Test 2: Control characters"); + const pt3 = createPatternObject('/^max.*power$/i'); + const test3a = await matchesPattern(pt3.raw, 'max power'); + const test3b = await matchesPattern(pt3.raw, 'max\npower'); + + console.log(`'/^max.*power$/i' vs 'max power': ${test3a} (should be true)`); + console.log(`'/^max.*power$/i' vs 'max\\npower': ${test3b} (should be false)`); + console.log(""); + + // Test 3: Unicode + console.log("Test 3: Unicode"); + const pt4 = createPatternObject('solana'); + const test4a = await matchesPattern(pt4.raw, 'Solana SPIN'); + const test4b = await matchesPattern(pt4.raw, 'Sølana'); + + console.log(`'solana' vs 'Solana SPIN': ${test4a} (should be true)`); + console.log(`'solana' vs 'Sølana': ${test4b} (should be false)`); +} + +debugTest().catch(console.error); \ No newline at end of file diff --git a/tests/hits.test.js b/tests/hits.test.js new file mode 100644 index 0000000..d3fb3ac --- /dev/null +++ b/tests/hits.test.js @@ -0,0 +1,18 @@ +import { test } from 'uvu'; +import * as assert from 'uvu/assert'; + +// Suppose you export incrementHitCounter/getHitStatsForGroup from bot.js or a helpers file +import { incrementHitCounter, getHitStatsForGroup } from '../bot.js'; + +test('incrementHitCounter and getHitStatsForGroup', () => { + incrementHitCounter(123, 'abc'); + incrementHitCounter(123, 'abc'); + incrementHitCounter(123, 'def'); + const stats = getHitStatsForGroup(123); + assert.equal(stats, [ + { pattern: 'abc', count: 2 }, + { pattern: 'def', count: 1 } + ]); +}); + +test.run(); diff --git a/tests/patterns.test.js b/tests/patterns.test.js new file mode 100644 index 0000000..145f92f --- /dev/null +++ b/tests/patterns.test.js @@ -0,0 +1,87 @@ +// tests/patterns.test.js + +import { test } from 'uvu'; +import * as assert from 'uvu/assert'; + +// Import the helpers (adapt import path as needed) +import { createPatternObject, matchesPattern } from '../security.js'; + +test('Simple text pattern matches exact and case-insensitive', async () => { + const pt = createPatternObject('max power'); + assert.ok(await matchesPattern(pt.raw, 'Max Power')); + assert.ok(await matchesPattern(pt.raw, 'MAX POWER')); + assert.not(await matchesPattern(pt.raw, 'maxwell power')); +}); + +test('Wildcard patterns work correctly', async () => { + const pt1 = createPatternObject('*power'); + const pt2 = createPatternObject('max*'); + assert.ok(await matchesPattern(pt1.raw, 'testpower')); + assert.ok(await matchesPattern(pt2.raw, 'maxwell')); + assert.not(await matchesPattern(pt1.raw, 'testpowerz')); +}); + +test('Pattern does not match on empty/invalid inputs', async () => { + const pt = createPatternObject('foo'); + assert.not(await matchesPattern(pt.raw, '')); + assert.not(await matchesPattern(pt.raw, null)); + assert.not(await matchesPattern(pt.raw, undefined)); +}); + +test('Regex pattern works, is not bypassed by whitespace or log symbols', async () => { + const pt = createPatternObject('/^max.*power$/i'); + assert.ok(await matchesPattern(pt.raw, 'max power')); + assert.ok(await matchesPattern(pt.raw, 'MAXpower')); + assert.not(await matchesPattern(pt.raw, 'powermax')); + // Avoid bypass with special log control chars + assert.not(await matchesPattern(pt.raw, 'max\npower')); + assert.not(await matchesPattern(pt.raw, 'max\rpower')); +}); + +test('Malicious/escaped input does not break logic', async () => { + // Inputs with backslashes or dangerous regex attempts + const pt = createPatternObject('test*'); + assert.ok(await matchesPattern(pt.raw, 'test\\evil')); // Should be treated as a wildcard, not an escape + assert.not(await matchesPattern(pt.raw, 'eviltest')); + // Malicious input string + assert.not(await matchesPattern(pt.raw, 'badinput*')); +}); + +test('Rejects dangerous regex patterns (re DoS, log pollution)', async () => { + // Should throw or fail to validate a catastrophic backtracking regex + try { + createPatternObject('/(a+)+$/'); + assert.unreachable('Should throw for dangerous regex'); + } catch (e) { + assert.match(e.message, /dangerous|timeout|invalid|unsupported/i); + } +}); + +test('No match with special log/control characters in input or pattern', async () => { + const pt = createPatternObject('testuser'); + assert.not(await matchesPattern(pt.raw, 'testuser\ninjection')); + assert.not(await matchesPattern(pt.raw, 'testuser\r')); + assert.not(await matchesPattern(pt.raw, 'testuser\0')); +}); + +test('Does not match on substrings unless pattern allows', async () => { + const pt = createPatternObject('power'); + assert.ok(await matchesPattern(pt.raw, 'POWER')); + assert.ok(await matchesPattern(pt.raw, 'superpower')); + assert.ok(await matchesPattern(pt.raw, 'powerful')); + assert.not(await matchesPattern(pt.raw, 'pow')); + // For stricter: use regex ^power$ + const ptStrict = createPatternObject('/^power$/i'); + assert.ok(await matchesPattern(ptStrict.raw, 'power')); + assert.not(await matchesPattern(ptStrict.raw, 'superpower')); +}); + +test('Ignores log-like sequences and unusual Unicode in name', async () => { + const pt = createPatternObject('solana'); + assert.ok(await matchesPattern(pt.raw, 'Solana SPIN')); + assert.not(await matchesPattern(pt.raw, 'Sølana')); + assert.not(await matchesPattern(pt.raw, '[SOLANA]')); + assert.ok(await matchesPattern(pt.raw, 'solana spin\n[INFO] User logged in')); +}); + +test.run(); diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..d57b1bc --- /dev/null +++ b/update.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +# Log file for update operations +LOG_FILE="/var/log/banbot-updater.log" +REPO_DIR="/root/repos/nameBanBot" +SERVICE_NAME="namebanbot.service" + +# Function to log messages +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +# Navigate to the repository directory +cd "$REPO_DIR" || { + log "Failed to change directory to $REPO_DIR. Exiting." + exit 1 +} + +log "Starting update check for banBaby bot" + +# Save the current HEAD commit hash +OLD_HEAD=$(git rev-parse HEAD) + +# Pull the latest changes from the main branch +log "Pulling latest changes from GitHub..." +git fetch origin main +git reset --hard origin/main + +# Get the new HEAD commit hash +NEW_HEAD=$(git rev-parse HEAD) + +# Check if there were any new commits +if [ "$OLD_HEAD" = "$NEW_HEAD" ]; then + log "No new changes detected. Exiting." + exit 0 +fi + +log "Changes detected. New commits: $(git log --oneline $OLD_HEAD..$NEW_HEAD)" + +# Skip git tracking for config.js (but keep the file) +git update-index --skip-worktree config.js +log "Set config.js to skip-worktree to preserve local changes" + +# Install or update dependencies +log "Installing dependencies..." +yarn install + +# Restart the service +log "Restarting the bot service... +systemctl restart "$SERVICE_NAME" + +log "Update completed successfully" diff --git a/yarn.lock b/yarn.lock index 6dca65a..e720137 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@bcoe/v8-coverage@^1.0.1": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" + integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== + "@eslint-community/eslint-utils@^4.2.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz#bfe67b3d334a8579a35e48fe240dc0638d1bcd91" @@ -103,6 +108,46 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.2.tgz#1860473de7dfa1546767448f333db80cb0ff2161" integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ== +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.12": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + "@telegraf/types@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@telegraf/types/-/types-7.1.0.tgz#d8bd9b2f5070b4de46971416e890338cd89fc23d" @@ -113,6 +158,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== +"@types/istanbul-lib-coverage@^2.0.1": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -145,13 +195,28 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-styles@^4.1.0: +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: color-convert "^2.0.1" +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -170,6 +235,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + buffer-alloc-unsafe@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" @@ -188,6 +260,23 @@ buffer-fill@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" integrity sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ== +c8@^10.1.3: + version "10.1.3" + resolved "https://registry.yarnpkg.com/c8/-/c8-10.1.3.tgz#54afb25ebdcc7f3b00112482c6d90d7541ad2fcd" + integrity sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA== + dependencies: + "@bcoe/v8-coverage" "^1.0.1" + "@istanbuljs/schema" "^0.1.3" + find-up "^5.0.0" + foreground-child "^3.1.1" + istanbul-lib-coverage "^3.2.0" + istanbul-lib-report "^3.0.1" + istanbul-reports "^3.1.6" + test-exclude "^7.0.1" + v8-to-istanbul "^9.0.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -201,6 +290,15 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" @@ -218,6 +316,11 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -239,11 +342,41 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +diff@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + dotenv@^16.5.0: version "16.5.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.5.0.tgz#092b49f25f808f020050051d1ff258e404c78692" integrity sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg== +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -389,10 +522,18 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== -fs@^0.0.1-security: - version "0.0.1-security" - resolved "https://registry.yarnpkg.com/fs/-/fs-0.0.1-security.tgz#8a7bd37186b6dddf3813f23858b57ecaaf5e41d4" - integrity sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w== +foreground-child@^3.1.0, foreground-child@^3.1.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== glob-parent@^6.0.2: version "6.0.2" @@ -401,6 +542,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@^10.4.1: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + globals@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" @@ -416,6 +569,11 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -439,6 +597,11 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + is-glob@^4.0.0, is-glob@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -451,6 +614,37 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-reports@^3.1.6: + version "3.1.7" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" + integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" @@ -480,6 +674,11 @@ keyv@^4.5.4: dependencies: json-buffer "3.0.1" +kleur@^4.0.3: + version "4.1.5" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" + integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -500,6 +699,18 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -507,7 +718,19 @@ minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -mri@^1.2.0: +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +mri@^1.1.0, mri@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== @@ -560,6 +783,11 @@ p-timeout@^4.1.0: resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-4.1.0.tgz#788253c0452ab0ffecf18a62dff94ff1bd09ca0a" integrity sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw== +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -577,6 +805,14 @@ path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -587,11 +823,23 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +sade@^1.7.3: + version "1.8.1" + resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" + integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== + dependencies: + mri "^1.1.0" + safe-compare@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/safe-compare/-/safe-compare-1.1.4.tgz#5e0128538a82820e2e9250cd78e45da6786ba593" @@ -604,6 +852,11 @@ sandwich-stream@^2.0.2: resolved "https://registry.yarnpkg.com/sandwich-stream/-/sandwich-stream-2.0.2.tgz#6d1feb6cf7e9fe9fadb41513459a72c2e84000fa" integrity sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ== +semver@^7.5.3: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -616,6 +869,59 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" @@ -642,6 +948,15 @@ telegraf@^4.16.3: safe-compare "^1.1.4" sandwich-stream "^2.0.2" +test-exclude@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" + integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^10.4.1" + minimatch "^9.0.4" + toml@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee" @@ -666,6 +981,25 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +uvu@^0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df" + integrity sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA== + dependencies: + dequal "^2.0.0" + diff "^5.0.0" + kleur "^4.0.3" + sade "^1.7.3" + +v8-to-istanbul@^9.0.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" + integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^2.0.0" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -691,6 +1025,56 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"