diff --git a/backend/src/telegram.js b/backend/src/telegram.js new file mode 100644 index 0000000..886cd98 --- /dev/null +++ b/backend/src/telegram.js @@ -0,0 +1,196 @@ +import { Telegraf } from 'telegraf'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import { + createOperatorMessage, + getConversationWithVisitor, + getOperatorByTelegramUserId, + getTelegramSettings, + listActiveTelegramOperatorsForSite, + upsertTelegramOperator +} from './db.js'; + +let bot = null; +let botStatus = { + enabled: false, + running: false, + error: '', + username: '', + proxyEnabled: false, + startedAt: null +}; + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function buildProxyAgent(proxy) { + if (!proxy?.enabled) return null; + + const auth = proxy.username + ? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password || '')}@` + : ''; + + return new SocksProxyAgent(`socks5://${auth}${proxy.host}:${proxy.port}`); +} + +function buildTelegramOptions(settings) { + const agent = buildProxyAgent(settings.proxy); + if (!agent) return undefined; + + return { + telegram: { + agent + } + }; +} + +export function getTelegramBridgeStatus() { + return { + ...botStatus, + hasBot: Boolean(bot) + }; +} + +export async function startTelegramBridge({ logger } = {}) { + const settings = getTelegramSettings({ revealSecrets: true }); + const token = settings.botToken; + + botStatus = { + enabled: Boolean(token), + running: false, + error: '', + username: '', + proxyEnabled: Boolean(settings.proxy?.enabled), + startedAt: null + }; + + if (!token) { + logger?.warn?.('Telegram bridge skipped: bot token is empty'); + return botStatus; + } + + try { + bot = new Telegraf(token, buildTelegramOptions(settings)); + + bot.start(async (ctx) => { + const from = ctx.from || {}; + const operator = upsertTelegramOperator({ + telegramUserId: from.id, + telegramUsername: from.username || '', + name: [from.first_name, from.last_name].filter(Boolean).join(' ') || from.username || String(from.id) + }); + + await ctx.reply( + `✅ WSChat оператор подключён.\n\nID: ${operator.id}\nTelegram: ${from.username ? '@' + from.username : from.id}\n\nТеперь новые сообщения сайта будут приходить сюда.` + ); + }); + + bot.command('status', async (ctx) => { + const status = getTelegramBridgeStatus(); + await ctx.reply( + `WSChat bridge\n` + + `running: ${status.running ? 'yes' : 'no'}\n` + + `proxy: ${status.proxyEnabled ? 'enabled' : 'disabled'}\n` + + `bot: ${status.username || 'unknown'}` + ); + }); + + bot.on('text', async (ctx) => { + const text = ctx.message?.text || ''; + if (!text || text.startsWith('/')) return; + + const operator = getOperatorByTelegramUserId(ctx.from.id); + if (!operator) { + await ctx.reply('Сначала отправьте /start, чтобы зарегистрироваться оператором.'); + return; + } + + const replyTo = ctx.message?.reply_to_message; + const sourceText = replyTo?.text || replyTo?.caption || ''; + const match = sourceText.match(/Conversation:\s*(conv_[a-zA-Z0-9_-]+)/); + + if (!match) { + await ctx.reply('Ответьте реплаем на сообщение WSChat, чтобы отправить ответ посетителю.'); + return; + } + + const conversationId = match[1]; + const conversation = getConversationWithVisitor(conversationId); + if (!conversation) { + await ctx.reply('Диалог не найден. Возможно, он был удалён.'); + return; + } + + createOperatorMessage({ + conversationId, + siteId: conversation.site_id, + operatorId: operator.id, + body: text, + telegramMessageId: String(ctx.message.message_id) + }); + + await ctx.reply(`✅ Ответ сохранён для диалога ${conversationId}. Доставка в виджет будет включена на следующем шаге.`); + }); + + const me = await bot.telegram.getMe(); + await bot.launch(); + + botStatus = { + enabled: true, + running: true, + error: '', + username: me.username || '', + proxyEnabled: Boolean(settings.proxy?.enabled), + startedAt: new Date().toISOString() + }; + + logger?.info?.({ username: botStatus.username, proxyEnabled: botStatus.proxyEnabled }, 'Telegram bridge started'); + + process.once('SIGINT', () => bot?.stop('SIGINT')); + process.once('SIGTERM', () => bot?.stop('SIGTERM')); + + return botStatus; + } catch (error) { + botStatus = { + ...botStatus, + running: false, + error: error.message + }; + logger?.error?.(error, 'Telegram bridge failed to start'); + return botStatus; + } +} + +export async function notifyOperatorsAboutVisitorMessage({ site, visitor, conversation, message, logger } = {}) { + if (!botStatus.running || !bot) return { ok: false, sent: 0, error: 'telegram bridge is not running' }; + + const operators = listActiveTelegramOperatorsForSite(site.id); + if (operators.length === 0) return { ok: true, sent: 0, error: 'no active telegram operators' }; + + const text = [ + '💬 Новое сообщение WSChat', + '', + `Сайт: ${escapeHtml(site.domain || site.name || site.id)}`, + `Visitor: ${escapeHtml(visitor.visitor_key || visitor.id)}`, + `Conversation: ${escapeHtml(conversation.id)}`, + '', + escapeHtml(message.body), + '', + 'Ответьте реплаем на это сообщение, чтобы сохранить ответ оператором.' + ].join('\n'); + + let sent = 0; + for (const operator of operators) { + try { + await bot.telegram.sendMessage(operator.telegram_user_id, text, { parse_mode: 'HTML' }); + sent += 1; + } catch (error) { + logger?.warn?.({ operatorId: operator.id, error: error.message }, 'Failed to notify Telegram operator'); + } + } + + return { ok: true, sent }; +}