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) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-23 12:14:24 +00:00
parent ab7e666a4f
commit 3afdd04b3e
4 changed files with 148 additions and 10 deletions

View File

@@ -11,6 +11,8 @@ export interface BotScenario {
variables: Record<string, ScenarioVariable>;
/** All nodes keyed by node ID */
nodes: Record<string, ScenarioNode>;
/** OneWay mode: prevent users from revisiting already seen message/input nodes */
oneWay?: boolean;
}
export interface ScenarioVariable {

View File

@@ -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),
});

View File

@@ -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<string, unknown>;
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<string, unknown> = 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<string, unknown>,
{ 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<void> {
// 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<void> {
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!)

View File

@@ -2107,6 +2107,20 @@ export function StepEditor({ scenario, onChange }: StepEditorProps) {
{' | '}
Старт: <span className="font-mono text-gray-700">{scenario.startNodeId}</span>
</div>
<label
className="flex items-center gap-2 cursor-pointer"
title="Пользователь не может повторно проходить уже пройденные шаги"
>
<span className="text-xs text-gray-500">Один проход</span>
<input
type="checkbox"
checked={!!(scenario as unknown as Record<string, unknown>).oneWay}
onChange={(e) =>
onChange({ ...scenario, oneWay: e.target.checked } as unknown as ScenarioJson)
}
className="w-4 h-4 rounded border-gray-300 text-amber-500 focus:ring-amber-400"
/>
</label>
</div>
{/* Main Flow */}