Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
{
"name": "@barev/bot-client",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch --clear-screen=false src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@barev/core": "workspace:*",
"@barev/db": "workspace:*",
"@barev/i18n": "workspace:*",
"@barev/services": "workspace:*",
"dotenv": "^17.0.0",
"grammy": "^1.46.0"
},
"devDependencies": {
"tsx": "^4.20.0"
}
}
+192
View File
@@ -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();
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }