From 7fdabd07a41c5d6346fcc7cd83b70fa57ffd8da8 Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Mon, 26 Oct 2026 03:07:01 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=BD=D0=BA=D0=B5=D1=82=D0=B0:=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=20=D0=BE=D0=B1=D1=8F=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BD,=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=9E=D1=82=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BC=D0=BE=D0=B9=20=D0=BD=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 1 + apps/api/src/intake/service.ts | 22 +++++++++----- apps/api/src/intake/texts.ts | 6 ++-- apps/api/src/showcase.int.test.ts | 16 +++++++++- apps/api/src/telegram/client.test.ts | 25 ++++++++++++++++ apps/api/src/telegram/client.ts | 44 ++++++++++++++++++++-------- 6 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/telegram/client.test.ts diff --git a/README.md b/README.md index f35d668..c623871 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ pnpm dev:web # http://localhost:5174 - **Анкета соискателя**: у каждого агента своя ссылка на клиентский бот (команда `/anketa` в основном боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент, закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?». + Телефон обязателен: пропустить нельзя, номер отправляется кнопкой «Отправить мой номер» или пишется вручную. - **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту, и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит создателю ссылки в бот и появляется в карточке клиента. diff --git a/apps/api/src/intake/service.ts b/apps/api/src/intake/service.ts index aa57be3..a54fa86 100644 --- a/apps/api/src/intake/service.ts +++ b/apps/api/src/intake/service.ts @@ -5,7 +5,7 @@ import type { Db } from '../db/client.ts'; import { clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts'; import type { Config } from '../lib/config.ts'; import { statusByCode } from '../pipelines/service.ts'; -import type { Keyboard, TelegramApi, TgUpdate } from '../telegram/client.ts'; +import { REMOVE_KEYBOARD, type Keyboard, type TelegramApi, type TgUpdate } from '../telegram/client.ts'; import { enqueueRaw } from '../telegram/outbox.ts'; import { esc } from '../telegram/texts.ts'; import { intakeTexts, LANGUAGE_BUTTONS, LANGUAGE_QUESTION } from './texts.ts'; @@ -159,7 +159,8 @@ function question(step: Step, s: Session): { text: string; keyboard?: Keyboard } case 'name': return { text: t.name, keyboard: s.tgName ? [[btn(t.keepName(s.tgName.slice(0, 30)), step, 'keep')]] : undefined }; case 'phone': - return { text: t.phone, keyboard: [[btn(t.skip, step, 'skip')]] }; + // Телефон обязателен: кнопка внизу экрана отправляет номер клиента из Telegram одним нажатием. + return { text: t.phone, keyboard: [[{ text: t.sharePhone, requestContact: true }]] }; } } @@ -197,8 +198,9 @@ function accept(step: Step, s: Session, value: string, viaButton: boolean): { pa case 'name': return { patch: { name: (viaButton ? (s.tgName ?? 'Telegram') : value).slice(0, 120) } }; case 'phone': { - if (viaButton) return { patch: { phone: null } }; - return value.replace(/\D/g, '').length >= 6 ? { patch: { phone: value.slice(0, 40) } } : { error: t.badPhone }; + // Номер — самое важное в анкете: без него анкету не закончить (кнопка «Пропустить» у старых вопросов тоже не сработает). + if (viaButton) return { error: t.phoneRequired }; + return value.replace(/\D/g, '').length >= 8 ? { patch: { phone: value.slice(0, 40) } } : { error: t.badPhone }; } } } @@ -375,7 +377,9 @@ export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, updat } const step = session.step as Step; - let value = text; + // Контакт кнопкой «Отправить мой номер» — ответ на вопрос о телефоне. + const contact = update.message?.contact?.phone_number; + let value = step === 'phone' && contact ? contact : text; let viaButton = false; if (update.callback_query) { const m = /^q:([a-zA-Z]+):(.+)$/.exec(update.callback_query.data ?? ''); @@ -389,7 +393,10 @@ export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, updat } const result = accept(step, session, value, viaButton); - if ('error' in result) return void (await tg.sendMessage(chatId, esc(result.error))); + if ('error' in result) { + // На вопросе о телефоне кнопка «Отправить мой номер» остаётся под рукой. + return void (await tg.sendMessage(chatId, esc(result.error), step === 'phone' ? question('phone', session).keyboard : undefined)); + } const answers = { ...(session.answers as Answers), ...result.patch }; const next = nextStep(step, answers); if (next !== 'done') { @@ -397,7 +404,8 @@ export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, updat return ask(updated!, next); } const { owner } = await complete(db, session, answers); - await tg.sendMessage(chatId, esc(st.done(owner.name))); + // Убираем кнопку «Отправить мой номер». + await tg.sendMessage(chatId, esc(st.done(owner.name)), REMOVE_KEYBOARD); } /** Чат соискателя в клиентском боте — чтобы отправить ему ссылку на подборку. */ diff --git a/apps/api/src/intake/texts.ts b/apps/api/src/intake/texts.ts index 03b29a8..370a4a3 100644 --- a/apps/api/src/intake/texts.ts +++ b/apps/api/src/intake/texts.ts @@ -18,7 +18,7 @@ const ru = { pets: 'Есть ли домашние животные?', more2: '2 и больше', petsAge: 'Кто это и сколько лет? Например: кот, 2 года.', name: 'Как вас зовут?', keepName: (n: string) => `Оставить «${n}»`, - phone: 'Оставьте номер телефона, чтобы агент мог с вами связаться.', badPhone: 'Похоже, это не номер телефона. Напишите его цифрами, например: 091 123 456.', + phone: 'Последний шаг — номер телефона, чтобы агент мог с вами связаться. Нажмите кнопку «Отправить мой номер» внизу или напишите номер.', sharePhone: '📱 Отправить мой номер', phoneRequired: 'Без номера телефона агент не сможет с вами связаться. Нажмите «Отправить мой номер» внизу или напишите номер, например: 091 123 456.', badPhone: 'Похоже, это не номер телефона. Напишите его цифрами, например: 091 123 456.', skip: 'Пропустить', done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`, photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.', @@ -47,7 +47,7 @@ const hy: Texts = { pets: 'Ընտանի կենդանիներ ունե՞ք', more2: '2 և ավելի', petsAge: 'Ո՞վ է և քանի՞ տարեկան։ Օրինակ՝ կատու, 2 տարեկան։', name: 'Ինչպե՞ս է Ձեր անունը', keepName: (n) => `Թողնել «${n}»`, - phone: 'Թողեք հեռախոսահամարը, որպեսզի գործակալը կապվի Ձեզ հետ։', badPhone: 'Կարծես սա հեռախոսահամար չէ։ Գրեք թվերով, օրինակ՝ 091 123 456։', + phone: 'Վերջին քայլը՝ հեռախոսահամարը, որպեսզի գործակալը կապվի Ձեզ հետ։ Սեղմեք ներքևում «Ուղարկել իմ համարը» կոճակը կամ գրեք համարը։', sharePhone: '📱 Ուղարկել իմ համարը', phoneRequired: 'Առանց հեռախոսահամարի գործակալը չի կարողանա կապվել Ձեզ հետ։ Սեղմեք ներքևում «Ուղարկել իմ համարը» կամ գրեք համարը, օրինակ՝ 091 123 456։', badPhone: 'Կարծես սա հեռախոսահամար չէ։ Գրեք թվերով, օրինակ՝ 091 123 456։', skip: 'Բաց թողնել', done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`, photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։', @@ -74,7 +74,7 @@ const en: Texts = { pets: 'Do you have pets?', more2: '2 or more', petsAge: 'Who is it and how old? E.g. cat, 2 years.', name: 'What is your name?', keepName: (n) => `Keep “${n}”`, - phone: 'Leave a phone number so the agent can contact you.', badPhone: 'That does not look like a phone number. Write it in digits, e.g. 091 123 456.', + phone: 'Last step — your phone number so the agent can contact you. Tap “Send my number” below or type the number.', sharePhone: '📱 Send my number', phoneRequired: 'Without a phone number the agent cannot contact you. Tap “Send my number” below or type the number, e.g. 091 123 456.', badPhone: 'That does not look like a phone number. Write it in digits, e.g. 091 123 456.', skip: 'Skip', done: (agent) => `Thank you, the questionnaire has been sent. Agent ${agent} will contact you.\n\nIf you like, send a photo here — of yourself or your pet — it will be added to the questionnaire.`, photoSaved: 'The photo was added to the questionnaire.', photoLimit: 'There are already 10 photos attached.', photoFail: 'Could not save the photo. Try another one, up to 5 MB.', diff --git a/apps/api/src/showcase.int.test.ts b/apps/api/src/showcase.int.test.ts index 3b4f611..630c845 100644 --- a/apps/api/src/showcase.int.test.ts +++ b/apps/api/src/showcase.int.test.ts @@ -54,6 +54,10 @@ const press = (chat: number, data: string) => handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config }, fromSeeker(chat, { callback_query: { id: `cb${updateId}`, from: { id: chat, first_name: 'Анна', language_code: 'ru' }, data, message: { chat: { id: chat }, message_id: 1 } } })); const last = (chat: number) => seekerBot.inbox(chat).at(-1)!; +/** Клиент нажал «Отправить мой номер»: Telegram присылает контакт без текста. */ +const shareContact = (chat: number, phone: string) => + handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config }, + fromSeeker(chat, { message: { chat: { id: chat }, from: { id: chat, first_name: 'Анна', username: 'anna', language_code: 'ru' }, contact: { phone_number: phone } } })); describe('адрес компании', () => { it('строится из названия; служебные адреса и мусор не проходят', () => { @@ -145,10 +149,20 @@ describe('анкета в клиентском боте', () => { await press(seeker, 'q:pets:0'); expect(last(seeker).text).toBe('Как вас зовут?'); await press(seeker, 'q:name:keep'); + // Телефон обязателен: кнопки «Пропустить» нет, есть «Отправить мой номер» внизу экрана. + expect(last(seeker).text).toContain('номер телефона'); + expect(last(seeker).keyboard.flat()).toEqual([{ text: '📱 Отправить мой номер', requestContact: true }]); + await press(seeker, 'q:phone:skip'); + expect(last(seeker).text).toContain('Без номера телефона'); await say(seeker, 'abc'); expect(last(seeker).text).toContain('не номер телефона'); - await say(seeker, '091 123 456'); + expect(last(seeker).keyboard.flat()).toEqual([{ text: '📱 Отправить мой номер', requestContact: true }]); + await say(seeker, '12345'); + expect(last(seeker).text).toContain('не номер телефона'); + await shareContact(seeker, '+37491123456'); expect(last(seeker).text).toContain('анкета отправлена'); + // Кнопка «Отправить мой номер» убирается. + expect(last(seeker).keyboard.flat()).toEqual([{ text: '', removeKeyboard: true }]); const [client] = await t.db.select().from(clients).where(eq(clients.assigneeId, agent.id)); expect(client).toMatchObject({ diff --git a/apps/api/src/telegram/client.test.ts b/apps/api/src/telegram/client.test.ts new file mode 100644 index 0000000..e2d48c2 --- /dev/null +++ b/apps/api/src/telegram/client.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { HttpTelegram, REMOVE_KEYBOARD } from './client.ts'; + +/** Что ушло в Bot API в поле reply_markup. */ +const sentMarkup = async (keyboard: Parameters[2]) => { + let body: { reply_markup?: unknown } = {}; + const fetchFn = (async (_url: string, init: { body: string }) => { + body = JSON.parse(init.body); + return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } })); + }) as unknown as typeof fetch; + await new HttpTelegram('t', fetchFn).sendMessage(1, 'x', keyboard); + return body.reply_markup; +}; + +describe('клавиатуры Bot API', () => { + it('кнопки под сообщением, «поделиться номером» и её удаление', async () => { + expect(await sentMarkup([[{ text: 'Да', callback: 'yes' }, { text: 'Сайт', url: 'https://x.am' }]])).toEqual({ + inline_keyboard: [[{ text: 'Да', callback_data: 'yes' }, { text: 'Сайт', url: 'https://x.am' }]], + }); + expect(await sentMarkup([[{ text: 'Отправить мой номер', requestContact: true }]])).toEqual({ + keyboard: [[{ text: 'Отправить мой номер', request_contact: true }]], resize_keyboard: true, one_time_keyboard: true, + }); + expect(await sentMarkup(REMOVE_KEYBOARD)).toEqual({ remove_keyboard: true }); + }); +}); diff --git a/apps/api/src/telegram/client.ts b/apps/api/src/telegram/client.ts index d5fd237..5fa15d0 100644 --- a/apps/api/src/telegram/client.ts +++ b/apps/api/src/telegram/client.ts @@ -1,7 +1,14 @@ -/** Кнопка под сообщением: callback — нажатие обрабатывает бот; url — ссылка; webApp — открыть Mini App. */ -export type Button = { text: string } & ({ callback: string } | { url: string } | { webApp: string }); +/** + * Кнопка под сообщением: callback — нажатие обрабатывает бот; url — ссылка; webApp — открыть Mini App. + * requestContact — кнопка внизу экрана «поделиться номером» (обычная клавиатура, не под сообщением); + * removeKeyboard — убрать такую клавиатуру. + */ +export type Button = { text: string } & ({ callback: string } | { url: string } | { webApp: string } | { requestContact: true } | { removeKeyboard: true }); export type Keyboard = Button[][]; +/** Клавиатура, которая убирает кнопку «поделиться номером». */ +export const REMOVE_KEYBOARD: Keyboard = [[{ text: '', removeKeyboard: true }]]; + export interface SentMessage { chatId: number; messageId: number; @@ -58,17 +65,30 @@ export interface TelegramApi { getUpdates(offset: number, timeoutSec: number): Promise; } -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 } }, +const markup = (keyboard?: Keyboard) => { + const buttons = (keyboard ?? []).flat(); + if (buttons.some((b) => 'removeKeyboard' in b)) return { remove_keyboard: true }; + if (buttons.some((b) => 'requestContact' in b)) { + return { + keyboard: (keyboard ?? []).map((row) => row.map((b) => ({ text: b.text, ...('requestContact' in b ? { request_contact: true } : {}) }))), + resize_keyboard: true, + one_time_keyboard: true, + }; + } + return { + inline_keyboard: (keyboard ?? []).map((row) => + row.flatMap((b): Record[] => + 'callback' in b + ? [{ text: b.text, callback_data: b.callback }] + : 'url' in b + ? [{ text: b.text, url: b.url }] + : 'webApp' in b + ? [{ text: b.text, web_app: { url: b.webApp } }] + : [], + ), ), - ), -}); + }; +}; export class HttpTelegram implements TelegramApi { constructor(