Files

407 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dotenv/config';
import fs from 'node:fs';
import { Telegraf, Markup } from 'telegraf';
import { generatePassword, strength, generateEmail, EMAIL_DOMAINS } from './lib/generator.js';
const boot = (m) =>
fs.appendFileSync(
new URL('./bot.log', import.meta.url),
new Date().toISOString() + ' ' + m + '\n'
);
const TOKEN = process.env.BOT_TOKEN;
const OWNER_ID = Number(process.env.OWNER_ID);
if (!TOKEN) {
console.error('BOT_TOKEN not set in .env');
process.exit(1);
}
const bot = new Telegraf(TOKEN, { handlerTimeout: 5_000 });
/* ---------- User settings state (in-memory) ---------- */
const defaults = () => ({
length: 16,
upper: true,
lower: true,
digits: true,
symbols: true,
excludeAmbiguous: false,
pronounceable: false,
emailStyle: 0,
emailDomain: 0,
emailNumber: true,
});
const users = new Map();
const history = new Map(); // userId -> [passwords]
const state = (id) => {
if (!users.has(id)) users.set(id, defaults());
return users.get(id);
};
const addHistory = (id, pwd) => {
if (!history.has(id)) history.set(id, []);
const h = history.get(id);
h.unshift(pwd);
if (h.length > 5) h.pop();
};
/* ---------- Keyboard builders ---------- */
const mainMenu = () =>
Markup.inlineKeyboard([
[Markup.button.callback('🔑 Сгенерировать пароль', 'gen')],
[Markup.button.callback('🎰 Пачка из 5', 'gen5')],
[Markup.button.callback('📧 Почтовый адрес', 'email')],
[Markup.button.callback('⚙️ Настройки', 'settings')],
[Markup.button.callback('📋 История', 'history')],
[Markup.button.callback('ℹ️ О боте', 'about')],
]);
const settingsKb = (s) => {
const on = (v) => (v ? '✅' : '❎');
return Markup.inlineKeyboard([
[
Markup.button.callback('➖ 5', 'len:-5'),
Markup.button.callback('➖ 1', 'len:-1'),
Markup.button.callback(`📏 ${s.length}`, 'noop'),
Markup.button.callback('➕ 1', 'len:+1'),
Markup.button.callback('➕ 5', 'len:+5'),
],
[Markup.button.callback(`${on(s.upper)} Заглавные (A-Z)`, 'set:upper')],
[Markup.button.callback(`${on(s.lower)} Строчные (a-z)`, 'set:lower')],
[Markup.button.callback(`${on(s.digits)} Цифры (0-9)`, 'set:digits')],
[Markup.button.callback(`${on(s.symbols)} Символы (!@#$)`, 'set:symbols')],
[Markup.button.callback(`${on(s.excludeAmbiguous)} Без похожих (il1Lo0O)`, 'set:excludeAmbiguous')],
[Markup.button.callback(`${on(s.pronounceable)} Произносимый`, 'set:pronounceable')],
[Markup.button.callback('🔑 Сгенерировать', 'gen'), Markup.button.callback('⬅️ Назад', 'main')],
]);
};
const resultKb = () =>
Markup.inlineKeyboard([
[Markup.button.callback('🔁 Ещё раз', 'gen'), Markup.button.callback('🎰 Пачка из 5', 'gen5')],
[Markup.button.callback('⚙️ Настройки', 'settings'), Markup.button.callback('⬅️ Меню', 'main')],
]);
const backKb = (cb = 'main') =>
Markup.inlineKeyboard([[Markup.button.callback('⬅️ Назад', cb)]]);
const modeLabel = (s) => {
if (s.pronounceable) return 'произносимый';
const parts = [];
if (s.upper) parts.push('A-Z');
if (s.lower) parts.push('a-z');
if (s.digits) parts.push('0-9');
if (s.symbols) parts.push('!@#');
return parts.join(' + ') || 'a-z';
};
const passwordText = (pwd, s) => {
const st = strength(pwd);
return [
'🔑 Ваш пароль:',
'',
'`' + pwd + '`',
'',
`${st.bar} ${st.level} · ~${st.bits} бит энтропии`,
`⚙️ Длина ${s.length} · ${modeLabel(s)}`,
'',
'🛡 crypto.randomInt — нажми на пароль, чтобы скопировать.',
].join('\n');
};
const EMAIL_STYLES = ['random (psxaiq)', 'pronounceable (sevemafi)', 'wordnum (nova.turbo86)'];
const EMAIL_STYLE_KEYS = ['random', 'pronounceable', 'wordnum'];
const emailKb = (s) =>
Markup.inlineKeyboard([
[Markup.button.callback('🔁 Ещё раз', 'email'), Markup.button.callback('🎰 Пачка из 5', 'email5')],
[
Markup.button.callback('◀️', 'edom:-1'),
Markup.button.callback('🌐 ' + EMAIL_DOMAINS[s.emailDomain], 'noop'),
Markup.button.callback('▶️', 'edom:+1'),
],
[Markup.button.callback('🎨 Стиль: ' + EMAIL_STYLES[s.emailStyle], 'estyle')],
[
Markup.button.callback((s.emailNumber ? '✅' : '❎') + ' Цифра в адресе', 'enum'),
Markup.button.callback('⬅️ Меню', 'main'),
],
]);
const emailText = (mail, s) =>
'📧 Почтовый адрес:\n\n' +
BT2(mail) +
'\n\n🎨 Стиль: ' + EMAIL_STYLES[s.emailStyle] + '\n' +
'🌐 Домен: ' + EMAIL_DOMAINS[s.emailDomain] + '\n\n' +
'🛡 crypto.randomInt — нажми на адрес, чтобы скопировать.';
const BT2 = (x) => "`" + x + "`" + '\n\n';
const makeEmail = (s) =>
generateEmail({
style: EMAIL_STYLE_KEYS[s.emailStyle] || 'random',
domain: EMAIL_DOMAINS[s.emailDomain],
withNumber: s.emailNumber,
length: Math.max(6, Math.min(s.length, 20)),
});
/* ---------- Shared actions ---------- */
const sendPassword = async (ctx, s) => {
const pwd = generatePassword(s);
addHistory(ctx.from.id, pwd);
return ctx.replyWithMarkdown(passwordText(pwd, s), resultKb());
};
const sendGreeting = (ctx) =>
ctx.reply(
'👋 Привет! Я генератор паролей.\n\n' +
'🔑 Жми «Сгенерировать» — пароль создаётся криптографически стойким генератором Node.js (crypto.randomInt).\n' +
'⚙️ В настройках — длина (4–128), наборы символов, режимы.\n' +
'💬 Быстрая команда: /gen 24 — пароль длиной 24.',
mainMenu()
);
/* ---------- Access restriction: OWNER_ID only ---------- */
const ownerOnly = async (ctx, next) => {
if (!Number.isFinite(OWNER_ID) || ctx.from?.id === OWNER_ID) return next();
if (ctx.callbackQuery) await ctx.answerCbQuery('Доступ запрещен').catch(() => {});
return ctx.reply('⛔ Доступ разрешен только владельцу бота.').catch(() => {});
};
bot.use(async (ctx, next) => {
const t = ctx.callbackQuery ? "callback " + ctx.callbackQuery.data : "message " + (ctx.message?.text || "");
boot("UPDATE from " + ctx.from?.id + ": " + t);
return next();
});
bot.use(ownerOnly);
/* ---------- Handlers ---------- */
bot.start((ctx) => {
state(ctx.from.id);
return sendGreeting(ctx);
});
bot.action('noop', (ctx) => ctx.answerCbQuery());
bot.action('main', (ctx) => {
ctx.answerCbQuery().catch(() => {});
return ctx.editMessageText('Выбери действие:', mainMenu()).catch(() => {});
});
bot.action('settings', (ctx) => {
const s = state(ctx.from.id);
return ctx
.editMessageText('⚙️ Настройки генерации:', settingsKb(s))
.then(() => ctx.answerCbQuery())
.catch(() => {});
});
bot.action(/^len:(-?\d+)$/, (ctx) => {
const delta = Number(ctx.match[1]);
const s = state(ctx.from.id);
s.length = Math.min(128, Math.max(4, s.length + delta));
return ctx
.editMessageText('⚙️ Настройки генерации:', settingsKb(s))
.then(() => ctx.answerCbQuery(`Длина: ${s.length}`))
.catch(() => {});
});
bot.action(/^set:(\w+)$/, (ctx) => {
const key = ctx.match[1];
if (!(key in defaults())) return ctx.answerCbQuery('Неизвестная настройка');
const s = state(ctx.from.id);
if (key === 'pronounceable' || key === 'excludeAmbiguous') {
s[key] = !s[key];
} else {
const others = ['upper', 'lower', 'digits', 'symbols'].filter((k) => k !== key);
if (s[key] && !others.some((k) => s[k])) {
return ctx.answerCbQuery('Нужен хотя бы один набор символов');
}
s[key] = !s[key];
}
return ctx
.editMessageText('⚙️ Настройки генерации:', settingsKb(s))
.then(() => ctx.answerCbQuery())
.catch(() => {});
});
bot.action('gen', async (ctx) => {
const s = state(ctx.from.id);
await ctx.answerCbQuery('Готово').catch(() => {});
return sendPassword(ctx, s).catch(() => {});
});
bot.action('gen5', async (ctx) => {
const s = state(ctx.from.id);
const rows = [];
for (let i = 0; i < 5; i++) {
const pwd = generatePassword(s);
addHistory(ctx.from.id, pwd);
const st = strength(pwd);
rows.push(`${i + 1}. \`${pwd}\` · ${st.bar}`);
}
const text = `🎰 Пачка из 5 (длина ${s.length}, ${modeLabel(s)}):\n\n` + rows.join('\n');
await ctx.answerCbQuery('Готово').catch(() => {});
return ctx
.replyWithMarkdown(text, Markup.inlineKeyboard([
[Markup.button.callback('🔁 Ещё пачку', 'gen5')],
[Markup.button.callback('⚙️ Настройки', 'settings'), Markup.button.callback('⬅️ Меню', 'main')],
]))
.catch(() => {});
});
bot.action('email', (ctx) => {
const s = state(ctx.from.id);
const mail = makeEmail(s);
addHistory(ctx.from.id, '📧 ' + mail);
return ctx
.replyWithMarkdown(emailText(mail, s), emailKb(s))
.then(() => ctx.answerCbQuery())
.catch(() => {});
});
bot.action('email5', (ctx) => {
const s = state(ctx.from.id);
const rows = [];
for (let i = 0; i < 5; i++) {
const mail = makeEmail(s);
addHistory(ctx.from.id, '📧 ' + mail);
rows.push(i + 1 + '. ' + BT + mail + BT);
}
return ctx
.replyWithMarkdown(
'🎰 Пачка из 5 адресов (' + EMAIL_DOMAINS[s.emailDomain] + ', ' + EMAIL_STYLES[s.emailStyle] + '):\n\n' + rows.join('\n'),
Markup.inlineKeyboard([
[Markup.button.callback('🔁 Ещё пачку', 'email5')],
[Markup.button.callback('⬅️ Назад', 'email'), Markup.button.callback('🏠 Меню', 'main')],
])
)
.then(() => ctx.answerCbQuery())
.catch(() => {});
});
bot.action(/^edom:(-?\d+)$/, (ctx) => {
const delta = Number(ctx.match[1]);
const s = state(ctx.from.id);
s.emailDomain = (s.emailDomain + delta + EMAIL_DOMAINS.length) % EMAIL_DOMAINS.length;
return ctx
.editMessageText('📧 Настройки адреса — жми «Ещё раз»:', emailKb(s))
.then(() => ctx.answerCbQuery(EMAIL_DOMAINS[s.emailDomain]))
.catch(() => {});
});
bot.action('estyle', (ctx) => {
const s = state(ctx.from.id);
s.emailStyle = (s.emailStyle + 1) % EMAIL_STYLES.length;
return ctx
.editMessageText('📧 Настройки адреса — жми «Ещё раз»:', emailKb(s))
.then(() => ctx.answerCbQuery(EMAIL_STYLES[s.emailStyle]))
.catch(() => {});
});
bot.action('enum', (ctx) => {
const s = state(ctx.from.id);
s.emailNumber = !s.emailNumber;
return ctx
.editMessageText('📧 Настройки адреса — жми «Ещё раз»:', emailKb(s))
.then(() => ctx.answerCbQuery())
.catch(() => {});
});
bot.action('history', (ctx) => {
const h = history.get(ctx.from.id) || [];
const text = h.length
? '📋 Последние 5 паролей:\n\n' + h.map((p, i) => `${i + 1}. \`${p}\``).join('\n')
: '📋 История пуста — сначала сгенерируй пароль.';
const kb = Markup.inlineKeyboard([
[Markup.button.callback('🧹 Очистить', 'clearhist')],
[Markup.button.callback('⬅️ Назад', 'main')],
]);
return ctx
.replyWithMarkdown(text, kb)
.then(() => ctx.answerCbQuery())
.catch(() => {});
});
bot.action('clearhist', (ctx) => {
history.delete(ctx.from.id);
return ctx
.editMessageText('🧹 История очищена.', backKb())
.then(() => ctx.answerCbQuery())
.catch(() => {});
});
bot.action('about', (ctx) =>
ctx
.editMessageText(
'ℹ️ tg_bot_gen — генератор паролей в Telegram\n\n' +
'🛡 Node.js crypto (crypto.randomInt), Fisher–Yates перемешивание, гарантия минимум одного символа из каждого набора.\n' +
'📏 Длина 4–128 символов.\n' +
'🔤 Наборы: A-Z, a-z, 0-9, символы; опции «без похожих» и «произносимый».\n' +
'📊 Оценка стойкости в битах энтропии.\n' +
'📧 Почтовые адреса: 3 стиля, 14 доменов (gmail, mail.ru, yandex, proton…).\n' +
'🔒 Пароли не отправляются на серверы и не сохраняются на диске.',
backKb()
)
.then(() => ctx.answerCbQuery())
.catch(() => {})
);
// Быстрые команды: /help /menu /gen <length>
bot.help((ctx) => sendGreeting(ctx));
bot.command('menu', (ctx) => sendGreeting(ctx));
bot.command('gen', (ctx) => {
const arg = (ctx.message?.text || '').trim().split(/\s+/)[1];
const s = state(ctx.from.id);
if (arg) {
const n = Number(arg);
if (!Number.isFinite(n) || n < 4 || n > 128) {
return ctx.reply('📏 Длина — число от 4 до 128. Пример: /gen 24');
}
s.length = Math.round(n);
}
return sendPassword(ctx, s).catch(() => {});
});
bot.command('email', (ctx) => {
const s = state(ctx.from.id);
const mail = makeEmail(s);
addHistory(ctx.from.id, '📧 ' + mail);
return ctx.replyWithMarkdown(emailText(mail, s), emailKb(s)).catch(() => {});
});
bot.on('text', (ctx) => ctx.reply('Используй /start 🙂', mainMenu()));
bot.catch((err) => {
const msg = err?.response?.description || err?.message || String(err);
boot('HANDLER ERROR: ' + msg);
console.error('Bot error:', msg);
});
/* ---------- Menu of bot commands ---------- */
const COMMANDS = [
{ command: 'start', description: 'Главное меню' },
{ command: 'gen', description: 'Сгенерировать пароль (/gen 24)' },
{ command: 'email', description: 'Сгенерировать почтовый адрес' },
{ command: 'help', description: 'Справка' },
];
/* ---------- Start (long polling) ---------- */
boot('booting...');
bot.telegram.getMe().then((me) => boot('getMe ok @' + me.username)).catch((e) => boot('getMe FAIL ' + (e?.response?.description || e.message)));
bot.telegram
.setMyCommands(COMMANDS)
.then(() => boot('setMyCommands OK'))
.catch((e) => boot('setMyCommands FAIL ' + (e?.response?.description || e.message)));
boot('launching...');
bot.launch({ dropPendingUpdates: true }).finally(() => boot('polling stopped'));
console.log('Bot @' + TOKEN.slice(0,4) + ' launched; waiting for updates');
const shutdown = () => {
boot('stopping');
try { bot.stop('shutting down'); } catch {}
process.exit(0);
};
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
process.on('unhandledRejection', (e) => boot('UNHANDLED: ' + (e?.message || e)));