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:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }
|
||||
Reference in new issue
Block a user