From 3afdd04b3ea6f0c9de6d1494b4d68c9098c076a9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Apr 2026 12:14:24 +0000 Subject: [PATCH] feat(telegram-bot): oneWay mode, emoji sanitization, form-urlencoded webhooks - BotScenario.oneWay flag: prevent revisiting message/input nodes (acts at executeFromNode level with _visited_nodes array; reset on /start) - Engine enriches variables with subscriber profile (_first_name, _last_name, _username, _phone, _source, _chat_id, _subscriber_id, _bot_id, _club_id) for use in webhook bodies and templates - stripEmoji() removes 4-byte UTF-8 from names before CRM sync (Bitrix rejected leads with emoji-only names) - Bot webhook processor supports application/x-www-form-urlencoded (Bitrix custom module expects form data, not JSON) - Step editor: oneWay toggle in scenario header Fixes: infinite send_to_crm duplication, Bitrix lead creation failures for users with emoji in Telegram first_name, and referral loop when user clicks /start from an input node. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../interfaces/scenario.interface.ts | 2 + .../processors/bot-webhook.processor.ts | 25 +++- .../services/scenario-engine.service.ts | 117 ++++++++++++++++-- .../telegram-bot/flow-editor/step-editor.tsx | 14 +++ 4 files changed, 148 insertions(+), 10 deletions(-) diff --git a/apps/api/src/modules/telegram-bot/interfaces/scenario.interface.ts b/apps/api/src/modules/telegram-bot/interfaces/scenario.interface.ts index 84229be..7c59c6e 100644 --- a/apps/api/src/modules/telegram-bot/interfaces/scenario.interface.ts +++ b/apps/api/src/modules/telegram-bot/interfaces/scenario.interface.ts @@ -11,6 +11,8 @@ export interface BotScenario { variables: Record; /** All nodes keyed by node ID */ nodes: Record; + /** OneWay mode: prevent users from revisiting already seen message/input nodes */ + oneWay?: boolean; } export interface ScenarioVariable { diff --git a/apps/api/src/modules/telegram-bot/processors/bot-webhook.processor.ts b/apps/api/src/modules/telegram-bot/processors/bot-webhook.processor.ts index a3325eb..669ac2f 100644 --- a/apps/api/src/modules/telegram-bot/processors/bot-webhook.processor.ts +++ b/apps/api/src/modules/telegram-bot/processors/bot-webhook.processor.ts @@ -19,13 +19,34 @@ export class BotWebhookProcessor extends WorkerHost { this.logger.log(`Delivering bot webhook: ${method} ${url}`); try { + const contentType = headers['Content-Type'] ?? headers['content-type'] ?? 'application/json'; + const isForm = contentType.includes('x-www-form-urlencoded'); + + let reqBody: string; + if (isForm) { + // Flatten body to form-urlencoded + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(body)) { + if (v === null || v === undefined) { + params.append(k, ''); + } else if (typeof v === 'object') { + params.append(k, JSON.stringify(v)); + } else { + params.append(k, String(v as string | number | boolean)); + } + } + reqBody = params.toString(); + } else { + reqBody = JSON.stringify(body); + } + const res = await fetch(url, { method: method ?? 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': contentType, ...headers, }, - body: JSON.stringify(body), + body: reqBody, signal: AbortSignal.timeout(10000), }); diff --git a/apps/api/src/modules/telegram-bot/services/scenario-engine.service.ts b/apps/api/src/modules/telegram-bot/services/scenario-engine.service.ts index 6a0a34f..613a769 100644 --- a/apps/api/src/modules/telegram-bot/services/scenario-engine.service.ts +++ b/apps/api/src/modules/telegram-bot/services/scenario-engine.service.ts @@ -61,6 +61,19 @@ export class ScenarioEngineService { const scenario = scenarioJson as BotScenario; if (!scenario?.nodes || !scenario.startNodeId) return; + // Enrich variables with subscriber profile data + const subProfile = await this.prisma.tgBotSubscriber.findUnique({ + where: { id: subscriberId }, + select: { firstName: true, lastName: true, username: true, phone: true, source: true }, + }); + if (subProfile) { + if (subProfile.firstName) variables['_first_name'] = this.stripEmoji(subProfile.firstName); + if (subProfile.lastName) variables['_last_name'] = this.stripEmoji(subProfile.lastName); + if (subProfile.username) variables['_username'] = subProfile.username; + if (subProfile.phone) variables['_phone'] = subProfile.phone; + if (subProfile.source) variables['_source'] = subProfile.source; + } + const ctx = this.buildContext(bot, scenario, subscriberId, chatId, variables); const text = message.text ?? ''; @@ -80,6 +93,8 @@ export class ScenarioEngineService { } const payload = text.replace('/start', '').trim(); + // Reset oneWay visited nodes on /start + ctx.variables['_visited_nodes'] = [] as unknown as string; // Allow direct node navigation (e.g. friendStartNode) const targetNode = payload && scenario.nodes[payload] ? payload : scenario.startNodeId; await this.executeFromNode(ctx, targetNode); @@ -174,6 +189,20 @@ export class ScenarioEngineService { return; } + // OneWay mode: prevent revisiting message/input nodes (action/condition always pass through) + if (ctx.scenario.oneWay && (node.type === 'message' || node.type === 'input')) { + const raw = ctx.variables['_visited_nodes']; + const visited: string[] = Array.isArray(raw) ? (raw as unknown as string[]) : []; + if (visited.includes(nodeId)) { + this.logger.log( + `OneWay: skipping already visited node "${nodeId}" for ${ctx.subscriberId}`, + ); + await this.saveState(ctx, nodeId); + return; + } + ctx.variables['_visited_nodes'] = [...visited, nodeId] as unknown as string; + } + switch (node.type) { case 'message': await this.executeMessage(ctx, node); @@ -226,6 +255,9 @@ export class ScenarioEngineService { } } + // Reply keyboard is now removed in handleInputResponse after successful input, + // so no need to remove it here. + // Resolve images array (backward compat: wrap legacy `image` string) const images: string[] = Array.isArray(node.data.images) ? node.data.images.filter(Boolean) @@ -358,6 +390,17 @@ export class ScenarioEngineService { break; case 'webhook': { + // Skip if oncePerUser and already sent + const actionAny = action as unknown as Record; + const urlValue = typeof actionAny.url === 'string' ? actionAny.url : ''; + const onceKey = actionAny.oncePerUser ? `_webhook_sent_${urlValue}` : null; + if (onceKey && ctx.variables[onceKey]) { + this.logger.log( + `Skipping webhook (already sent for this user): ${String(actionAny.url)}`, + ); + break; + } + const url = this.interpolate(action.url, ctx); const body: Record = action.body ? (JSON.parse(this.interpolate(JSON.stringify(action.body), ctx)) as Record< @@ -373,8 +416,13 @@ export class ScenarioEngineService { body, headers: action.headers ?? {}, } as Record, - { attempts: 3, backoff: { type: 'exponential', delay: 5000 } }, + { attempts: 1 }, ); + + // Mark as sent if oncePerUser + if (onceKey) { + ctx.variables[onceKey] = true; + } break; } @@ -523,22 +571,36 @@ export class ScenarioEngineService { node: InputNode, message: TgMessage, ): Promise { + // OneWay: ignore if user already completed this input AND advanced past it + // (currentNodeId != this node means they already submitted and moved on) + if (ctx.scenario.oneWay) { + const sub = await this.prisma.tgBotSubscriber.findUnique({ + where: { id: ctx.subscriberId }, + select: { currentNodeId: true }, + }); + if (sub && sub.currentNodeId !== node.id) { + this.logger.log( + `OneWay: ignoring repeat input for "${node.id}" (current: ${sub.currentNodeId}) from ${ctx.subscriberId}`, + ); + return; + } + } + let value: string; + let skipValidation = false; + if (node.data.inputType === 'phone' && message.contact?.phone_number) { value = message.contact.phone_number; - await this.subscribers.updatePhone(ctx.subscriberId, value); - await this.triggerReferralCheck(ctx); + skipValidation = true; } else if (node.data.inputType === 'phone' && message.text) { value = message.text; - await this.subscribers.updatePhone(ctx.subscriberId, value); - await this.triggerReferralCheck(ctx); } else { value = message.text ?? ''; } - // Validate - if (node.data.validation) { + // Validate (skip for contacts shared via Telegram button) + if (!skipValidation && node.data.validation) { const regex = new RegExp(node.data.validation); if (!regex.test(value)) { const errorMsg = node.data.errorMessage ?? 'Неверный формат. Попробуйте ещё раз.'; @@ -550,6 +612,24 @@ export class ScenarioEngineService { } } + // Remove reply keyboard IMMEDIATELY (user sees response fast) + if (node.data.requestContact) { + await this.telegramApi + .sendMessage(ctx.botToken, { + chat_id: ctx.chatId, + text: '✓', + reply_markup: this.telegramApi.removeKeyboard(), + }) + .catch(() => {}); + } + + // Save phone + trigger referral (after UI response) + if (node.data.inputType === 'phone') { + await this.subscribers.updatePhone(ctx.subscriberId, value); + // Fire and forget — don't block user flow + this.triggerReferralCheck(ctx).catch(() => {}); + } + // Save variable ctx.variables[node.data.saveAs] = value; @@ -630,6 +710,18 @@ export class ScenarioEngineService { } } + /** Strip emoji and non-BMP characters (Bitrix CRM may reject 4-byte UTF-8) */ + private stripEmoji(text: string): string { + // Strip in sequence to avoid combining-char issues in a single class. + const cleaned = text + .replace(/[\u{1F000}-\u{1FFFF}]/gu, '') + .replace(/[\u{2600}-\u{27BF}]/gu, '') + .replace(/\u{FE0F}/gu, '') + .replace(/\u{200D}/gu, '') + .trim(); + return cleaned || 'Друг'; // fallback if name was only emoji + } + /** Check if a Telegram API error indicates the user blocked/deactivated the bot */ private isBlockedError(err: unknown): boolean { const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase(); @@ -656,6 +748,12 @@ export class ScenarioEngineService { } } + // Add system variables for use in templates + variables['_chat_id'] = chatId; + variables['_subscriber_id'] = subscriberId; + variables['_bot_id'] = bot.id; + variables['_club_id'] = bot.clubId; + return { botId: bot.id, botToken: bot.token, @@ -722,7 +820,10 @@ export class ScenarioEngineService { private async triggerReferralCheck(ctx: EngineContext): Promise { try { const result = await this.referralService.onFriendRegistered( - ctx.botId, ctx.botToken, ctx.subscriberId, ctx.scenario, + ctx.botId, + ctx.botToken, + ctx.subscriberId, + ctx.scenario, ); if (result) { // Target reached — execute onReach node for the REFERRER (different user!) diff --git a/apps/web-club-admin/src/components/telegram-bot/flow-editor/step-editor.tsx b/apps/web-club-admin/src/components/telegram-bot/flow-editor/step-editor.tsx index 124c18f..68c3ab5 100644 --- a/apps/web-club-admin/src/components/telegram-bot/flow-editor/step-editor.tsx +++ b/apps/web-club-admin/src/components/telegram-bot/flow-editor/step-editor.tsx @@ -2107,6 +2107,20 @@ export function StepEditor({ scenario, onChange }: StepEditorProps) { {' | '} Старт: {scenario.startNodeId} + {/* Main Flow */}