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-pro",
"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"
}
}
+264
View File
@@ -0,0 +1,264 @@
/**
* Рабочий бот: исполнители (инвайт, принятие/отказ от заказов PRO-5, кабинет, переписка с оператором)
* и операторы админки (уведомления о заявках и сообщениях, ответ клиенту прямо из Telegram).
*/
import { getDb } from '@barev/db';
import { createT, formatDateTime, type Locale } from '@barev/i18n';
import { acceptPartnerInvite, addProviderMessage, checkCard, DomainError, linkByInvite, proIdentity, redeemOffer, replyTo, respondToOffer, type Ctx, type Peer } 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.PRO_BOT_TOKEN ?? '';
// без токена бот не запускается, но процесс (общий с API в продакшене) продолжает работать
if (!token) console.log('PRO_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.PRO_MINIAPP_URL ?? '';
const bot = new Bot(token || 'disabled:token');
async function identity(c: Context) {
return c.from ? proIdentity(db, BigInt(c.from.id)) : null;
}
const localeOf = (id: Awaited<ReturnType<typeof identity>>): Locale => (id?.user.locale as Locale) ?? 'ru';
function cabinetKeyboard(locale: Locale) {
const kb = new InlineKeyboard();
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL);
return kb;
}
async function greet(c: Context) {
const id = await identity(c);
const locale = localeOf(id);
const t = createT(locale);
if (!id || (!id.provider && id.companies.length === 0 && id.partners.length === 0)) {
await c.reply(t('bot.pro.notLinked'));
return;
}
const roles = [
id.provider ? t('bot.pro.youAreProvider', { name: id.provider.name, role: t(`roles.${id.provider.role}`) }) : null,
...id.companies.map((co) => t('bot.pro.youAreDispatcher', { company: co.name })),
...id.partners.map((p) => t(p.role === 'OWNER' ? 'bot.partner.youAreOwner' : 'bot.partner.youAreStaff', { partner: p.name })),
].filter(Boolean);
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale) });
}
/** Оператор админки, привязавший этот Telegram */
const adminOf = (c: Context) => (c.from ? db.adminUser.findFirst({ where: { telegramId: BigInt(c.from.id), active: true } }) : null);
const adminT = createT('ru');
/** Оператор нажал «Ответить» — следующий его текст уйдёт этому клиенту или исполнителю */
const replyingTo = new Map<number, Peer & { name: string }>();
const LEVEL_NAME: Record<string, string> = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
type Check = Awaited<ReturnType<typeof checkCard>>;
/** Результат проверки карты: уровень клиента и что ему положено; лимитные предложения — кнопками «Списать» */
function checkReply(check: Check, locale: Locale) {
const t = createT(locale);
const name = (title: Record<string, string>) => title[locale] || title.ru || '';
const lines = [
`✅ <b>${t('bot.partner.valid', { name: check.client.firstName, level: LEVEL_NAME[check.level] ?? check.level })}</b>`,
check.endsAt ? t('bot.partner.until', { date: formatDateTime(locale, check.endsAt, { day: 'numeric', month: 'long' }) }) : '',
'',
];
const kb = new InlineKeyboard();
for (const o of check.offers) {
if (!o.eligible) {
lines.push(`▫️ ${name(o.title)} — ${t('bot.partner.needLevel', { level: LEVEL_NAME[o.minLevel ?? 'BAREV'] })}`);
} else if (o.remaining === null) {
lines.push(`🎁 ${name(o.title)}`);
} else if (o.remaining > 0) {
lines.push(`🎁 ${name(o.title)} — ${t(`bot.partner.left_${o.limit}`, { count: o.remaining })}`);
kb.text(`${t('bot.partner.redeem')}: ${name(o.title)}`.slice(0, 60), `rd:${check.checkId}:${o.id}`).row();
} else {
lines.push(`✖️ ${name(o.title)} — ${t(`bot.partner.used_${o.limit}`)}`);
}
}
if (!check.offers.length) lines.push(t('bot.partner.noOffers'));
return { text: lines.filter((l, i) => l || i === 2).join('\n'), kb };
}
bot.command('start', async (c) => {
const code = c.match?.trim();
// клубная карта клиента: QR открыли камерой — проверяет собственник или сотрудник партнёра
if (code?.startsWith('cd-')) {
const id = await identity(c);
const locale = localeOf(id);
try {
const check = await checkCard(ctx, { telegramId: BigInt(c.from!.id), token: code });
const { text, kb } = checkReply(check, locale);
await c.reply(text, { parse_mode: 'HTML', reply_markup: kb });
} catch (e) {
if (!(e instanceof DomainError)) throw e;
await c.reply(createT(locale)(e.code === 'FORBIDDEN' ? 'bot.partner.notPartner' : `errors.${e.code}`));
}
return;
}
// приглашение партнёра: po- — собственник (из админки), pi- — сотрудник (QR от собственника)
if (code?.startsWith('po-') || code?.startsWith('pi-')) {
const locale = localeOf(await identity(c));
try {
const res = await acceptPartnerInvite(ctx, c.from!, code);
await c.reply(createT(locale)(res.role === 'OWNER' ? 'bot.partner.linkedOwner' : 'bot.partner.linkedStaff', { partner: res.partnerName }), { reply_markup: cabinetKeyboard(locale) });
} catch (e) {
if (!(e instanceof DomainError)) throw e;
await c.reply(createT(locale)(`errors.${e.code}`));
}
return;
}
// вход в новую админку: страница показала ссылку lg-<код>, подтверждаем по Telegram сотрудника
if (code?.startsWith('lg-')) {
const req = await db.adminLoginRequest.findUnique({ where: { code: code.slice(3) } });
if (!req || req.approvedAt || req.expiresAt < new Date()) {
await c.reply(adminT('bot.admin.loginExpired'));
return;
}
const staff = await db.adminUser.findUnique({ where: { telegramId: BigInt(c.from!.id) } });
if (!staff || !staff.active || staff.roles.length === 0) {
await c.reply(adminT('bot.admin.notAdmin'));
return;
}
await db.adminLoginRequest.update({ where: { id: req.id }, data: { approvedAt: new Date(), adminUserId: staff.id } });
await c.reply(adminT('bot.admin.loginOk'));
return;
}
// привязка оператора по ссылке из админки
if (code?.startsWith('adm-')) {
const admin = await db.adminUser.findUnique({ where: { tgLinkToken: code.slice(4) } });
if (!admin) {
await c.reply(adminT('bot.admin.linkInvalid'));
return;
}
await db.adminUser.updateMany({ where: { telegramId: BigInt(c.from!.id) }, data: { telegramId: null } });
await db.adminUser.update({ where: { id: admin.id }, data: { telegramId: BigInt(c.from!.id), tgLinkToken: null, tgNotify: true } });
await c.reply(adminT('bot.admin.linked', { name: admin.name }));
return;
}
if (code) {
try {
const res = await linkByInvite(ctx, c.from!, code);
const id = await identity(c);
await c.reply(createT(localeOf(id))(res.kind === 'provider' ? 'bot.pro.linkedProvider' : 'bot.pro.linkedDispatcher', { name: res.name }));
} catch (e) {
if (e instanceof DomainError) {
await c.reply(createT(localeOf(await identity(c)))(`errors.${e.code}`));
return;
}
throw e;
}
}
await greet(c);
});
bot.command('app', greet);
/** PRO-5: принять / отклонить из уведомления */
bot.callbackQuery(/^offer:(accept|decline):(\w+)$/, async (c) => {
const [, decision, offerId] = c.match;
const id = await identity(c);
const t = createT(localeOf(id));
if (!id?.provider) {
await c.answerCallbackQuery({ text: t('bot.pro.notLinked'), show_alert: true });
return;
}
try {
await respondToOffer(ctx, { offerId: offerId!, providerId: id.provider.id, accept: decision === 'accept' });
await c.answerCallbackQuery({ text: t(decision === 'accept' ? 'pro.offer.accepted' : 'pro.offer.declined') });
await c.editMessageReplyMarkup({ reply_markup: cabinetKeyboard(localeOf(id)) });
await c.reply(t(decision === 'accept' ? 'pro.offer.accepted' : 'pro.offer.declined'));
} catch (e) {
if (e instanceof DomainError) {
await c.answerCallbackQuery({ text: t(`errors.${e.code}`), show_alert: true });
await c.editMessageReplyMarkup().catch(() => {});
return;
}
throw e;
}
});
/** Списать лимитное предложение после проверки */
bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
const locale = localeOf(await identity(c));
const t = createT(locale);
try {
const offer = await redeemOffer(ctx, { telegramId: BigInt(c.from.id), checkId: c.match[1]!, offerId: c.match[2]! });
const title = offer.title[locale] || offer.title.ru || '';
await c.answerCallbackQuery({ text: t('bot.partner.redeemed') });
await c.reply(`✅ ${t('bot.partner.redeemedText', { offer: title })}${offer.remaining !== null ? ` · ${t(`bot.partner.left_${offer.limit}`, { count: offer.remaining })}` : ''}`);
} catch (e) {
if (!(e instanceof DomainError)) throw e;
await c.answerCallbackQuery({ text: t(`errors.${e.code}`), show_alert: true });
}
});
/** «Ответить» под уведомлением о сообщении */
bot.callbackQuery(/^ar:(c|p):(\w+)$/, async (c) => {
const admin = await adminOf(c);
if (!admin) {
await c.answerCallbackQuery({ text: adminT('bot.admin.notAdmin'), show_alert: true });
return;
}
const [, kind, id] = c.match;
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
const name =
kind === 'c'
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
replyingTo.set(c.chat!.id, { ...peer, name });
await c.answerCallbackQuery();
await c.reply(adminT('bot.admin.replyPrompt', { name }), { reply_markup: { force_reply: true, input_field_placeholder: adminT('bot.admin.replyPlaceholder') } });
});
bot.command('cancel', async (c) => {
replyingTo.delete(c.chat.id);
await c.reply(adminT('bot.admin.replyCancelled'));
});
bot.on('message:text', async (c) => {
// оператор отвечает клиенту или исполнителю
const target = replyingTo.get(c.chat.id);
const admin = await adminOf(c);
if (target && admin) {
try {
await replyTo(ctx, { ...(target.clientId ? { clientId: target.clientId } : { providerId: target.providerId! }), adminUserId: admin.id, text: c.message.text });
replyingTo.delete(c.chat.id);
await c.reply(adminT('bot.admin.replySent', { name: target.name }));
} catch (e) {
if (e instanceof DomainError) await c.reply(adminT(`errors.${e.code}`));
else throw e;
}
return;
}
// исполнитель пишет оператору — сообщение появится в админке
const id = await identity(c);
if (id?.provider) {
const first = !(await db.conciergeMessage.count({ where: { providerId: id.provider.id } }));
await addProviderMessage(ctx, id.provider.id, c.message.text);
if (first) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
return;
}
if (admin) {
await c.reply(adminT('bot.admin.hello', { name: admin.name }));
return;
}
await greet(c);
});
bot.on('message', greet);
bot.catch((err) => console.error('[bot-pro]', err.error));
async function main() {
if (!token) return;
if (APP_URL.startsWith('https://')) {
await bot.api.setChatMenuButton({ menu_button: { type: 'web_app', text: 'Barev Pro', web_app: { url: APP_URL } } }).catch(() => {});
}
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"] }