Lead Radar: исходный код

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuriandClaude Opus 5.5 committed 2026-10-25 23:11:04 +04:00
commit 9749da5ae7
201 files changed
+97685

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
import { loadBotStart, startText } from './start.ts';
import { accessState, canWrite, type Role } from '@lr/shared';
import { and, eq } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { botLangs, clients, companies, users } from '../db/schema.ts';
import { intakeLink, seekerChat } from '../intake/service.ts';
import { intakeTexts } from '../intake/texts.ts';
import { ensureClientPage, offerMatching } from '../showcase/service.ts';
import { AppError } from '../lib/errors.ts';
import type { Config } from '../lib/config.ts';
import { takeLead } from '../leads/service.ts';
import { receiveSupportMessage } from '../support/service.ts';
import type { Keyboard, TelegramApi, TgFrom, TgUpdate } from './client.ts';
import { esc, texts } from './texts.ts';
export interface BotDeps {
db: Db;
tg: TelegramApi;
config: Config;
/** Клиентский бот: через него соискателю уходит ссылка на подборку. */
clientTg?: TelegramApi | null;
}
/** Вопрос о языке после приветствия: понятен на всех трёх языках, ответ — кнопкой. */
const LANG_QUESTION = 'Выберите язык · Ընտրեք լեզուն · Choose a language';
const LANG_KEYBOARD: Keyboard = [[
{ text: 'Русский', callback: 'lang:ru' },
{ text: 'Հայերեն', callback: 'lang:hy' },
{ text: 'English', callback: 'lang:en' },
]];
const appKeyboard = (config: Config, lang: string): Keyboard | undefined =>
config.appUrl.startsWith('https://') ? [[{ text: texts(lang).openApp, webApp: config.appUrl }]] : undefined;
/** Обрабатывает одно обновление от Telegram: команды и нажатие «Беру». */
export async function handleUpdate({ db, tg, config, clientTg }: BotDeps, update: TgUpdate): Promise<void> {
const from: TgFrom | undefined = update.message?.from ?? update.callback_query?.from;
if (!from) return;
const [user] = await db.select().from(users).where(eq(users.telegramId, from.id));
// Незарегистрированному бот отвечает на языке, который тот выбрал кнопкой; иначе — на языке его Telegram.
const [chosen] = user ? [] : await db.select().from(botLangs).where(eq(botLangs.telegramId, from.id));
const lang = user?.lang ?? chosen?.lang ?? (from.language_code === 'hy' ? 'hy' : from.language_code === 'en' ? 'en' : 'ru');
const t = texts(lang);
if (update.callback_query) {
const cb = update.callback_query;
// Выбор языка после приветствия: запоминается и сразу применяется.
const picked = /^lang:(ru|hy|en)$/.exec(cb.data ?? '')?.[1];
if (picked) {
if (user) await db.update(users).set({ lang: picked }).where(eq(users.id, user.id));
else await db.insert(botLangs).values({ telegramId: from.id, lang: picked }).onConflictDoUpdate({ target: botLangs.telegramId, set: { lang: picked } });
await tg.answerCallback(cb.id);
const chat = cb.message?.chat.id ?? from.id;
const own = user && !user.removedAt ? null : startText(await loadBotStart(db), picked, false);
const reply = user && !user.removedAt ? texts(picked).hello(esc(user.name)) : own ? esc(own) : texts(picked).start;
return void (await tg.sendMessage(chat, reply, appKeyboard(config, picked)));
}
// Ответ на анкету клиента: показать ему подходящие объекты или нет.
const match = /^(match|nomatch):(\d+)$/.exec(cb.data ?? '');
if (match) {
if (!user || user.removedAt || !user.companyId) return tg.answerCallback(cb.id, t.unknownUser, true);
await tg.answerCallback(cb.id);
const chat = cb.message?.chat.id ?? from.id;
if (match[1] === 'nomatch') return void (await tg.sendMessage(chat, t.intakeOk));
const [company] = await db.select().from(companies).where(eq(companies.id, user.companyId));
if (!company || !canWrite(accessState(company))) return void (await tg.sendMessage(chat, t.readOnly));
const actor = { user, role: user.role as Role, company };
const [client] = await db.select().from(clients).where(and(eq(clients.id, Number(match[2])), eq(clients.companyId, company.id)));
if (!client || (user.role !== 'manager' && client.assigneeId !== user.id)) return void (await tg.sendMessage(chat, t.unavailable));
const { count, url } = await db.transaction(async (tx) => ({
count: await offerMatching(tx, actor, client),
url: await ensureClientPage(tx, config, company, client, user.id),
}));
if (!count) return void (await tg.sendMessage(chat, t.matchNone));
// Ссылку клиенту отправляет клиентский бот — в тот чат, где он заполнял анкету.
const seeker = clientTg ? await seekerChat(db, client.id) : null;
let delivered = false;
if (seeker && clientTg) {
delivered = await clientTg
.sendMessage(seeker.chatId, esc(intakeTexts(seeker.lang).selection(count, user.name, `${url}?lang=${seeker.lang}`)))
.then(() => true, () => false);
}
return void (await tg.sendMessage(chat, esc(t.matchSent(count, url, delivered))));
}
const leadId = Number(cb.data?.match(/^take:(\d+)$/)?.[1]);
if (!Number.isInteger(leadId)) return tg.answerCallback(cb.id);
if (!user || user.removedAt || !user.companyId) return tg.answerCallback(cb.id, t.unknownUser, true);
const [company] = await db.select().from(companies).where(eq(companies.id, user.companyId));
if (!company || !canWrite(accessState(company))) return tg.answerCallback(cb.id, t.readOnly, true);
try {
await takeLead(db, { user, role: user.role as Role, company }, leadId);
return await tg.answerCallback(cb.id, t.taken);
} catch (e) {
if (e instanceof AppError) {
return tg.answerCallback(cb.id, e.code === 'ALREADY_TAKEN' ? t.alreadyTaken : t.unavailable, true);
}
throw e;
}
}
const msg = update.message!;
const chatId = msg.chat.id;
// Бот работает в личных чатах: в группах он молчит.
if (chatId !== from.id) return;
const text = msg.text?.trim() ?? '';
// Всё, что не команда, — обращение в поддержку: текст, фото, файл.
const file = msg.photo?.length
? { id: msg.photo.at(-1)!.file_id, kind: 'photo' as const }
: msg.document
? { id: msg.document.file_id, kind: 'file' as const, name: msg.document.file_name }
: undefined;
const body = text || msg.caption?.trim() || '';
if (!body.startsWith('/') && (body || file)) {
await receiveSupportMessage(db, {
chatId,
user: user && !user.removedAt ? user : null,
tgName: [from.first_name, from.last_name].filter(Boolean).join(' ') || null,
tgUsername: from.username ?? null,
lang,
text: body || null,
file,
});
return;
}
const [command, arg] = text.split(/\s+/, 2);
if (command === '/start' && arg) {
// Привязка Telegram к аккаунту по одноразовому коду из настроек кабинета.
const [target] = await db.select().from(users).where(eq(users.telegramLinkToken, arg));
if (!target) return void (await tg.sendMessage(chatId, t.linkBad));
if (user && user.id !== target.id) return void (await tg.sendMessage(chatId, t.linkUsed));
await db
.update(users)
.set({ telegramId: from.id, telegramUsername: from.username ?? null, telegramLinkToken: null })
.where(eq(users.id, target.id));
return void (await tg.sendMessage(chatId, texts(target.lang).linked(esc(target.name)), appKeyboard(config, target.lang)));
}
if (!user || user.removedAt) {
// Приветствие для тех, кто ещё не в кабинете: текст и файлы задаёт суперадмин.
const custom = await loadBotStart(db);
const own = startText(custom, lang);
await tg.sendMessage(chatId, own ? esc(own) : t.start, appKeyboard(config, lang));
for (const f of custom.files) await tg.sendFileById(chatId, f.fileId, f.kind).catch((e) => console.error('[start-file]', e));
await tg.sendMessage(chatId, LANG_QUESTION, LANG_KEYBOARD);
return;
}
if (command === '/anketa') {
const url = await intakeLink(db, config, user);
return void (await tg.sendMessage(chatId, url ? esc(t.anketa(url)) : t.anketaOff));
}
if (command === '/pause' || command === '/resume') {
await db.update(users).set({ notificationsPaused: command === '/pause' }).where(eq(users.id, user.id));
return void (await tg.sendMessage(chatId, command === '/pause' ? t.paused : t.resumed));
}
await tg.sendMessage(chatId, t.hello(esc(user.name)), appKeyboard(config, lang));
}
/** Длинный опрос Bot API. Возвращает функцию остановки. */
export function startPolling(
deps: BotDeps | { tg: TelegramApi },
onError: (e: unknown) => void = console.error,
/** Обработчик обновлений: у основного и клиентского бота он свой. */
handle: (update: TgUpdate) => Promise<void> = (update) => handleUpdate(deps as BotDeps, update),
): () => void {
let stopped = false;
void (async () => {
let offset = 0;
while (!stopped) {
try {
const updates = await deps.tg.getUpdates(offset, 25);
for (const update of updates) {
offset = update.update_id + 1;
await handle(update).catch(onError);
}
} catch (e) {
if (stopped) break;
onError(e);
await new Promise((r) => setTimeout(r, 3000));
}
}
})();
return () => {
stopped = true;
};
}
+153
View File
@@ -0,0 +1,153 @@
/** Кнопка под сообщением: callback — нажатие обрабатывает бот; url — ссылка; webApp — открыть Mini App. */
export type Button = { text: string } & ({ callback: string } | { url: string } | { webApp: string });
export type Keyboard = Button[][];
export interface SentMessage {
chatId: number;
messageId: number;
}
export class TelegramError extends Error {
constructor(
readonly code: number,
description: string,
/** Сколько секунд Telegram просит подождать (ошибка 429). */
readonly retryAfter?: number,
) {
super(description);
}
/** Повторять бессмысленно: бот заблокирован, чат не найден, неверный запрос. */
get permanent() {
return this.code === 400 || this.code === 403;
}
}
export interface TgUpdate {
update_id: number;
message?: {
chat: { id: number };
from?: TgFrom;
text?: string;
/** Подпись к фото или файлу. */
caption?: string;
/** Фото в нескольких размерах, последнее — самое большое. */
photo?: { file_id: string }[];
document?: { file_id: string; file_name?: string };
/** Контакт, отправленный кнопкой «Поделиться контактом». */
contact?: { phone_number: string; first_name?: string };
};
callback_query?: { id: string; from: TgFrom; data?: string; message?: { chat: { id: number }; message_id: number } };
}
export interface TgFrom {
id: number;
first_name?: string;
last_name?: string;
username?: string;
language_code?: string;
}
/** То, что сервису нужно от Bot API. В тестах подменяется записывающей заглушкой. */
export interface TelegramApi {
sendMessage(chatId: number, text: string, keyboard?: Keyboard): Promise<SentMessage>;
sendPhoto(chatId: number, photoUrl: string, caption: string, keyboard?: Keyboard): Promise<SentMessage>;
/** Файл, который уже лежит у Telegram, — по его идентификатору. */
sendFileById(chatId: number, fileId: string, kind: 'photo' | 'file'): Promise<void>;
/** Меняет текст (или подпись к фото) и кнопки уже отправленного сообщения. */
editMessage(msg: SentMessage, hasPhoto: boolean, text: string, keyboard?: Keyboard): Promise<void>;
answerCallback(callbackId: string, text?: string, alert?: boolean): Promise<void>;
getUpdates(offset: number, timeoutSec: number): Promise<TgUpdate[]>;
}
const markup = (keyboard?: Keyboard) => ({
inline_keyboard: (keyboard ?? []).map((row) =>
row.map((b) =>
'callback' in b
? { text: b.text, callback_data: b.callback }
: 'url' in b
? { text: b.text, url: b.url }
: { text: b.text, web_app: { url: b.webApp } },
),
),
});
export class HttpTelegram implements TelegramApi {
constructor(
private readonly token: string,
private readonly fetchFn: typeof fetch = fetch,
) {}
private async call<T>(method: string, body: Record<string, unknown>, timeoutMs = 20_000): Promise<T> {
const res = await this.fetchFn(`https://api.telegram.org/bot${this.token}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const data = (await res.json()) as {
ok: boolean;
result?: T;
error_code?: number;
description?: string;
parameters?: { retry_after?: number };
};
if (!data.ok) {
throw new TelegramError(data.error_code ?? res.status, data.description ?? method, data.parameters?.retry_after);
}
return data.result as T;
}
async sendMessage(chatId: number, text: string, keyboard?: Keyboard) {
const m = await this.call<{ message_id: number }>('sendMessage', {
chat_id: chatId,
text,
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
reply_markup: markup(keyboard),
});
return { chatId, messageId: m.message_id };
}
async sendPhoto(chatId: number, photoUrl: string, caption: string, keyboard?: Keyboard) {
const m = await this.call<{ message_id: number }>('sendPhoto', {
chat_id: chatId,
photo: photoUrl,
caption,
parse_mode: 'HTML',
reply_markup: markup(keyboard),
});
return { chatId, messageId: m.message_id };
}
async sendFileById(chatId: number, fileId: string, kind: 'photo' | 'file') {
await this.call(kind === 'photo' ? 'sendPhoto' : 'sendDocument', { chat_id: chatId, [kind === 'photo' ? 'photo' : 'document']: fileId });
}
async editMessage(msg: SentMessage, hasPhoto: boolean, text: string, keyboard?: Keyboard) {
const base = { chat_id: msg.chatId, message_id: msg.messageId, parse_mode: 'HTML', reply_markup: markup(keyboard) };
try {
if (hasPhoto) await this.call('editMessageCaption', { ...base, caption: text });
else await this.call('editMessageText', { ...base, text, link_preview_options: { is_disabled: true } });
} catch (e) {
// Текст не изменился — для нас это успех.
if (e instanceof TelegramError && /not modified/i.test(e.message)) return;
throw e;
}
}
async answerCallback(callbackId: string, text?: string, alert = false) {
await this.call('answerCallbackQuery', { callback_query_id: callbackId, text, show_alert: alert });
}
/** Кнопка меню бота открывает Mini App. Вызывается при старте, когда кабинет доступен по https. */
async setMenuButton(text: string, url: string) {
await this.call('setChatMenuButton', { menu_button: { type: 'web_app', text, web_app: { url } } });
}
getUpdates(offset: number, timeoutSec: number) {
return this.call<TgUpdate[]>(
'getUpdates',
{ offset, timeout: timeoutSec, allowed_updates: ['message', 'callback_query'] },
(timeoutSec + 10) * 1000,
);
}
}
+207
View File
@@ -0,0 +1,207 @@
import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import type { Db, Tx } from '../db/client.ts';
import { leadNotifications, leads, outbox, pipelineStatuses, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts';
import { TelegramError, type Keyboard, type TelegramApi } from './client.ts';
import { esc, leadMessage, texts } from './texts.ts';
// Очередь сообщений Telegram (ТЗ: «очередь для уведомлений», лимиты Bot API, повтор при ошибке).
// Сообщение сначала записывается в таблицу outbox в той же транзакции, что и изменение лида,
// поэтому не теряется при сбое; воркер отправляет его отдельно.
type User = typeof users.$inferSelect;
/** Сейчас у пользователя тихие часы (время Еревана, UTC+4). */
export function inQuietHours(user: Pick<User, 'quietFrom' | 'quietTo'>, now = new Date()): boolean {
if (!user.quietFrom || !user.quietTo || user.quietFrom === user.quietTo) return false;
const d = new Date(now.getTime() + 4 * 3_600_000);
const hm = `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
return user.quietFrom < user.quietTo
? hm >= user.quietFrom && hm < user.quietTo
: hm >= user.quietFrom || hm < user.quietTo;
}
/** Уведомление о лиде с кнопкой «Беру». Пауза и тихие часы пользователя учитываются здесь. */
export async function enqueueLead(tx: Tx, leadId: number, recipients: User[], again = false, now = new Date()) {
const rows = recipients
.filter((u) => u.telegramId && !u.removedAt && !u.notificationsPaused && !inQuietHours(u, now))
.map((u) => ({ kind: 'lead', userId: u.id, leadId, payload: { again } }));
if (rows.length) await tx.insert(outbox).values(rows);
return rows.length;
}
/** Обычное сообщение пользователю. `key` — ключ текста из texts(), `args` — его параметры. */
export async function enqueueText(tx: Tx, userIds: number[], key: string, args: unknown[] = [], leadId?: number) {
if (!userIds.length) return;
await tx.insert(outbox).values(
[...new Set(userIds)].map((userId) => ({ kind: 'text', userId, leadId: leadId ?? null, payload: { key, args } })),
);
}
/** Сообщение всем суперадминам с привязанным Telegram: новые регистрации и компании. */
export async function enqueueForSuperadmins(tx: Tx, key: string, args: unknown[] = []) {
const admins = await tx.select({ id: users.id }).from(users).where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), isNull(users.removedAt)));
await enqueueText(tx, admins.map((u) => u.id), key, args);
}
/** Как связаться с человеком: email и Telegram. */
export const contactOf = (u: { email: string | null; telegramUsername: string | null; telegramId: number | null }) =>
[u.email, u.telegramUsername ? `@${u.telegramUsername}` : u.telegramId ? `Telegram ID ${u.telegramId}` : null].filter(Boolean).join(' · ') || '—';
/** Произвольный текст (сообщения суперадмина). */
export async function enqueueRaw(tx: Tx, userIds: number[], text: string) {
if (!userIds.length) return;
await tx.insert(outbox).values(userIds.map((userId) => ({ kind: 'text', userId, payload: { raw: text } })));
}
/** Лид взяли: у всех получателей обновить сообщение и убрать «Беру»; неотправленные уведомления отменить. */
export async function enqueueTaken(tx: Tx, leadId: number, takenById: number) {
await tx
.update(outbox)
.set({ doneAt: new Date(), error: 'canceled: lead taken' })
.where(and(eq(outbox.leadId, leadId), eq(outbox.kind, 'lead'), isNull(outbox.doneAt)));
const sent = await tx.select().from(leadNotifications).where(eq(leadNotifications.leadId, leadId));
if (!sent.length) return;
await tx.insert(outbox).values(
sent.map((n) => ({ kind: 'edit', userId: n.userId, leadId, payload: { notificationId: n.id, takenById } })),
);
}
const BATCH = 20;
/** Не больше ~25 сообщений в секунду на бота (лимит Bot API — 30). */
const GAP_MS = 40;
const MAX_ATTEMPTS = 6;
export interface OutboxDeps {
db: Db;
tg: TelegramApi;
config: Config;
sleep?: (ms: number) => Promise<void>;
}
const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => {
// Telegram принимает в кнопках только https-адреса; на локальном стенде кнопки «Открыть» нет.
if (!config.appUrl.startsWith('https://')) return [];
return [{ text: texts(lang).open, webApp: `${config.appUrl}/leads/${leadId}` }];
};
/** Отправляет одну пачку сообщений из очереди. Возвращает число обработанных. */
export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Promise<number> {
const wait = sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
// Забираем пачку и сразу отодвигаем run_at: если воркер упадёт, сообщения вернутся в очередь сами.
const jobs = await db
.update(outbox)
.set({ runAt: sql`now() + interval '2 minutes'`, attempts: sql`${outbox.attempts} + 1` })
.where(
inArray(
outbox.id,
sql`(select id from outbox where done_at is null and run_at <= now() order by id limit ${BATCH} for update skip locked)`,
),
)
.returning();
jobs.sort((a, b) => a.id - b.id);
for (const job of jobs) {
const finish = (error: string | null = null) =>
db.update(outbox).set({ doneAt: new Date(), error }).where(eq(outbox.id, job.id));
try {
const [found] = job.userId ? await db.select().from(users).where(eq(users.id, job.userId)) : [];
// Сообщения поддержки идут прямо в чат: у написавшего может не быть аккаунта.
const direct = job.payload as { chatId?: number; lang?: string };
if (!direct.chatId && (!found?.telegramId || found.removedAt)) {
await finish('skipped: no telegram');
continue;
}
const user = found ?? { id: 0, lang: direct.lang ?? 'ru' };
const chatId = direct.chatId ?? found!.telegramId!;
const t = texts(user.lang);
if (job.kind === 'text') {
const p = job.payload as { raw?: string; key?: string; args?: unknown[]; buttons?: { key: string; callback: string }[] };
// Кнопки под сообщением: подпись берётся из текстов бота на языке получателя.
const buttons: Keyboard | undefined = p.buttons?.length
? [p.buttons.map((b) => ({ text: String((t as unknown as Record<string, unknown>)[b.key] ?? b.key), callback: b.callback }))]
: undefined;
const fn = p.key ? (t as unknown as Record<string, unknown>)[p.key] : undefined;
const text =
p.raw !== undefined
? esc(p.raw)
: typeof fn === 'function'
? (fn as (...a: unknown[]) => string)(...(p.args ?? []).map((a) => (typeof a === 'string' ? esc(a) : a)))
: String(fn ?? '');
if (text) await tg.sendMessage(chatId, text, buttons ?? (job.leadId ? [openButton(config, user.lang, job.leadId)].filter((r) => r.length) : undefined));
await finish();
} else if (job.kind === 'lead') {
const [row] = await db
.select({ lead: leads, code: pipelineStatuses.code })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.where(eq(leads.id, job.leadId!));
// Пока сообщение ждало в очереди, лид могли взять.
if (!row || row.lead.assigneeId !== null || row.code !== 'new') {
await finish('skipped: lead not in pool');
continue;
}
const { lead } = row;
const again = Boolean((job.payload as { again?: boolean }).again);
const text = leadMessage(lead, user.lang, again ? t.backInPool : t.newLead);
const keyboard: Keyboard = [
[{ text: t.take, callback: `take:${lead.id}` }, ...openButton(config, user.lang, lead.id)],
];
const photo = lead.photos[0];
let sent;
let hasPhoto = false;
if (photo) {
try {
sent = await tg.sendPhoto(chatId, photo, text, keyboard);
hasPhoto = true;
} catch (e) {
// Telegram не смог скачать фото — отправляем без него. Остальные ошибки обрабатываются ниже.
if (!(e instanceof TelegramError) || e.code !== 400) throw e;
}
}
sent ??= await tg.sendMessage(chatId, text, keyboard);
await db.insert(leadNotifications).values({
leadId: lead.id,
userId: user.id,
chatId: sent.chatId,
messageId: sent.messageId,
hasPhoto,
sentAt: new Date(),
});
await finish();
} else if (job.kind === 'edit') {
const p = job.payload as { notificationId: number; takenById: number };
const [n] = await db.select().from(leadNotifications).where(eq(leadNotifications.id, p.notificationId));
const [lead] = await db.select().from(leads).where(eq(leads.id, job.leadId!));
if (n?.chatId && n.messageId && lead) {
const [taker] = await db.select({ name: users.name }).from(users).where(eq(users.id, p.takenById));
const footer = p.takenById === user.id ? t.youTook : `<i>${t.takenBy(esc(taker?.name ?? '—'))}</i>`;
const mine = p.takenById === user.id ? [openButton(config, user.lang, lead.id)].filter((r) => r.length) : [];
await tg.editMessage({ chatId: n.chatId, messageId: n.messageId }, n.hasPhoto, leadMessage(lead, user.lang, t.newLead, footer), mine);
}
await finish();
} else {
await finish(`unknown kind ${job.kind}`);
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (e instanceof TelegramError && e.code === 429) {
// Telegram просит подождать: попытку не считаем, всю пачку откладываем.
const until = sql`now() + make_interval(secs => ${(e.retryAfter ?? 5) + 1})`;
await db.update(outbox).set({ runAt: until, attempts: sql`${outbox.attempts} - 1`, error: message }).where(eq(outbox.id, job.id));
await wait(((e.retryAfter ?? 5) + 1) * 1000);
} else if ((e instanceof TelegramError && e.permanent) || job.attempts >= MAX_ATTEMPTS) {
await finish(`failed: ${message}`);
} else {
// Повтор с нарастающей паузой: 10 с, 40 с, 90 с…
await db
.update(outbox)
.set({ runAt: sql`now() + make_interval(secs => ${job.attempts * job.attempts * 10})`, error: message })
.where(eq(outbox.id, job.id));
}
}
await wait(GAP_MS);
}
return jobs.length;
}
+89
View File
@@ -0,0 +1,89 @@
import { eq } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { settings, users } from '../db/schema.ts';
import { badRequest, notFound } from '../lib/errors.ts';
import type { FileSender } from '../support/service.ts';
// Приветствие бота: что он пишет в ответ на /start тому, кто ещё не привязал Telegram к аккаунту,
// и какие файлы присылает следом. Суперадмин меняет это в админке без перезапуска сервиса.
const Text = z.string().trim().max(3500).default('');
export const BotStart = z.object({
/** Текст по языкам; пустой — стандартный текст бота на этом языке. */
text: z.object({ ru: Text, hy: Text, en: Text }).default({ ru: '', hy: '', en: '' }),
/** Файлы следом за текстом. Хранится идентификатор файла у Telegram: сам файл лежит у него. */
files: z
.array(z.object({ fileId: z.string(), kind: z.enum(['photo', 'file']), name: z.string().max(200) }))
.max(10)
.default([]),
});
export type BotStart = z.infer<typeof BotStart>;
const KEY = 'bot_start';
const MAX_FILES = 10;
const MAX_FILE = 20 * 1024 * 1024;
export async function loadBotStart(db: Db): Promise<BotStart> {
const [row] = await db.select().from(settings).where(eq(settings.key, KEY));
return BotStart.parse(row?.value ?? {});
}
async function saveBotStart(db: Db, patch: Partial<BotStart>): Promise<BotStart> {
const value = BotStart.parse({ ...(await loadBotStart(db)), ...patch });
await db.insert(settings).values({ key: KEY, value }).onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } });
return value;
}
/**
* Текст приветствия на языке собеседника; null — стандартный текст бота. Пока человек язык не выбирал,
* вместо незаполненного языка уходит русский; после выбора — только текст на выбранном языке.
*/
export const startText = (s: BotStart, lang: string, fallback = true): string | null =>
s.text[lang as keyof BotStart['text']] || (fallback ? s.text.ru : '') || null;
export function registerBotStartRoutes(
app: FastifyInstance,
db: Db,
superadmin: (req: FastifyRequest) => { user: typeof users.$inferSelect },
sendFile: FileSender | null,
) {
app.get('/api/admin/bot-start', async (req) => {
superadmin(req);
return loadBotStart(db);
});
app.patch('/api/admin/bot-start', async (req) => {
superadmin(req);
const { text } = BotStart.pick({ text: true }).parse(req.body);
return saveBotStart(db, { text });
});
/**
* Новый файл: имя и тип — в адресе, сам файл — в теле запроса. Чтобы получить идентификатор файла,
* бот присылает его суперадмину в Telegram — заодно видно, как файл выглядит у получателя.
*/
app.post('/api/admin/bot-start/files', { bodyLimit: MAX_FILE }, async (req) => {
const ctx = superadmin(req);
const q = z
.object({ name: z.string().trim().min(1).max(200), type: z.string().trim().max(100).default('application/octet-stream') })
.parse(req.query);
if (!sendFile) throw badRequest('TELEGRAM_DISABLED');
if (!ctx.user.telegramId) throw badRequest('LINK_TELEGRAM_FIRST');
if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION');
const current = await loadBotStart(db);
if (current.files.length >= MAX_FILES) throw badRequest('TOO_MANY_FILES');
const sent = await sendFile(ctx.user.telegramId, { name: q.name, type: q.type, data: req.body }, '/start');
return saveBotStart(db, { files: [...current.files, { fileId: sent.fileId, kind: sent.kind, name: q.name }] });
});
app.delete('/api/admin/bot-start/files/:index', async (req) => {
superadmin(req);
const { index } = z.object({ index: z.coerce.number().int().min(0) }).parse(req.params);
const current = await loadBotStart(db);
if (!current.files[index]) throw notFound();
return saveBotStart(db, { files: current.files.filter((_, i) => i !== index) });
});
}
+247
View File
@@ -0,0 +1,247 @@
import type { Lang } from '@lr/shared';
/** Тексты бота. Сообщения уходят в HTML-разметке, поэтому пользовательские строки экранируются. */
export const esc = (v: string) => v.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const ru = {
take: 'Беру',
open: 'Открыть',
openApp: 'Открыть Lead Radar',
newLead: 'Новый объект',
backInPool: 'Объект снова в пуле',
youTook: '✅ Вы взяли этот объект',
takenBy: (name: string) => `Взял(а) ${name}`,
taken: 'Объект ваш',
alreadyTaken: 'Уже взят',
unavailable: 'Объект недоступен',
readOnly: 'Доступ ограничен: оплатите подписку',
unknownUser: 'Сначала войдите в Lead Radar и привяжите Telegram.',
rooms: (n: number) => `${n} комн.`,
area: (n: number) => `${n} м²`,
floor: (a: number, b?: number | null) => (b ? `этаж ${a}/${b}` : `этаж ${a}`),
sale: 'Продажа',
rent: 'Аренда',
perMonth: '/мес',
agency: 'агентство',
onListAm: 'Объявление на List.am',
linked: (name: string) => `Готово, ${name}. Telegram привязан — сюда будут приходить новые объекты.`,
linkUsed: 'Этот Telegram уже привязан к другому аккаунту.',
linkBad: 'Ссылка привязки устарела. Создайте новую в настройках кабинета.',
hello: (name: string) => `Здравствуйте, ${name}. Новые объекты будут приходить в этот чат.\n\n/anketa — ссылка на анкету для клиента\n/pause — пауза уведомлений\n/resume — включить снова\n\nНужна помощь? Просто напишите сюда — подключится поддержка.`,
signupNew: (name: string, contact: string) => `🆕 Новая регистрация: ${name}\n${contact}`,
signupCompany: (company: string, plan: string, name: string, contact: string) => `🏢 Новая компания «${company}», тариф ${plan}\nРуководитель: ${name}\n${contact}`,
start: 'Это бот Y&P Lead Radar: новые объявления List.am по вашим фильтрам. Зарегистрируйтесь в кабинете и привяжите Telegram в настройках.\n\nНужна помощь? Просто напишите сюда — подключится поддержка.',
supportOpened: 'Сообщение передано в поддержку. Ответим здесь, в этом чате.',
supportReply: (text: string) => `💬 <b>Поддержка</b>\n${text}`,
supportClosed: 'Обращение закрыто. Если вопрос остался — просто напишите сюда.',
supportNew: (who: string, text: string) => `🆘 Обращение в поддержку от ${who}:\n${text}`,
paused: 'Уведомления о новых объектах на паузе. /resume — включить.',
resumed: 'Уведомления о новых объектах включены.',
assigned: (title: string) => `Вам назначен объект: ${title}`,
reassignedAway: (title: string, to: string) => `Объект «${title}» передан агенту ${to}.`,
poolWarn: (title: string, hours: number) => `⏳ Объект «${title}» вернётся в пул через ${hours} ч. Обновите статус, чтобы оставить его за собой.`,
poolLost: (title: string) => `Объект «${title}» вернулся в пул: статус долго не менялся.`,
reminder: (title: string, note: string | null) => `🔔 Напоминание по объекту «${title}»${note ? `: ${note}` : ''}`,
clientAssigned: (name: string) => `Вам назначен клиент: ${name}`,
clientReassignedAway: (name: string, to: string) => `Клиент «${name}» передан агенту ${to}.`,
clientReminder: (name: string, note: string | null) => `🔔 Напоминание по клиенту «${name}»${note ? `: ${note}` : ''}`,
joined: (name: string) => `${name} присоединился к компании по приглашению.`,
limitReached: 'Достигнут лимит участников тарифа. Новые приглашения не сработают.',
parserFailed: (error: string) => `⚠️ Парсер List.am: ${error}`,
intakeDone: (name: string, kind: string) => `📝 Новая анкета: ${name} — ${kind === 'buyer' ? 'покупатель' : 'арендатор'}. Клиент добавлен в CRM и закреплён за вами.\n\nПоказать ему подходящие объекты?`,
intakeMatch: 'Показать объекты', intakeLater: 'Не сейчас',
intakeOk: 'Хорошо. Подобрать объекты можно позже в карточке клиента.',
matchSent: (n: number, url: string, delivered: boolean) => `Подобрано объектов: ${n}. Страница подборки: ${url}\n${delivered ? 'Ссылка отправлена клиенту в бот.' : 'Отправьте ссылку клиенту — в бот она ему не ушла.'}`,
matchNone: 'Подходящих объектов пока нет. Когда появятся, подберите их в карточке клиента.',
viewingBooked: (client: string, title: string, day: string, note: string) => `📅 ${client} записался на показ ${day}: ${title}${note ? `\nКомментарий: ${note}` : ''}`,
anketa: (url: string) => `Ваша ссылка на анкету:\n${url}\n\nОтправьте её клиенту или разместите в объявлении. Каждый, кто заполнит анкету, попадёт в CRM как ваш клиент.`,
anketaOff: 'Анкета пока не подключена: клиентский бот не настроен.',
allow: { children: 'С детьми', pets: 'С животными', yes: 'можно', no: 'нельзя', negotiable: 'по договорённости' },
invoiceIssued: (amount: string) => `Выставлен счёт на ${amount}. Посмотреть и оплатить можно в разделе «Подписка».`,
paymentReceived: (until: string) => `Оплата получена. Подписка действует до ${until}.`,
subEnding: (days: number, until: string) => `Подписка заканчивается ${until} — осталось ${days} дн. Оплатите счёт в разделе «Подписка», чтобы сервис работал без перерыва.`,
subOverdue: (days: number) => `Подписка просрочена. Сервис работает ещё ${days} дн., затем уведомления о новых объектах остановятся, а CRM станет доступна только на чтение.`,
subRestricted: 'Доступ ограничен: уведомления о новых объектах не приходят, CRM работает только на чтение. После оплаты доступ вернётся сразу, данные сохранены.',
planChangeCanceled: 'Смена тарифа отменена: в компании больше участников, чем вмещает выбранный тариф.',
};
type Texts = typeof ru;
const hy: Texts = {
take: 'Վերցնում եմ',
open: 'Բացել',
openApp: 'Բացել Lead Radar-ը',
newLead: 'Նոր օբյեկտ',
backInPool: 'Օբյեկտը կրկին ընդհանուր ցուցակում է',
youTook: '✅ Դուք վերցրել եք այս օբյեկտը',
takenBy: (name) => `Վերցրել է ${name}`,
taken: 'Օբյեկտը ձերն է',
alreadyTaken: 'Արդեն վերցված է',
unavailable: 'Օբյեկտը հասանելի չէ',
readOnly: 'Մուտքը սահմանափակված է. վճարեք բաժանորդագրությունը',
unknownUser: 'Նախ մուտք գործեք Lead Radar և կապեք Telegram-ը։',
rooms: (n) => `${n} սենյակ`,
area: (n) => `${n} մ²`,
floor: (a, b) => (b ? `հարկ ${a}/${b}` : `հարկ ${a}`),
sale: 'Վաճառք',
rent: 'Վարձակալություն',
perMonth: '/ամիս',
agency: 'գործակալություն',
onListAm: 'Հայտարարությունը List.am-ում',
linked: (name) => `Պատրաստ է, ${name}։ Telegram-ը կապված է. նոր օբյեկտները կգան այստեղ։`,
linkUsed: 'Այս Telegram-ն արդեն կապված է այլ հաշվի։',
linkBad: 'Կապման հղումը ժամկետանց է։ Ստեղծեք նորը կաբինետի կարգավորումներում։',
hello: (name) => `Բարև, ${name}։ Նոր օբյեկտները կգան այս չատ։\n\n/anketa — հարցաթերթիկի հղում հաճախորդի համար\n/pause — դադարեցնել ծանուցումները\n/resume — կրկին միացնել\n\nՕգնությո՞ւն է պետք։ Պարզապես գրեք այստեղ՝ աջակցությունը կմիանա։`,
signupNew: (name, contact) => `🆕 Նոր գրանցում՝ ${name}\n${contact}`,
signupCompany: (company, plan, name, contact) => `🏢 Նոր ընկերություն «${company}», սակագին՝ ${plan}\nՂեկավար՝ ${name}\n${contact}`,
start: 'Սա Y&P Lead Radar բոտն է. List.am-ի նոր հայտարարությունները՝ ըստ ձեր ֆիլտրերի։ Գրանցվեք կաբինետում և կապեք Telegram-ը կարգավորումներում։\n\nՕգնությո՞ւն է պետք։ Պարզապես գրեք այստեղ՝ աջակցությունը կմիանա։',
supportOpened: 'Հաղորդագրությունը փոխանցվել է աջակցությանը։ Կպատասխանենք այստեղ՝ այս չատում։',
supportReply: (text) => `💬 <b>Աջակցություն</b>\n${text}`,
supportClosed: 'Դիմումը փակված է։ Եթե հարցը մնացել է՝ պարզապես գրեք այստեղ։',
supportNew: (who, text) => `🆘 Դիմում աջակցությանը՝ ${who}.\n${text}`,
paused: 'Նոր օբյեկտների ծանուցումները դադարեցված են։ /resume — միացնել։',
resumed: 'Նոր օբյեկտների ծանուցումները միացված են։',
assigned: (title) => `Ձեզ նշանակվել է օբյեկտ՝ ${title}`,
reassignedAway: (title, to) => `«${title}» օբյեկտը փոխանցվել է գործակալ ${to}-ին։`,
poolWarn: (title, hours) => `⏳ «${title}» օբյեկտը ${hours} ժամից կվերադառնա ընդհանուր ցուցակ։ Թարմացրեք կարգավիճակը՝ այն պահելու համար։`,
poolLost: (title) => `«${title}» օբյեկտը վերադարձել է ընդհանուր ցուցակ. կարգավիճակը երկար ժամանակ չի փոխվել։`,
reminder: (title, note) => `🔔 Հիշեցում «${title}» օբյեկտի վերաբերյալ${note ? `՝ ${note}` : ''}`,
clientAssigned: (name) => `Ձեզ նշանակվել է հաճախորդ՝ ${name}`,
clientReassignedAway: (name, to) => `«${name}» հաճախորդը փոխանցվել է գործակալ ${to}-ին։`,
clientReminder: (name, note) => `🔔 Հիշեցում «${name}» հաճախորդի վերաբերյալ${note ? `՝ ${note}` : ''}`,
joined: (name) => `${name}-ը միացել է ընկերությանը հրավերով։`,
limitReached: 'Սակագնի մասնակիցների սահմանաչափը լրացել է։ Նոր հրավերները չեն աշխատի։',
parserFailed: (error) => `⚠️ List.am պարսեր՝ ${error}`,
intakeDone: (name, kind) => `📝 Նոր հարցաթերթիկ՝ ${name} — ${kind === 'buyer' ? 'գնորդ' : 'վարձակալ'}։ Հաճախորդն ավելացվել է CRM և ամրագրված է Ձեզ։\n\nՑույց տա՞լ նրան համապատասխան օբյեկտները։`,
intakeMatch: 'Ցույց տալ օբյեկտները', intakeLater: 'Ոչ հիմա',
intakeOk: 'Լավ։ Օբյեկտները կարող եք ընտրել ավելի ուշ՝ հաճախորդի քարտում։',
matchSent: (n, url, delivered) => `Ընտրված օբյեկտներ՝ ${n}։ Ընտրանու էջը՝ ${url}\n${delivered ? 'Հղումն ուղարկվել է հաճախորդին բոտում։' : 'Ուղարկեք հղումը հաճախորդին. բոտով այն չի գնացել։'}`,
matchNone: 'Համապատասխան օբյեկտներ դեռ չկան։ Երբ հայտնվեն, ընտրեք դրանք հաճախորդի քարտում։',
viewingBooked: (client, title, day, note) => `📅 ${client}-ը գրանցվել է ցուցադրության ${day}-ին՝ ${title}${note ? `\nՄեկնաբանություն՝ ${note}` : ''}`,
anketa: (url) => `Ձեր հարցաթերթիկի հղումը.\n${url}\n\nՈւղարկեք այն հաճախորդին կամ տեղադրեք հայտարարության մեջ։ Յուրաքանչյուրը, ով կլրացնի հարցաթերթիկը, կհայտնվի CRM-ում որպես Ձեր հաճախորդ։`,
anketaOff: 'Հարցաթերթիկը դեռ միացված չէ. հաճախորդների բոտը կարգավորված չէ։',
allow: { children: 'Երեխաներով', pets: 'Կենդանիներով', yes: 'կարելի է', no: 'չի կարելի', negotiable: 'պայմանավորվածությամբ' },
invoiceIssued: (amount) => `Դուրս է գրվել հաշիվ՝ ${amount}։ Դիտել և վճարել կարող եք «Բաժանորդագրություն» բաժնում։`,
paymentReceived: (until) => `Վճարումը ստացված է։ Բաժանորդագրությունը գործում է մինչև ${until}։`,
subEnding: (days, until) => `Բաժանորդագրությունն ավարտվում է ${until}-ին՝ մնացել է ${days} օր։ Վճարեք հաշիվը «Բաժանորդագրություն» բաժնում, որպեսզի ծառայությունն աշխատի առանց ընդհատման։`,
subOverdue: (days) => `Բաժանորդագրությունը ժամկետանց է։ Ծառայությունը կաշխատի ևս ${days} օր, ապա նոր օբյեկտների ծանուցումները կդադարեն, իսկ CRM-ը կլինի միայն ընթերցման համար։`,
subRestricted: 'Մուտքը սահմանափակված է. նոր օբյեկտների ծանուցումներ չեն գալիս, CRM-ը միայն ընթերցման համար է։ Վճարումից հետո մուտքը կվերականգնվի անմիջապես, տվյալները պահպանված են։',
planChangeCanceled: 'Սակագնի փոփոխությունը չեղարկվել է. ընկերությունում ավելի շատ մասնակից կա, քան թույլ է տալիս ընտրված սակագինը։',
};
const en: Texts = {
take: 'Take',
open: 'Open',
openApp: 'Open Lead Radar',
newLead: 'New property',
backInPool: 'Property is back in the pool',
youTook: '✅ You took this property',
takenBy: (name) => `Taken by ${name}`,
taken: 'The property is yours',
alreadyTaken: 'Already taken',
unavailable: 'Property is unavailable',
readOnly: 'Access is restricted: pay the subscription',
unknownUser: 'Log in to Lead Radar and link your Telegram first.',
rooms: (n) => `${n} rooms`,
area: (n) => `${n} m²`,
floor: (a, b) => (b ? `floor ${a}/${b}` : `floor ${a}`),
sale: 'Sale',
rent: 'Rent',
perMonth: '/mo',
agency: 'agency',
onListAm: 'Listing on List.am',
linked: (name) => `Done, ${name}. Telegram is linked — new properties will arrive here.`,
linkUsed: 'This Telegram is already linked to another account.',
linkBad: 'The link has expired. Create a new one in the app settings.',
hello: (name) => `Hello, ${name}. New properties will arrive in this chat.\n\n/anketa — questionnaire link for a client\n/pause — pause notifications\n/resume — turn them back on\n\nNeed help? Just write here — support will join.`,
signupNew: (name, contact) => `🆕 New sign-up: ${name}\n${contact}`,
signupCompany: (company, plan, name, contact) => `🏢 New company “${company}”, plan ${plan}\nManager: ${name}\n${contact}`,
start: 'This is the Y&P Lead Radar bot: new List.am listings matching your filters. Sign up in the app and link Telegram in settings.\n\nNeed help? Just write here — support will join.',
supportOpened: 'Your message has been passed to support. We will reply here, in this chat.',
supportReply: (text) => `💬 <b>Support</b>\n${text}`,
supportClosed: 'The request is closed. If the question remains, just write here.',
supportNew: (who, text) => `🆘 Support request from ${who}:\n${text}`,
paused: 'New-property notifications are paused. /resume to turn on.',
resumed: 'New-property notifications are on.',
assigned: (title) => `A property was assigned to you: ${title}`,
reassignedAway: (title, to) => `“${title}” was reassigned to ${to}.`,
poolWarn: (title, hours) => `⏳ “${title}” returns to the pool in ${hours} h. Update the status to keep it.`,
poolLost: (title) => `“${title}” returned to the pool: the status had not changed for too long.`,
reminder: (title, note) => `🔔 Reminder for “${title}”${note ? `: ${note}` : ''}`,
clientAssigned: (name) => `A client was assigned to you: ${name}`,
clientReassignedAway: (name, to) => `Client “${name}” was reassigned to ${to}.`,
clientReminder: (name, note) => `🔔 Reminder for client “${name}”${note ? `: ${note}` : ''}`,
joined: (name) => `${name} joined the company via invitation.`,
limitReached: 'The plan member limit is reached. New invitations will not work.',
parserFailed: (error) => `⚠️ List.am parser: ${error}`,
intakeDone: (name, kind) => `📝 New questionnaire: ${name} — ${kind === 'buyer' ? 'buyer' : 'tenant'}. The client was added to the CRM and assigned to you.\n\nShow them matching properties?`,
intakeMatch: 'Show properties', intakeLater: 'Not now',
intakeOk: 'OK. You can pick properties later in the client card.',
matchSent: (n, url, delivered) => `Properties picked: ${n}. Selection page: ${url}\n${delivered ? 'The link was sent to the client in the bot.' : 'Send the link to the client — it was not delivered in the bot.'}`,
matchNone: 'No matching properties yet. When they appear, pick them in the client card.',
viewingBooked: (client, title, day, note) => `📅 ${client} booked a viewing on ${day}: ${title}${note ? `\nComment: ${note}` : ''}`,
anketa: (url) => `Your questionnaire link:\n${url}\n\nSend it to a client or put it in your ad. Everyone who fills it in appears in the CRM as your client.`,
anketaOff: 'The questionnaire is not connected yet: the client bot is not configured.',
allow: { children: 'Children', pets: 'Pets', yes: 'allowed', no: 'not allowed', negotiable: 'negotiable' },
invoiceIssued: (amount) => `An invoice for ${amount} has been issued. You can view and pay it in “Subscription”.`,
paymentReceived: (until) => `Payment received. The subscription is valid until ${until}.`,
subEnding: (days, until) => `The subscription ends on ${until} — ${days} day(s) left. Pay the invoice in “Subscription” to keep the service running.`,
subOverdue: (days) => `The subscription is overdue. The service keeps working for ${days} more day(s); after that new-property notifications stop and the CRM becomes read-only.`,
subRestricted: 'Access is restricted: no new-property notifications, the CRM is read-only. Access returns right after payment, your data is kept.',
planChangeCanceled: 'The plan change was canceled: the company has more members than the selected plan allows.',
};
const all: Record<Lang, Texts> = { ru, hy, en };
export const texts = (lang: string | null | undefined): Texts => all[(lang as Lang) ?? 'ru'] ?? ru;
const SYMBOL: Record<string, string> = { USD: '$', EUR: '€', AMD: '֏', RUB: '₽' };
export function formatPrice(price: number | null, currency: string | null): string | null {
if (price == null) return null;
const n = Math.round(price).toLocaleString('en-US').replaceAll(',', ' ');
const sym = SYMBOL[currency ?? 'USD'] ?? '';
return currency === 'AMD' || currency === 'RUB' ? `${n} ${sym}` : `${sym}${n}`;
}
export interface LeadCard {
title: string;
description: string | null;
dealType: string | null;
region: string | null;
district: string | null;
address: string | null;
price: number | null;
currency: string | null;
rooms: number | null;
area: number | null;
floor: number | null;
floorsTotal: number | null;
url: string | null;
features?: { children?: 'yes' | 'no' | 'negotiable'; pets?: 'yes' | 'no' | 'negotiable' } | null;
}
/** Текст уведомления о лиде: цена, район и адрес, комнаты, площадь, этаж, начало описания, ссылка. */
export function leadMessage(lead: LeadCard, lang: string, header: string, footer?: string): string {
const t = texts(lang);
const price = formatPrice(lead.price, lead.currency);
const lines: string[] = [`<b>${esc(header)}</b>`];
if (price) lines.push(`<b>${price}${lead.dealType === 'rent' ? t.perMonth : ''}</b> · ${lead.dealType === 'rent' ? t.rent : t.sale}`);
const place = [lead.district ?? lead.region, lead.address].filter(Boolean).join(', ');
if (place) lines.push(`📍 ${esc(place)}`);
const specs = [
lead.rooms != null ? t.rooms(lead.rooms) : null,
lead.area != null ? t.area(lead.area) : null,
lead.floor != null ? t.floor(lead.floor, lead.floorsTotal) : null,
].filter(Boolean);
if (specs.length) lines.push(specs.join(' · '));
// Правила проживания из объявления: агенту сразу видно, подойдёт ли объект семье или хозяину животного.
const rules = (['children', 'pets'] as const)
.filter((k) => lead.features?.[k])
.map((k) => `${t.allow[k]}: ${t.allow[lead.features![k]!]}`);
if (rules.length) lines.push(rules.join(' · '));
const text = (lead.description ?? lead.title).replace(/\s+/g, ' ').trim();
if (text) lines.push('', esc(text.length > 220 ? `${text.slice(0, 220)}…` : text));
if (lead.url) lines.push('', `<a href="${esc(lead.url)}">${t.onListAm}</a>`);
if (footer) lines.push('', footer);
return lines.join('\n');
}