Анкета: телефон обязателен, кнопка «Отправить мой номер»
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2da10169b8
commit
7fdabd07a4
6 files changed
+91
-23
No files matched your search
@@ -103,6 +103,7 @@ pnpm dev:web # http://localhost:5174
|
||||
- **Анкета соискателя**: у каждого агента своя ссылка на клиентский бот (команда `/anketa` в основном
|
||||
боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент,
|
||||
закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?».
|
||||
Телефон обязателен: пропустить нельзя, номер отправляется кнопкой «Отправить мой номер» или пишется вручную.
|
||||
- **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту,
|
||||
и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит
|
||||
создателю ссылки в бот и появляется в карточке клиента.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** Чат соискателя в клиентском боте — чтобы отправить ему ссылку на подборку. */
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user