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:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user