Анкета: телефон обязателен, кнопка «Отправить мой номер»

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-26 03:07:01 +04:00
1 parent 2da10169b8
commit 7fdabd07a4
6 files changed
+87 -19

No files matched your search

+1
View File
@@ -103,6 +103,7 @@ pnpm dev:web # http://localhost:5174
- **Анкета соискателя**: у каждого агента своя ссылка на клиентский бот (команда `/anketa` в основном - **Анкета соискателя**: у каждого агента своя ссылка на клиентский бот (команда `/anketa` в основном
боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент, боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент,
закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?». закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?».
Телефон обязателен: пропустить нельзя, номер отправляется кнопкой «Отправить мой номер» или пишется вручную.
- **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту, - **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту,
и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит
создателю ссылки в бот и появляется в карточке клиента. создателю ссылки в бот и появляется в карточке клиента.
+15 -7
View File
@@ -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 { clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts'; import type { Config } from '../lib/config.ts';
import { statusByCode } from '../pipelines/service.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 { enqueueRaw } from '../telegram/outbox.ts';
import { esc } from '../telegram/texts.ts'; import { esc } from '../telegram/texts.ts';
import { intakeTexts, LANGUAGE_BUTTONS, LANGUAGE_QUESTION } from './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': case 'name':
return { text: t.name, keyboard: s.tgName ? [[btn(t.keepName(s.tgName.slice(0, 30)), step, 'keep')]] : undefined }; return { text: t.name, keyboard: s.tgName ? [[btn(t.keepName(s.tgName.slice(0, 30)), step, 'keep')]] : undefined };
case 'phone': 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': case 'name':
return { patch: { name: (viaButton ? (s.tgName ?? 'Telegram') : value).slice(0, 120) } }; return { patch: { name: (viaButton ? (s.tgName ?? 'Telegram') : value).slice(0, 120) } };
case 'phone': { 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; 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; let viaButton = false;
if (update.callback_query) { if (update.callback_query) {
const m = /^q:([a-zA-Z]+):(.+)$/.exec(update.callback_query.data ?? ''); 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); 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 answers = { ...(session.answers as Answers), ...result.patch };
const next = nextStep(step, answers); const next = nextStep(step, answers);
if (next !== 'done') { if (next !== 'done') {
@@ -397,7 +404,8 @@ export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, updat
return ask(updated!, next); return ask(updated!, next);
} }
const { owner } = await complete(db, session, answers); 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);
} }
/** Чат соискателя в клиентском боте — чтобы отправить ему ссылку на подборку. */ /** Чат соискателя в клиентском боте — чтобы отправить ему ссылку на подборку. */
+3 -3
View File
@@ -18,7 +18,7 @@ const ru = {
pets: 'Есть ли домашние животные?', more2: '2 и больше', pets: 'Есть ли домашние животные?', more2: '2 и больше',
petsAge: 'Кто это и сколько лет? Например: кот, 2 года.', petsAge: 'Кто это и сколько лет? Например: кот, 2 года.',
name: 'Как вас зовут?', keepName: (n: string) => `Оставить «${n}»`, name: 'Как вас зовут?', keepName: (n: string) => `Оставить «${n}»`,
phone: 'Оставьте номер телефона, чтобы агент мог с вами связаться.', badPhone: 'Похоже, это не номер телефона. Напишите его цифрами, например: 091 123 456.', phone: 'Последний шаг — номер телефона, чтобы агент мог с вами связаться. Нажмите кнопку «Отправить мой номер» внизу или напишите номер.', sharePhone: '📱 Отправить мой номер', phoneRequired: 'Без номера телефона агент не сможет с вами связаться. Нажмите «Отправить мой номер» внизу или напишите номер, например: 091 123 456.', badPhone: 'Похоже, это не номер телефона. Напишите его цифрами, например: 091 123 456.',
skip: 'Пропустить', skip: 'Пропустить',
done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`, done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`,
photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.', photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.',
@@ -47,7 +47,7 @@ const hy: Texts = {
pets: 'Ընտանի կենդանիներ ունե՞ք', more2: '2 և ավելի', pets: 'Ընտանի կենդանիներ ունե՞ք', more2: '2 և ավելի',
petsAge: 'Ո՞վ է և քանի՞ տարեկան։ Օրինակ՝ կատու, 2 տարեկան։', petsAge: 'Ո՞վ է և քանի՞ տարեկան։ Օրինակ՝ կատու, 2 տարեկան։',
name: 'Ինչպե՞ս է Ձեր անունը', keepName: (n) => `Թողնել «${n}»`, name: 'Ինչպե՞ս է Ձեր անունը', keepName: (n) => `Թողնել «${n}»`,
phone: 'Թողեք հեռախոսահամարը, որպեսզի գործակալը կապվի Ձեզ հետ։', badPhone: 'Կարծես սա հեռախոսահամար չէ։ Գրեք թվերով, օրինակ՝ 091 123 456։', phone: 'Վերջին քայլը՝ հեռախոսահամարը, որպեսզի գործակալը կապվի Ձեզ հետ։ Սեղմեք ներքևում «Ուղարկել իմ համարը» կոճակը կամ գրեք համարը։', sharePhone: '📱 Ուղարկել իմ համարը', phoneRequired: 'Առանց հեռախոսահամարի գործակալը չի կարողանա կապվել Ձեզ հետ։ Սեղմեք ներքևում «Ուղարկել իմ համարը» կամ գրեք համարը, օրինակ՝ 091 123 456։', badPhone: 'Կարծես սա հեռախոսահամար չէ։ Գրեք թվերով, օրինակ՝ 091 123 456։',
skip: 'Բաց թողնել', skip: 'Բաց թողնել',
done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`, done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`,
photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։', photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։',
@@ -74,7 +74,7 @@ const en: Texts = {
pets: 'Do you have pets?', more2: '2 or more', pets: 'Do you have pets?', more2: '2 or more',
petsAge: 'Who is it and how old? E.g. cat, 2 years.', petsAge: 'Who is it and how old? E.g. cat, 2 years.',
name: 'What is your name?', keepName: (n) => `Keep “${n}”`, 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', 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.`, 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.', 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.',
+15 -1
View File
@@ -54,6 +54,10 @@ const press = (chat: number, data: string) =>
handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config }, 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 } } })); 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)!; 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('адрес компании', () => { describe('адрес компании', () => {
it('строится из названия; служебные адреса и мусор не проходят', () => { it('строится из названия; служебные адреса и мусор не проходят', () => {
@@ -145,10 +149,20 @@ describe('анкета в клиентском боте', () => {
await press(seeker, 'q:pets:0'); await press(seeker, 'q:pets:0');
expect(last(seeker).text).toBe('Как вас зовут?'); expect(last(seeker).text).toBe('Как вас зовут?');
await press(seeker, 'q:name:keep'); 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'); await say(seeker, 'abc');
expect(last(seeker).text).toContain('не номер телефона'); 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).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)); const [client] = await t.db.select().from(clients).where(eq(clients.assigneeId, agent.id));
expect(client).toMatchObject({ expect(client).toMatchObject({
+25
View File
@@ -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<HttpTelegram['sendMessage']>[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 });
});
});
+28 -8
View File
@@ -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 type Keyboard = Button[][];
/** Клавиатура, которая убирает кнопку «поделиться номером». */
export const REMOVE_KEYBOARD: Keyboard = [[{ text: '', removeKeyboard: true }]];
export interface SentMessage { export interface SentMessage {
chatId: number; chatId: number;
messageId: number; messageId: number;
@@ -58,17 +65,30 @@ export interface TelegramApi {
getUpdates(offset: number, timeoutSec: number): Promise<TgUpdate[]>; getUpdates(offset: number, timeoutSec: number): Promise<TgUpdate[]>;
} }
const markup = (keyboard?: Keyboard) => ({ 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) => inline_keyboard: (keyboard ?? []).map((row) =>
row.map((b) => row.flatMap((b): Record<string, unknown>[] =>
'callback' in b 'callback' in b
? { text: b.text, callback_data: b.callback } ? [{ text: b.text, callback_data: b.callback }]
: 'url' in b : 'url' in b
? { text: b.text, url: b.url } ? [{ text: b.text, url: b.url }]
: { text: b.text, web_app: { url: b.webApp } }, : 'webApp' in b
? [{ text: b.text, web_app: { url: b.webApp } }]
: [],
), ),
), ),
}); };
};
export class HttpTelegram implements TelegramApi { export class HttpTelegram implements TelegramApi {
constructor( constructor(