Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Клиентский бот: точка входа и онбординг (ONB-1…6).
|
||||
* /start <payload> → язык → согласие на ПДн → подарок агентства → «Открыть приложение».
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatDateTime, isLocale, LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { decodeStartPayload } from '@barev/core';
|
||||
import { addClientMessage, applyAgencyCode, DomainError, getClientByTelegramId, giveConsent, linkWebRequest, setLocale, startClient, type Ctx } from '@barev/services';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier } from '@barev/services/queue';
|
||||
import { Bot, InlineKeyboard, type Context } from 'grammy';
|
||||
|
||||
loadRootEnv();
|
||||
|
||||
const token = process.env.CLIENT_BOT_TOKEN ?? '';
|
||||
// без токена бот не запускается, но процесс (общий с API в продакшене) продолжает работать
|
||||
if (!token) console.log('CLIENT_BOT_TOKEN не задан — клиентский бот не запущен. Создайте бота в BotFather и добавьте токен в .env.');
|
||||
|
||||
const db = getDb();
|
||||
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
|
||||
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
|
||||
const APP_URL = process.env.CLIENT_MINIAPP_URL ?? '';
|
||||
const LANDING = process.env.LANDING_URL ?? '';
|
||||
const bot = new Bot(token || 'disabled:token');
|
||||
|
||||
/** Ожидаем ввод кода агентства от этих чатов */
|
||||
const awaitingCode = new Set<number>();
|
||||
|
||||
async function localeOf(from: Context['from']): Promise<Locale> {
|
||||
if (!from) return 'ru';
|
||||
const c = await getClientByTelegramId(db, BigInt(from.id));
|
||||
return (c?.user.locale as Locale) ?? 'ru';
|
||||
}
|
||||
|
||||
function languageKeyboard() {
|
||||
const kb = new InlineKeyboard();
|
||||
LOCALES.forEach((l, i) => {
|
||||
kb.text(LOCALE_NAMES[l], `lang:${l}`);
|
||||
if (i % 2 === 1) kb.row();
|
||||
});
|
||||
return kb;
|
||||
}
|
||||
|
||||
function mainKeyboard(locale: Locale) {
|
||||
const t = createT(locale);
|
||||
const kb = new InlineKeyboard();
|
||||
// Telegram принимает Mini App только по https; локально — обычная ссылка не нужна
|
||||
if (APP_URL.startsWith('https://')) kb.webApp(t('bot.client.openApp'), APP_URL).row();
|
||||
kb.text(t('bot.client.enterCode'), 'code').row();
|
||||
kb.text(t('bot.client.changeLanguage'), 'language');
|
||||
return kb;
|
||||
}
|
||||
|
||||
async function askConsent(c: Context, locale: Locale) {
|
||||
const t = createT(locale);
|
||||
const kb = new InlineKeyboard().text(t('bot.client.consentAccept'), 'consent');
|
||||
await c.reply(t('bot.client.consentText', { url: `${LANDING}/${locale}/privacy` }), { reply_markup: kb, parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
|
||||
}
|
||||
|
||||
async function showMain(c: Context, locale: Locale, gift?: { endsAt: Date } | null) {
|
||||
const t = createT(locale);
|
||||
if (gift) await c.reply(t('bot.client.giftActivated', { date: formatDateTime(locale, gift.endsAt, { dateStyle: 'long' }) }));
|
||||
await c.reply(t('bot.client.welcome'), { reply_markup: mainKeyboard(locale) });
|
||||
}
|
||||
|
||||
async function continueOnboarding(c: Context, locale: Locale) {
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
return showMain(c, locale);
|
||||
}
|
||||
|
||||
bot.command('start', async (c) => {
|
||||
const res = await startClient(ctx, c.from!, c.match || undefined);
|
||||
// пришёл с сайта после заявки: язык и согласие уже есть — сразу говорим, что с заявкой
|
||||
const token = decodeStartPayload(c.match).token;
|
||||
const link = token ? await linkWebRequest(ctx, token, res.clientId) : null;
|
||||
if (link && link.kind !== 'none') {
|
||||
const t = createT(res.locale);
|
||||
if (link.kind === 'ordered') {
|
||||
const date = formatDateTime(res.locale, link.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
await c.reply(t('bot.client.webRequestOrdered', { request: link.requestNumber, order: link.orderNumber, date }));
|
||||
} else {
|
||||
await c.reply(t('bot.client.webRequestLinked', { request: link.requestNumber }));
|
||||
}
|
||||
return showMain(c, res.locale, link.gift ?? res.gift);
|
||||
}
|
||||
if (res.needsLocale) {
|
||||
await c.reply(createT('ru')('bot.client.chooseLanguage'), { reply_markup: languageKeyboard() });
|
||||
return;
|
||||
}
|
||||
if (res.agencyName && res.isNew) await c.reply(createT(res.locale)('bot.client.fromAgency', { agency: res.agencyName }));
|
||||
if (res.needsConsent) return askConsent(c, res.locale);
|
||||
return showMain(c, res.locale, res.gift);
|
||||
});
|
||||
|
||||
bot.command('language', (c) => c.reply(createT('ru')('bot.client.chooseLanguage'), { reply_markup: languageKeyboard() }));
|
||||
bot.callbackQuery('language', async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
await c.reply(createT(await localeOf(c.from))('bot.client.chooseLanguage'), { reply_markup: languageKeyboard() });
|
||||
});
|
||||
|
||||
bot.callbackQuery(/^lang:(\w+)$/, async (c) => {
|
||||
const locale = c.match[1];
|
||||
await c.answerCallbackQuery();
|
||||
if (!isLocale(locale)) return;
|
||||
// клиента могло не быть, если кнопка нажата до /start
|
||||
await startClient(ctx, c.from);
|
||||
await setLocale(db, BigInt(c.from.id), locale);
|
||||
await c.editMessageText(createT(locale)('bot.client.languageSet', { language: LOCALE_NAMES[locale] }));
|
||||
await continueOnboarding(c, locale);
|
||||
});
|
||||
|
||||
bot.callbackQuery('consent', async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from.id));
|
||||
if (!client) return;
|
||||
const locale = client.user.locale as Locale;
|
||||
const gift = client.consentAt ? null : await giveConsent(ctx, client.id);
|
||||
await c.editMessageReplyMarkup();
|
||||
await showMain(c, locale, gift);
|
||||
});
|
||||
|
||||
async function promptCode(c: Context) {
|
||||
const locale = await localeOf(c.from);
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
awaitingCode.add(c.chat!.id);
|
||||
await c.reply(createT(locale)('bot.client.codePrompt'));
|
||||
}
|
||||
bot.command('code', promptCode);
|
||||
bot.callbackQuery('code', async (c) => {
|
||||
await c.answerCallbackQuery();
|
||||
await promptCode(c);
|
||||
});
|
||||
|
||||
bot.command('app', async (c) => {
|
||||
const locale = await localeOf(c.from);
|
||||
await c.reply(createT(locale)('bot.client.welcome'), { reply_markup: mainKeyboard(locale) });
|
||||
});
|
||||
|
||||
bot.command('help', async (c) => c.reply(createT(await localeOf(c.from))('bot.client.help')));
|
||||
|
||||
/** ONB-6: ручной ввод кода агентства; любой другой текст — консьержу */
|
||||
bot.on('message:text', async (c) => {
|
||||
const locale = await localeOf(c.from);
|
||||
const t = createT(locale);
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from.id));
|
||||
if (!awaitingCode.has(c.chat.id)) {
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
// чат с консьержем: сообщение видит оператор в админке, ответ придёт сюда же
|
||||
const first = !(await db.conciergeMessage.count({ where: { clientId: client.id } }));
|
||||
await addClientMessage(ctx, client.id, c.message.text);
|
||||
if (first) await c.reply(t('bot.client.conciergeReceived'));
|
||||
return;
|
||||
}
|
||||
if (!client) return;
|
||||
try {
|
||||
const res = await applyAgencyCode(ctx, client.id, c.message.text);
|
||||
awaitingCode.delete(c.chat.id);
|
||||
await c.reply(t('bot.client.codeAccepted', { agency: res.agencyName }));
|
||||
await showMain(c, locale, res.gift);
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) await c.reply(t(`errors.${e.code}`));
|
||||
else throw e;
|
||||
}
|
||||
});
|
||||
|
||||
bot.catch((err) => console.error('[bot-client]', err.error));
|
||||
|
||||
async function main() {
|
||||
if (!token) return;
|
||||
// ONB-5: кнопка меню открывает Mini App. Telegram не локализует её текст — используем название бренда
|
||||
if (APP_URL.startsWith('https://')) {
|
||||
await bot.api.setChatMenuButton({ menu_button: { type: 'web_app', text: 'Barev Club', web_app: { url: APP_URL } } }).catch(() => {});
|
||||
}
|
||||
for (const l of LOCALES) {
|
||||
const t = createT(l);
|
||||
await bot.api.setMyCommands(
|
||||
[
|
||||
{ command: 'start', description: t('bot.client.cmdStart') },
|
||||
{ command: 'app', description: t('bot.client.cmdApp') },
|
||||
{ command: 'code', description: t('bot.client.cmdCode') },
|
||||
{ command: 'language', description: t('bot.client.cmdLanguage') },
|
||||
{ command: 'help', description: t('bot.client.cmdHelp') },
|
||||
],
|
||||
l === 'ru' ? {} : { language_code: l },
|
||||
);
|
||||
}
|
||||
await bot.start({ onStart: (me) => console.log(`Клиентский бот @${me.username} запущен (long polling)`) });
|
||||
}
|
||||
|
||||
void main();
|
||||
Reference in new issue
Block a user