From 8d1e59103d0a934efe76353675ca9669195f8db7 Mon Sep 17 00:00:00 2001 From: Tori Date: Mon, 30 Mar 2026 00:03:39 -0500 Subject: [PATCH 1/2] feat: allow /block and /unblock by numeric Telegram ID (#701) - Add resolveUser() helper that accepts @username or numeric tg_id - Update block/unblock/blocklist commands to use resolveUser - Show 'ID: ' in /blocklist for users without a username - Add isValidBlockTarget() validation in command router - Add invalid_block_target locale key in all 10 supported languages - Add unit tests covering all new and existing block command flows --- bot/modules/block/commands.ts | 30 ++- bot/modules/block/index.ts | 8 +- bot/modules/block/messages.ts | 10 +- locales/de.yaml | 1 + locales/en.yaml | 1 + locales/es.yaml | 1 + locales/fa.yaml | 1 + locales/fr.yaml | 1 + locales/it.yaml | 1 + locales/ko.yaml | 1 + locales/pt.yaml | 1 + locales/ru.yaml | 1 + locales/uk.yaml | 1 + tests/bot/modules/block/commands.spec.ts | 237 +++++++++++++++++++++++ 14 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 tests/bot/modules/block/commands.spec.ts diff --git a/bot/modules/block/commands.ts b/bot/modules/block/commands.ts index 836129ed..5054d0bd 100644 --- a/bot/modules/block/commands.ts +++ b/bot/modules/block/commands.ts @@ -3,8 +3,22 @@ import * as messages from './messages'; import * as globalMessages from '../../messages'; import { MainContext } from '../../start'; -const block = async (ctx: MainContext, username: string): Promise => { - const userToBlock = await User.findOne({ username: username.substring(1) }); +/** + * Resolve a user by @username or numeric Telegram ID. + * Returns null if the argument format is invalid or the user is not found. + */ +const resolveUser = async (arg: string) => { + if (arg.startsWith('@')) { + return User.findOne({ username: arg.substring(1) }); + } + if (/^\d+$/.test(arg)) { + return User.findOne({ tg_id: arg }); + } + return null; +}; + +const block = async (ctx: MainContext, target: string): Promise => { + const userToBlock = await resolveUser(target); const user = ctx.user; if (!userToBlock) { @@ -52,8 +66,8 @@ const block = async (ctx: MainContext, username: string): Promise => { await messages.userBlocked(ctx); }; -const unblock = async (ctx: MainContext, username: string): Promise => { - const userToUnblock = await User.findOne({ username: username.substring(1) }); +const unblock = async (ctx: MainContext, target: string): Promise => { + const userToUnblock = await resolveUser(target); if (!userToUnblock) { await globalMessages.notFoundUserMessage(ctx); return; @@ -84,7 +98,13 @@ const blocklist = async (ctx: MainContext): Promise => { } const usersBlocked = await User.find({ tg_id: { $in: tgIdBlocks } }); - await messages.blocklistMessage(ctx, usersBlocked); + + // For tg_ids that have no matching user record, pass the raw id so the + // message layer can still display something meaningful. + const foundIds = new Set(usersBlocked.map(u => u.tg_id)); + const unknownIds = tgIdBlocks.filter((id: string) => !foundIds.has(id)); + + await messages.blocklistMessage(ctx, usersBlocked, unknownIds); }; export { block, unblock, blocklist }; diff --git a/bot/modules/block/index.ts b/bot/modules/block/index.ts index 26d0dd94..241866d0 100644 --- a/bot/modules/block/index.ts +++ b/bot/modules/block/index.ts @@ -5,16 +5,20 @@ import { logger } from '../../../logger'; const commands = require('./commands'); const { userMiddleware } = require('../../middleware/user'); +/** Returns true for @username or a numeric Telegram user ID */ +const isValidBlockTarget = (arg: string) => + arg.startsWith('@') || /^\d+$/.test(arg); + export const configure = (bot: Telegraf) => { bot.command('block', userMiddleware, async (ctx, next) => { const args = ctx.message.text.split(' ') || []; - if (args.length !== 2) return next(); + if (args.length !== 2 || !isValidBlockTarget(args[1])) return next(); commands.block(ctx, args[1]); }); bot.command('unblock', userMiddleware, async (ctx, next) => { const args = ctx.message.text.split(' ') || []; - if (args.length !== 2) return next(); + if (args.length !== 2 || !isValidBlockTarget(args[1])) return next(); commands.unblock(ctx, args[1]); }); diff --git a/bot/modules/block/messages.ts b/bot/modules/block/messages.ts index 5a19f840..dd8bc97a 100644 --- a/bot/modules/block/messages.ts +++ b/bot/modules/block/messages.ts @@ -38,13 +38,17 @@ const userUnblocked = async (ctx: MainContext) => { const blocklistMessage = async ( ctx: MainContext, usersBlocked: UserDocument[], + unknownIds: string[] = [], ) => { try { - if (!usersBlocked?.length) { + if (!usersBlocked?.length && !unknownIds.length) { return await blocklistEmptyMessage(ctx); } - const userList = usersBlocked.map(block => '@' + block.username); - ctx.reply(userList.join('\n')); + const lines: string[] = [ + ...usersBlocked.map(u => (u.username ? '@' + u.username : `ID: ${u.tg_id}`)), + ...unknownIds.map(id => `ID: ${id}`), + ]; + ctx.reply(lines.join('\n')); } catch (error) { logger.error(error); } diff --git a/locales/de.yaml b/locales/de.yaml index 0646651c..b2cff211 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -691,6 +691,7 @@ user_already_blocked: Benutzer ist bereits gesperrt user_blocked: Benutzer erfolgreich gesperrt user_unblocked: Benutzer erfolgreich entsperrt blocklist_empty: Du hast keine gesperrten Benutzer +invalid_block_target: "Bitte gib einen gültigen @username oder eine numerische Telegram-Benutzer-ID an" orders_in_process: Es gibt laufende Aufträge mit diesem Benutzer user_order_is_blocked_by_user_taker: Du kannst diesen Auftrag nicht annehmen, da du seinen Ersteller gesperrt hast user_taker_is_blocked_by_user_order: Du kannst diesen Auftrag nicht annehmen, da dich sein Ersteller gesperrt hat diff --git a/locales/en.yaml b/locales/en.yaml index b41af155..9aebfeec 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -696,6 +696,7 @@ user_already_blocked: User is already blocked user_blocked: User successfully blocked user_unblocked: User successfully unblocked blocklist_empty: You do not have any blocked user +invalid_block_target: "Please provide a valid @username or numeric Telegram user ID" orders_in_process: There are orders in process with this user user_order_is_blocked_by_user_taker: You can't take this order because you blocked its maker user_taker_is_blocked_by_user_order: You can't take this order because its maker blocked you diff --git a/locales/es.yaml b/locales/es.yaml index e23f825f..49dc17d9 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -693,6 +693,7 @@ user_already_blocked: El usuario ya está bloqueado user_blocked: Usuario bloqueado correctamente user_unblocked: Usuario desbloqueado correctamente blocklist_empty: No tienes ningun usuario bloqueado +invalid_block_target: "Por favor proporciona un @username válido o un ID numérico de Telegram" orders_in_process: Hay ordenes en proceso con este usuario user_order_is_blocked_by_user_taker: No puedes aceptar esta oferta porque has bloqueado a su creador user_taker_is_blocked_by_user_order: No puedes aceptar esta oferta porque su creador te ha bloqueado diff --git a/locales/fa.yaml b/locales/fa.yaml index 6fc01002..a4f8f2a0 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -785,6 +785,7 @@ user_already_blocked: کاربر از پیش مسدود شده بود. user_blocked: کاربر با موفقیت مسدود شد. user_unblocked: انسداد کاربر با موفقیت خاتمه یافت. blocklist_empty: شما هیچ کاربر مسدود شده‌ای ندارید. +invalid_block_target: 'لطفاً یک @username معتبر یا شناسه عددی کاربر تلگرام وارد کنید' orders_in_process: سفارش‌های در جریانی وجود دارند که این کاربر در آن‌ها دخیل است. user_order_is_blocked_by_user_taker: شما نمی‌توانید این سفارش را بردارید زیرا گذارنده آن را مسدود کرده‌اید. user_taker_is_blocked_by_user_order: شما نمی‌توانید این سفارش را بردارید زیرا توسط گذارنده آن مسدود شده‌اید. diff --git a/locales/fr.yaml b/locales/fr.yaml index 6471f1e5..34b60102 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -690,6 +690,7 @@ user_already_blocked: L'utilisateur est déjà bloqué user_blocked: Utilisateur bloqué avec succès user_unblocked: Utilisateur débloqué avec succès blocklist_empty: Vous n'avez aucun utilisateur bloqué +invalid_block_target: "Veuillez fournir un @username valide ou un ID numérique d'utilisateur Telegram" orders_in_process: Il y a des ordres en cours avec cet utilisateur user_order_is_blocked_by_user_taker: Vous ne pouvez pas accepter cette offre car vous avez bloqué son créateur user_taker_is_blocked_by_user_order: Vous ne pouvez pas accepter cette offre car son créateur vous a bloqué diff --git a/locales/it.yaml b/locales/it.yaml index 125e2a98..51d1231d 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -688,6 +688,7 @@ user_already_blocked: L'utente è già bloccato user_blocked: Utente bloccato con successo user_unblocked: Utente sbloccato con successo blocklist_empty: Non hai alcun utente bloccato +invalid_block_target: "Fornisci un @username valido o un ID numerico utente Telegram" orders_in_process: Ci sono ordini in corso con questo utente user_order_is_blocked_by_user_taker: Non puoi accettare questo ordine perché hai bloccato il suo creatore user_taker_is_blocked_by_user_order: Non puoi accettare questo ordine perché il suo creatore ti ha bloccato diff --git a/locales/ko.yaml b/locales/ko.yaml index d727655f..6063573a 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -688,6 +688,7 @@ user_already_blocked: User is already blocked user_blocked: User successfully blocked user_unblocked: User successfully unblocked blocklist_empty: You do not have any blocked user +invalid_block_target: "Please provide a valid @username or numeric Telegram user ID" orders_in_process: There are orders in process with this user user_order_is_blocked_by_user_taker: You can't take this order because you blocked its maker user_taker_is_blocked_by_user_order: You can't take this order because its maker blocked you diff --git a/locales/pt.yaml b/locales/pt.yaml index 1410115e..b4eb6786 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -690,6 +690,7 @@ user_already_blocked: Usuário já está bloqueado user_blocked: Usuário bloqueado com sucesso user_unblocked: Usuário desbloqueado com sucesso blocklist_empty: Você não tem nenhum usuário bloqueado +invalid_block_target: "Por favor, forneça um @username válido ou um ID numérico de usuário do Telegram" orders_in_process: Existem ordens em andamento com este usuário user_order_is_blocked_by_user_taker: Você não pode aceitar esta oferta porque bloqueou seu criador user_taker_is_blocked_by_user_order: Você não pode aceitar esta oferta porque seu criador bloqueou você diff --git a/locales/ru.yaml b/locales/ru.yaml index 42dcaf4a..d84c283e 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -691,6 +691,7 @@ user_already_blocked: User is already blocked user_blocked: User successfully blocked user_unblocked: User successfully unblocked blocklist_empty: You do not have any blocked user +invalid_block_target: "Please provide a valid @username or numeric Telegram user ID" orders_in_process: There are orders in process with this user user_order_is_blocked_by_user_taker: You can't take this order because you blocked its maker user_taker_is_blocked_by_user_order: You can't take this order because its maker blocked you diff --git a/locales/uk.yaml b/locales/uk.yaml index 06b86bed..99e92152 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -687,6 +687,7 @@ user_already_blocked: User is already blocked user_blocked: User successfully blocked user_unblocked: User successfully unblocked blocklist_empty: You do not have any blocked user +invalid_block_target: "Please provide a valid @username or numeric Telegram user ID" orders_in_process: There are orders in process with this user user_order_is_blocked_by_user_taker: You can't take this order because you blocked its maker user_taker_is_blocked_by_user_order: You can't take this order because its maker blocked you diff --git a/tests/bot/modules/block/commands.spec.ts b/tests/bot/modules/block/commands.spec.ts new file mode 100644 index 00000000..777e97b5 --- /dev/null +++ b/tests/bot/modules/block/commands.spec.ts @@ -0,0 +1,237 @@ +export {}; +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +describe('Block module – commands', () => { + let sandbox: any; + let ctx: any; + let commands: any; + + // Model stubs + let userFindOneStub: any; + let blockExistsStub: any; + let blockSaveStub: any; + let blockDeleteOneStub: any; + let orderExistsStub: any; + let blockFindStub: any; + let userFindStub: any; + + // Message stubs + let notFoundUserMessageStub: any; + let userBlockedStub: any; + let userAlreadyBlockedStub: any; + let ordersInProcessStub: any; + let userUnblockedStub: any; + let blocklistMessageStub: any; + let blocklistEmptyMessageStub: any; + + const makeUser = (overrides = {}) => ({ + id: 'user-db-id', + tg_id: '111', + username: 'alice', + ...overrides, + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + ctx = { + i18n: { t: (k: string) => k }, + reply: sandbox.stub(), + user: makeUser({ tg_id: '111', username: 'alice' }), + }; + + userFindOneStub = sandbox.stub(); + blockExistsStub = sandbox.stub(); + blockSaveStub = sandbox.stub().resolves(); + blockDeleteOneStub = sandbox.stub(); + orderExistsStub = sandbox.stub(); + blockFindStub = sandbox.stub(); + userFindStub = sandbox.stub(); + + notFoundUserMessageStub = sandbox.stub().resolves(); + userBlockedStub = sandbox.stub().resolves(); + userAlreadyBlockedStub = sandbox.stub().resolves(); + ordersInProcessStub = sandbox.stub().resolves(); + userUnblockedStub = sandbox.stub().resolves(); + blocklistMessageStub = sandbox.stub().resolves(); + blocklistEmptyMessageStub = sandbox.stub().resolves(); + + const BlockConstructorStub = function (this: any, data: any) { + Object.assign(this, data); + this.save = blockSaveStub; + }; + BlockConstructorStub.exists = blockExistsStub; + BlockConstructorStub.deleteOne = blockDeleteOneStub; + BlockConstructorStub.find = blockFindStub; + + commands = proxyquire('../../../../bot/modules/block/commands', { + '../../../models': { + User: { findOne: userFindOneStub, find: userFindStub }, + Block: BlockConstructorStub, + Order: { exists: orderExistsStub }, + }, + './messages': { + userBlocked: userBlockedStub, + userAlreadyBlocked: userAlreadyBlockedStub, + ordersInProcess: ordersInProcessStub, + userUnblocked: userUnblockedStub, + blocklistMessage: blocklistMessageStub, + blocklistEmptyMessage: blocklistEmptyMessageStub, + }, + '../../messages': { + notFoundUserMessage: notFoundUserMessageStub, + }, + }); + }); + + afterEach(() => sandbox.restore()); + + // ─── resolveUser (tested indirectly through block) ─────────────────────── + + describe('block', () => { + it('resolves user by @username', async () => { + const target = makeUser({ tg_id: '222', username: 'spammer' }); + userFindOneStub.resolves(target); + orderExistsStub.resolves(false); + blockExistsStub.resolves(false); + + await commands.block(ctx, '@spammer'); + + expect(userFindOneStub.calledWith({ username: 'spammer' })).to.equal( + true, + ); + expect(blockSaveStub.called).to.equal(true); + expect(userBlockedStub.called).to.equal(true); + }); + + it('resolves user by numeric Telegram ID', async () => { + const target = makeUser({ tg_id: '222', username: 'spammer' }); + userFindOneStub.resolves(target); + orderExistsStub.resolves(false); + blockExistsStub.resolves(false); + + await commands.block(ctx, '222'); + + expect(userFindOneStub.calledWith({ tg_id: '222' })).to.equal(true); + expect(blockSaveStub.called).to.equal(true); + expect(userBlockedStub.called).to.equal(true); + }); + + it('replies not found when user does not exist', async () => { + userFindOneStub.resolves(null); + + await commands.block(ctx, '999999'); + + expect(notFoundUserMessageStub.called).to.equal(true); + expect(blockSaveStub.called).to.equal(false); + }); + + it('rejects block when there are active orders between the two users', async () => { + const target = makeUser({ tg_id: '222' }); + userFindOneStub.resolves(target); + orderExistsStub.resolves(true); + + await commands.block(ctx, '222'); + + expect(ordersInProcessStub.called).to.equal(true); + expect(blockSaveStub.called).to.equal(false); + }); + + it('rejects block when user is already blocked', async () => { + const target = makeUser({ tg_id: '222' }); + userFindOneStub.resolves(target); + orderExistsStub.resolves(false); + blockExistsStub.resolves(true); + + await commands.block(ctx, '222'); + + expect(userAlreadyBlockedStub.called).to.equal(true); + expect(blockSaveStub.called).to.equal(false); + }); + }); + + // ─── unblock ───────────────────────────────────────────────────────────── + + describe('unblock', () => { + it('unblocks user found by @username', async () => { + const target = makeUser({ tg_id: '222' }); + userFindOneStub.resolves(target); + blockDeleteOneStub.resolves({ deletedCount: 1 }); + + await commands.unblock(ctx, '@spammer'); + + expect(userFindOneStub.calledWith({ username: 'spammer' })).to.equal( + true, + ); + expect(userUnblockedStub.called).to.equal(true); + }); + + it('unblocks user found by numeric ID', async () => { + const target = makeUser({ tg_id: '222' }); + userFindOneStub.resolves(target); + blockDeleteOneStub.resolves({ deletedCount: 1 }); + + await commands.unblock(ctx, '222'); + + expect(userFindOneStub.calledWith({ tg_id: '222' })).to.equal(true); + expect(userUnblockedStub.called).to.equal(true); + }); + + it('replies not found when user does not exist', async () => { + userFindOneStub.resolves(null); + + await commands.unblock(ctx, '999'); + + expect(notFoundUserMessageStub.called).to.equal(true); + }); + + it('replies not found when block record does not exist', async () => { + const target = makeUser({ tg_id: '222' }); + userFindOneStub.resolves(target); + blockDeleteOneStub.resolves({ deletedCount: 0 }); + + await commands.unblock(ctx, '222'); + + expect(notFoundUserMessageStub.called).to.equal(true); + }); + }); + + // ─── blocklist ──────────────────────────────────────────────────────────── + + describe('blocklist', () => { + it('shows empty message when no blocks exist', async () => { + blockFindStub.resolves([]); + + await commands.blocklist(ctx); + + expect(blocklistEmptyMessageStub.called).to.equal(true); + expect(blocklistMessageStub.called).to.equal(false); + }); + + it('shows blocked users with known usernames', async () => { + blockFindStub.resolves([{ blocked_tg_id: '222' }]); + userFindStub.resolves([makeUser({ tg_id: '222', username: 'spammer' })]); + + await commands.blocklist(ctx); + + expect(blocklistMessageStub.called).to.equal(true); + const [, users, unknownIds] = blocklistMessageStub.firstCall.args; + expect(users).to.have.lengthOf(1); + expect(unknownIds).to.have.lengthOf(0); + }); + + it('passes unknownIds for blocked tg_ids with no User record', async () => { + blockFindStub.resolves([{ blocked_tg_id: '999' }]); + userFindStub.resolves([]); // no matching user in DB + + await commands.blocklist(ctx); + + expect(blocklistMessageStub.called).to.equal(true); + const [, users, unknownIds] = blocklistMessageStub.firstCall.args; + expect(users).to.have.lengthOf(0); + expect(unknownIds).to.deep.equal(['999']); + }); + }); +}); From e5a8c575a6862c39559689e0466ffaaf8c9fe0c8 Mon Sep 17 00:00:00 2001 From: Tori Date: Mon, 30 Mar 2026 00:37:46 -0500 Subject: [PATCH 2/2] fix: reply with error message for invalid block target format - Use invalid_block_target locale key instead of silently calling next() when the argument is neither @username nor numeric ID - Add Persian, Korean, Russian and Ukrainian translations for invalid_block_target key --- bot/modules/block/index.ts | 12 ++++++++++-- locales/ko.yaml | 2 +- locales/ru.yaml | 2 +- locales/uk.yaml | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/bot/modules/block/index.ts b/bot/modules/block/index.ts index 241866d0..d9735879 100644 --- a/bot/modules/block/index.ts +++ b/bot/modules/block/index.ts @@ -12,13 +12,21 @@ const isValidBlockTarget = (arg: string) => export const configure = (bot: Telegraf) => { bot.command('block', userMiddleware, async (ctx, next) => { const args = ctx.message.text.split(' ') || []; - if (args.length !== 2 || !isValidBlockTarget(args[1])) return next(); + if (args.length !== 2) return next(); + if (!isValidBlockTarget(args[1])) { + await ctx.reply(ctx.i18n.t('invalid_block_target')); + return; + } commands.block(ctx, args[1]); }); bot.command('unblock', userMiddleware, async (ctx, next) => { const args = ctx.message.text.split(' ') || []; - if (args.length !== 2 || !isValidBlockTarget(args[1])) return next(); + if (args.length !== 2) return next(); + if (!isValidBlockTarget(args[1])) { + await ctx.reply(ctx.i18n.t('invalid_block_target')); + return; + } commands.unblock(ctx, args[1]); }); diff --git a/locales/ko.yaml b/locales/ko.yaml index 6063573a..125c30a4 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -688,7 +688,7 @@ user_already_blocked: User is already blocked user_blocked: User successfully blocked user_unblocked: User successfully unblocked blocklist_empty: You do not have any blocked user -invalid_block_target: "Please provide a valid @username or numeric Telegram user ID" +invalid_block_target: "유효한 @username 또는 숫자로 된 텔레그램 사용자 ID를 입력해 주세요" orders_in_process: There are orders in process with this user user_order_is_blocked_by_user_taker: You can't take this order because you blocked its maker user_taker_is_blocked_by_user_order: You can't take this order because its maker blocked you diff --git a/locales/ru.yaml b/locales/ru.yaml index d84c283e..6dfd0254 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -691,7 +691,7 @@ user_already_blocked: User is already blocked user_blocked: User successfully blocked user_unblocked: User successfully unblocked blocklist_empty: You do not have any blocked user -invalid_block_target: "Please provide a valid @username or numeric Telegram user ID" +invalid_block_target: "Пожалуйста, укажите действительный @username или числовой Telegram ID пользователя" orders_in_process: There are orders in process with this user user_order_is_blocked_by_user_taker: You can't take this order because you blocked its maker user_taker_is_blocked_by_user_order: You can't take this order because its maker blocked you diff --git a/locales/uk.yaml b/locales/uk.yaml index 99e92152..12760b1e 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -687,7 +687,7 @@ user_already_blocked: User is already blocked user_blocked: User successfully blocked user_unblocked: User successfully unblocked blocklist_empty: You do not have any blocked user -invalid_block_target: "Please provide a valid @username or numeric Telegram user ID" +invalid_block_target: "Будь ласка, вкажіть дійсний @username або числовий Telegram ID користувача" orders_in_process: There are orders in process with this user user_order_is_blocked_by_user_taker: You can't take this order because you blocked its maker user_taker_is_blocked_by_user_order: You can't take this order because its maker blocked you