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

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
+91 -23

No files matched your search

+1
View File
@@ -103,6 +103,7 @@ pnpm dev:web # http://localhost:5174
- **Анкета соискателя**: у каждого агента своя ссылка на клиентский бот (команда `/anketa` в основном
боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент,
закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?».
Телефон обязателен: пропустить нельзя, номер отправляется кнопкой «Отправить мой номер» или пишется вручную.
- **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту,
и запись на показ по дням. Адрес и ссылка на 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 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);
}
/** Чат соискателя в клиентском боте — чтобы отправить ему ссылку на подборку. */
+3 -3
View File
@@ -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.',
+15 -1
View File
@@ -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({
+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 });
});
});
+32 -12
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 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<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 } },
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<string, unknown>[] =>
'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(