diff --git a/.gitignore b/.gitignore index 1fefaee..cb94a89 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ build/Release node_modules/ jspm_packages/ +# Prevent npm lock file (we use Yarn) +package-lock.json + # TypeScript cache *.tsbuildinfo diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3b8e742 --- /dev/null +++ b/.npmrc @@ -0,0 +1,9 @@ +# Force using Yarn instead of npm +# This file will cause npm commands to fail with an error +engine-strict=true + +# Prevent npm from creating package-lock.json +package-lock=false + +# Show a helpful error message +save-exact=true diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000..531af09 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,11 @@ +# Yarn configuration +# This ensures consistent behavior across environments + +# Use exact versions (no semver ranges) +save-exact true + +# Disable emoji in output (better for logs) +emoji false + +# Use network timeout for slow connections +network-timeout 300000 diff --git a/README.md b/README.md index 7f2f5b8..2065f46 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The Unthread Telegram Bot seamlessly connects your Telegram community with Unthread's powerful ticket management system. This official integration transforms how you handle support requests by enabling users to create and manage tickets directly within Telegram. -With simple commands and intuitive interactions, support tickets automatically sync between both platforms, streamlining your workflow and improving response times. Whether you're managing a community group, running a business chat, or supporting an open-source project, this bot provides the tools you need for efficient, organized customer support. +With simple commands and intuitive interactions, support tickets automatically sync between both platforms, streamlining your workflow and improving response times. Users receive real-time notifications when ticket status changes, ensuring they stay informed throughout the support process. Whether you're managing a community group, running a business chat, or supporting an open-source project, this bot provides the tools you need for efficient, organized customer support. ## πŸ’Έ Sponsored Ads @@ -19,22 +19,27 @@ Open source development is resource-intensive. These **sponsored ads help keep L The Unthread Telegram Bot creates a seamless bridge between your Telegram group chats and Unthread's ticket management system. Here's how it works: ### **πŸ“₯ Ticket Creation** + - Users in group chats can create support tickets using the `/support` command - The bot guides them through a simple conversation to collect issue summary and email (optional) - Tickets are automatically created in Unthread with proper customer and user association -### **πŸ”„ Bidirectional Communication** +### **πŸ”„ Bidirectional Communication** + - **Agent β†’ User**: When agents respond via the Unthread dashboard, messages are delivered to Telegram in real-time through webhook processing - **User β†’ Agent**: Users can simply reply to agent messages naturally - no special commands needed +- **Status Notifications**: Receive real-time notifications when ticket status changes (Open/Closed) with clear messaging and emoji indicators - **Conversation Flow**: Maintains complete conversation history across both platforms using message reply chains - **Webhook Server**: Powered by [`wgtechlabs/unthread-webhook-server`](https://github.com/wgtechlabs/unthread-webhook-server) which processes Unthread webhooks and queues events in Redis for real-time delivery ### **🏒 Smart Customer Management** + - Automatically extracts customer company names from group chat titles (e.g., "Company X Relay" β†’ "Company X") - Creates customers in Unthread with `[Telegram]` prefix for platform identification - Maps Telegram users to Unthread user profiles with fallback email generation ### **πŸ’Ύ Multi-Layer Storage** + - **Memory Layer** (24h): Fast access for active conversations - **Redis Layer** (3 days): Intermediate caching for recent activity - **PostgreSQL** (permanent): Long-term storage with full conversation history @@ -44,7 +49,8 @@ The Unthread Telegram Bot creates a seamless bridge between your Telegram group This bot works in conjunction with the [`wgtechlabs/unthread-webhook-server`](https://github.com/wgtechlabs/unthread-webhook-server) to enable real-time bidirectional communication. Here's how the complete system works: ### **πŸ—οΈ System Architecture** -``` + +```text β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Unthread β”‚ β”‚ Webhook β”‚ β”‚ Redis β”‚ β”‚ Telegram β”‚ β”‚ Dashboard │───▢│ Server │───▢│ Queue │───▢│ Bot β”‚ @@ -55,13 +61,16 @@ This bot works in conjunction with the [`wgtechlabs/unthread-webhook-server`](ht ``` ### **πŸ”„ Event Flow** + 1. **Agent responds** in Unthread dashboard to a ticket 2. **Unthread webhook** fires and sends event to the webhook server 3. **Webhook server** processes the event and queues it in Redis with proper formatting 4. **Telegram bot** polls the Redis queue and delivers the message to the appropriate group chat 5. **User replies** in Telegram, and the bot sends it back to Unthread API +6. **Status changes** (ticket closed/reopened) trigger real-time notifications to users ### **βš™οΈ Configuration Requirements** + - **Webhook Server**: Must be deployed separately to receive Unthread webhooks - **Shared Redis**: Both webhook server and bot must use the same Redis instance - **Queue Names**: Both webhook server and bot use the standard queue name `unthread-events` @@ -71,24 +80,30 @@ For webhook server setup instructions, see the [`wgtechlabs/unthread-webhook-ser ## ✨ Key Features ### **🎫 Seamless Ticket Management** + - Create support tickets directly from Telegram group chats with `/support` command - Interactive ticket creation with guided prompts for summary and email - Automatic ticket numbering and confirmation messages - Smart customer extraction from group chat names ### **πŸ’¬ Real-Time Bidirectional Communication** + - Agent responses from Unthread dashboard delivered instantly to Telegram - Users reply naturally to agent messages without special commands - Complete conversation history maintained across both platforms - Message reply chains preserve conversation context +- **Status Notifications**: Real-time alerts when tickets are opened or closed with emoji-rich formatting +- **Reaction-Based Feedback**: User messages are reacted to with ⏳ (sending) β†’ βœ… (sent) or ❌ (error) for clean, non-intrusive status updates ### **🏒 Enterprise-Ready Customer Management** + - Automatic customer creation with `[Telegram]` platform identification - Smart company name extraction from group chat titles (e.g., "Acme Corp x Support" β†’ "Acme Corp") - User profile mapping with automatic fallback email generation - Duplicate prevention for customers and users ### **πŸš€ Production-Grade Architecture** + - Multi-layer storage: Memory (24h) β†’ Redis (3d) β†’ PostgreSQL (permanent) - Webhook-based real-time event processing from Unthread via [`wgtechlabs/unthread-webhook-server`](https://github.com/wgtechlabs/unthread-webhook-server) - Redis queue system for reliable webhook event processing and delivery @@ -96,12 +111,14 @@ For webhook server setup instructions, see the [`wgtechlabs/unthread-webhook-ser - Comprehensive error handling and recovery mechanisms ### **⚑ Developer Experience** + - Built with modern ES6+ modules and async/await patterns - Structured logging with `@wgtechlabs/log-engine` integration - Auto-setup database schema on first run - Clean separation of concerns with SDK architecture ### **πŸ”§ Flexible Configuration** + - Environment variable based configuration - Support for both basic mode (ticket creation only) and full mode (with webhooks) - Configurable webhook polling intervals and queue names @@ -162,6 +179,7 @@ That's it! The database schema will be created automatically on first run. [![Deploy on Railway](https://railway.app/button.svg)](https://railway.com/u/warengonzaga) #### **🐳 Docker Support** + ```bash # Coming soon - Docker deployment support docker-compose up -d @@ -181,6 +199,7 @@ docker-compose up -d The bot provides several commands for users and administrators: #### **User Commands** + - `/start` - Welcome message and bot introduction - `/help` - Display available commands and usage instructions - `/support` - Create a new support ticket (group chats only) @@ -193,7 +212,7 @@ The bot provides several commands for users and administrators: 3. **Email (Optional)**: Provide email or skip for auto-generated one 4. **Confirmation**: Receive ticket number and confirmation message -``` +```text User: /support Bot: Let's create a support ticket. Please provide your issue summary: @@ -209,28 +228,36 @@ Bot: 🎫 Support Ticket Created Successfully! ### **Agent Workflow** #### **Receiving Tickets** + - New tickets appear in your Unthread dashboard - Customer name shows as `[Telegram] GroupChatName` - User information includes Telegram username and ID #### **Responding to Users** + - Reply to tickets in Unthread dashboard as normal - Messages are automatically delivered to the original Telegram group - Users receive agent responses in real-time #### **Ongoing Conversations** + - Users can reply directly to agent messages in Telegram - No special commands needed - natural conversation flow - All replies are automatically sent back to Unthread +- **Status Updates**: Users receive real-time notifications when tickets are closed (πŸ”’) or reopened (πŸ“‚) +- **Reply Status**: Message reactions show reply status (⏳ sending β†’ βœ… sent successfully / ❌ error) +- Status notifications include clear messaging about next steps and reply to original ticket messages ### **Group Chat Setup** #### **Adding the Bot** + 1. Add your bot to the desired Telegram group chat 2. Ensure the bot has permission to read and send messages 3. Group chat title should ideally include customer company name #### **Best Practices** + - Use descriptive group chat names (e.g., "Acme Corp Support", "ClientName x YourCompany") - The bot automatically extracts customer names from chat titles - Only group members can create support tickets (private chats are blocked) @@ -238,11 +265,13 @@ Bot: 🎫 Support Ticket Created Successfully! ### **Admin Features** #### **Customer Management** + - Customers are automatically created from group chat names - Duplicate prevention ensures one customer per chat - Customer names are prefixed with `[Telegram]` for easy identification #### **Conversation Tracking** + - Each ticket maintains complete conversation history - Reply chains preserve context across platforms - Message metadata includes user information and timestamps @@ -256,32 +285,39 @@ Bot: 🎫 Support Ticket Created Successfully! - **PostgreSQL 12+** (primary database) - **Redis 6+** (optional, for enhanced performance) +> **⚠️ Package Manager Notice:** This project enforces the use of Yarn and will prevent npm installation attempts. If you try to use `npm install`, you'll receive an error message with instructions to use Yarn instead. + ### **Step-by-Step Installation** #### **1. Clone Repository** + ```bash git clone https://github.com/wgtechlabs/unthread-telegram-bot.git cd unthread-telegram-bot ``` #### **2. Install Dependencies** + ```bash # Use Yarn only (npm not supported) yarn install ``` #### **3. Create Telegram Bot** + 1. Message [@BotFather](https://t.me/botfather) on Telegram 2. Create new bot with `/newbot` command 3. Save the bot token for environment configuration #### **4. Setup Unthread Integration** + 1. Log into your Unthread dashboard 2. Navigate to Settings β†’ API Keys 3. Generate a new API key 4. Find your channel ID in the dashboard URL #### **5. Database Setup** + ```bash # PostgreSQL (required) createdb unthread_telegram_bot @@ -291,6 +327,7 @@ createdb unthread_telegram_bot ``` #### **6. Environment Configuration** + ```bash # Copy example environment file cp .env.example .env @@ -300,6 +337,7 @@ nano .env ``` Required environment variables: + ```bash TELEGRAM_BOT_TOKEN=your_bot_token_from_botfather POSTGRES_URL=postgresql://user:password@localhost:5432/unthread_telegram_bot @@ -308,6 +346,7 @@ UNTHREAD_CHANNEL_ID=your_unthread_channel_id ``` Optional environment variables: + ```bash WEBHOOK_REDIS_URL=redis://localhost:6379 PLATFORM_REDIS_URL=redis://localhost:6379 @@ -316,6 +355,7 @@ WEBHOOK_POLL_INTERVAL=1000 ``` #### **7. Start the Bot** + ```bash # Development mode (with auto-restart) yarn dev @@ -327,8 +367,10 @@ yarn start ### **Verification** #### **Check Bot Status** + 1. Look for successful startup logs: - ``` + + ```text [INFO] Database initialized successfully [INFO] BotsStore initialized successfully [INFO] Bot initialized successfully @@ -343,12 +385,14 @@ yarn start #### **Troubleshooting** **Common Issues:** + - **Import errors**: Ensure you're using Yarn, not npm - **Database connection**: Verify PostgreSQL is running and connection string is correct - **Bot not responding**: Check bot token and ensure bot is added to group with proper permissions - **Webhook issues**: Verify Redis connection if using webhook features **Debug Mode:** + ```bash # Enable detailed logging NODE_ENV=development yarn start @@ -359,7 +403,7 @@ NODE_ENV=development yarn start Join our community discussions to get help, share ideas, and connect with other users: - πŸ“£ **[Announcements](https://github.com/wgtechlabs/unthread-telegram-bot/discussions/categories/announcements)**: Official updates from the maintainer -- πŸ“Έ **[Showcase](https://github.com/wgtechlabs/unthread-telegram-bot/discussions/categories/showcase)**: Show and tell your implementation +- πŸ“Έ **[Showcase](https://github.com/wgtechlabs/unthread-telegram-bot/discussions/categories/showcase)**: Show and tell your implementation - πŸ’– **[Wall of Love](https://github.com/wgtechlabs/unthread-telegram-bot/discussions/categories/wall-of-love)**: Share your experience with the bot - πŸ›Ÿ **[Help & Support](https://github.com/wgtechlabs/unthread-telegram-bot/discussions/categories/help-support)**: Get assistance from the community - 🧠 **[Ideas](https://github.com/wgtechlabs/unthread-telegram-bot/discussions/categories/ideas)**: Suggest new features and improvements diff --git a/package.json b/package.json index 6a9ec3c..88f70cf 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,25 @@ { "name": "unthread-telegram-bot", - "version": "0.1.0-alpha", + "version": "0.1.0-beta", "description": "A Telegram bot integrated with Unthread API featuring enhanced logging with @wgtechlabs/log-engine", - "main": "src/index.js", + "main": "dist/index.js", "type": "module", "private": true, "engines": { "node": ">=20.0.0", "yarn": ">=1.22.22" }, + "packageManager": "yarn@1.22.22", "scripts": { - "start": "node src/index.js", - "dev": "nodemon src/index.js" + "preinstall": "npx only-allow yarn", + "build": "tsc", + "start": "node dist/index.js", + "dev": "nodemon --exec 'npm run build && npm start' src/index.ts", + "dev:watch": "concurrently \"tsc --watch\" \"nodemon dist/index.js\"", + "clean": "rm -rf dist" }, "dependencies": { - "@wgtechlabs/log-engine": "^1.2.1", + "@wgtechlabs/log-engine": "^1.3.0", "dotenv": "^16.4.7", "node-fetch": "^3.3.2", "pg": "^8.16.0", @@ -22,13 +27,19 @@ "telegraf": "^4.0.0" }, "devDependencies": { - "nodemon": "^3.1.9" + "@types/node": "24.0.3", + "@types/pg": "8.15.4", + "concurrently": "9.1.2", + "nodemon": "^3.1.9", + "typescript": "5.8.3" }, "keywords": [ "telegram", "bot" ], "author": "Waren Gonzaga (https://warengonzaga.com)", - "license": "AGPL-3.0-only", - "packageManager": "yarn@1.22.22" + "contributors": [ + "WG Tech Labs (https://wgtechlabs.com)" + ], + "license": "AGPL-3.0-only" } diff --git a/src/bot.js b/src/bot.js deleted file mode 100644 index 5001008..0000000 --- a/src/bot.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Telegram Bot Utility Module - * - * This module provides utility functions for creating and configuring a Telegram bot - * using the Telegraf framework. It includes functions for bot initialization, command - * configuration, and bot startup. - * - * Potential Improvements: - * - Add error handling for bot operations - * - Implement middleware support for cross-cutting concerns - * - Add support for inline queries and callback queries - * - Add graceful shutdown mechanism - * - Add webhook support as an alternative to polling - */ -import { Telegraf, Markup } from 'telegraf'; - -/** - * Creates a new Telegram bot instance - * - * @param {string} token - The Telegram Bot API token - * @returns {Telegraf} A new Telegraf bot instance - * - * Possible Bugs: - * - No validation for the token parameter - * - No error handling if token is invalid - * - * Enhancement Opportunities: - * - Add token validation - * - Add bot configuration options parameter - * - Add session support initialization - */ -export function createBot(token) { - return new Telegraf(token); -} - -/** - * Configures the bot's command handlers - * - * @param {Telegraf} bot - The Telegraf bot instance - * @param {Array<{name: string, handler: Function}>} commands - Array of command objects with name and handler - * - * Possible Bugs: - * - No validation for the commands parameter - * - No error handling if a command handler throws an exception - * - * Enhancement Opportunities: - * - Add command descriptions for the /help menu - * - Add middleware support for commands - * - Add error handling for command execution - * - Support for command groups or categories - */ -export function configureCommands(bot, commands) { - commands.forEach(command => { - bot.command(command.name, command.handler); - }); -} - -/** - * Starts the bot polling for updates - * - * @param {Telegraf} bot - The Telegraf bot instance - * - * Possible Bugs: - * - No error handling for network issues - * - No retry mechanism for failed polling - * - * Enhancement Opportunities: - * - Add polling options parameter - * - Add graceful shutdown support - * - Add webhook support as an alternative to polling - * - Add status reporting and health check mechanism - * - Implement logging of bot startup - */ -export function startPolling(bot) { - bot.launch(); -} \ No newline at end of file diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..88433a0 --- /dev/null +++ b/src/bot.ts @@ -0,0 +1,317 @@ +/** + * Telegram Bot Utility Module + * + * This module provides utility functions for creating and configuring a Telegram bot + * using the Telegraf framework. It includes functions for bot initialization, command + * configuration, and bot startup. + */ +import { Telegraf, Markup } from 'telegraf'; +import type { ExtraReplyMessage, ExtraEditMessageText } from 'telegraf/typings/telegram-types'; +import { LogEngine } from '@wgtechlabs/log-engine'; +import { BotsStore } from './sdk/bots-brain/index.js'; +import { BotContext, TelegramError, CommandHandler } from './types/index.js'; + +/** + * Creates and returns a new Telegraf bot instance using the provided Telegram Bot API token. + * + * @param token - The Telegram Bot API token to authenticate the bot + * @returns The initialized Telegraf bot instance + * @throws If the token is missing or empty + */ +export function createBot(token: string): Telegraf { + if (!token) { + throw new Error('Telegram bot token is required'); + } + return new Telegraf(token); +} + +/** + * Registers command handlers on the bot for each specified command. + * + * @param commands - List of command definitions, each containing a command name and its handler function. + */ +export function configureCommands( + bot: Telegraf, + commands: Array<{ name: string; handler: CommandHandler }> +): void { + commands.forEach(command => { + bot.command(command.name, command.handler); + }); +} + +/** + * Starts the bot's polling mechanism to receive updates from Telegram. + * + * Initiates the process for the bot to listen for and handle incoming messages and events. + */ +export function startPolling(bot: Telegraf): void { + bot.launch(); +} + +/** + * Sends a message to a specified chat, handling common Telegram errors such as blocked users, chat not found, and rate limits. + * + * Attempts to send a message and performs cleanup if the bot is blocked or the chat does not exist. Returns null if sending fails due to these conditions or rate limiting; otherwise, returns the sent message object. + * + * @param chatId - The target chat ID + * @param text - The message text to send + * @param options - Optional parameters for message formatting and behavior + * @returns The sent message object, or null if the message could not be delivered due to blocking, chat not found, or rate limiting + */ +export async function safeSendMessage( + bot: Telegraf, + chatId: number, + text: string, + options: ExtraReplyMessage = {} +): Promise { + try { + return await bot.telegram.sendMessage(chatId, text, options); + } catch (error) { + const telegramError = error as TelegramError; + + if (telegramError.response?.error_code === 403) { + if (telegramError.response.description?.includes('bot was blocked by the user')) { + LogEngine.warn('Bot was blocked by user - cleaning up user data', { chatId }); + + // Clean up blocked user from storage + await cleanupBlockedUser(chatId); + + return null; + } + if (telegramError.response.description?.includes('chat not found')) { + LogEngine.warn('Chat not found - cleaning up chat data', { chatId }); + + // Clean up chat that no longer exists + await cleanupBlockedUser(chatId); + + return null; + } + } + + if (telegramError.response?.error_code === 429) { + LogEngine.warn('Rate limit exceeded when sending message', { + chatId, + retryAfter: telegramError.response.parameters?.retry_after + }); + return null; + } + + // For other errors, log and re-throw + LogEngine.error('Error sending message', { + error: telegramError.message, + chatId, + textLength: text?.length + }); + throw error; + } +} + +/** + * Replies to a message in the given context, handling errors such as blocked users, missing chats, and rate limits. + * + * If the bot is blocked or the chat is not found, associated user data is cleaned up and null is returned. If rate limits are exceeded, a warning is logged and null is returned. Other errors are logged and re-thrown. + * + * @param ctx - The Telegraf context for the incoming message + * @param text - The reply message text + * @param options - Optional parameters for the reply + * @returns The sent message object, or null if the reply could not be sent due to blocking, missing chat, or rate limiting + */ +export async function safeReply( + ctx: BotContext, + text: string, + options: ExtraReplyMessage = {} +): Promise { + try { + return await ctx.reply(text, options); + } catch (error) { + const telegramError = error as TelegramError; + + if (telegramError.response?.error_code === 403) { + if (telegramError.response.description?.includes('bot was blocked by the user')) { + LogEngine.warn('Bot was blocked by user during reply - cleaning up user data', { + chatId: ctx.chat?.id, + userId: ctx.from?.id + }); + + // Clean up blocked user from storage + if (ctx.chat?.id) { + await cleanupBlockedUser(ctx.chat.id); + } + + return null; + } + if (telegramError.response.description?.includes('chat not found')) { + LogEngine.warn('Chat not found during reply - cleaning up chat data', { + chatId: ctx.chat?.id + }); + + // Clean up chat that no longer exists + if (ctx.chat?.id) { + await cleanupBlockedUser(ctx.chat.id); + } + + return null; + } + } + + if (telegramError.response?.error_code === 429) { + LogEngine.warn('Rate limit exceeded during reply', { + chatId: ctx.chat?.id, + retryAfter: telegramError.response.parameters?.retry_after + }); + return null; + } + + // For other errors, log and re-throw + LogEngine.error('Error sending reply', { + error: telegramError.message, + chatId: ctx.chat?.id, + textLength: text?.length + }); + throw error; + } +} + +/** + * Attempts to edit the text of a Telegram message, handling common errors such as blocked users, missing chats, rate limits, and non-critical edit failures. + * + * If the bot is blocked or the chat is not found, associated user data is cleaned up and `null` is returned. Rate limit errors also result in `null`. If the message is not found or already modified, the error is logged and `null` is returned. Other errors are logged and re-thrown. + * + * @param ctx - The Telegraf context object + * @param chatId - The target chat ID + * @param messageId - The ID of the message to edit + * @param inlineMessageId - The inline message ID, if applicable + * @param text - The new text for the message + * @param options - Additional options for editing the message + * @returns The edited message object, or `null` if the operation fails due to handled errors + */ +export async function safeEditMessageText( + ctx: BotContext, + chatId: number, + messageId: number, + inlineMessageId: string | undefined, + text: string, + options: ExtraEditMessageText = {} +): Promise { + try { + return await ctx.telegram.editMessageText(chatId, messageId, inlineMessageId, text, options); + } catch (error) { + const telegramError = error as TelegramError; + + if (telegramError.response?.error_code === 403) { + if (telegramError.response.description?.includes('bot was blocked by the user')) { + LogEngine.warn('Bot was blocked by user during message edit - cleaning up user data', { + chatId, + messageId + }); + + // Clean up blocked user from storage + await cleanupBlockedUser(chatId); + return null; + } + if (telegramError.response.description?.includes('chat not found')) { + LogEngine.warn('Chat not found during message edit - cleaning up chat data', { + chatId, + messageId + }); + + // Clean up chat that no longer exists + await cleanupBlockedUser(chatId); + return null; + } + } + + if (telegramError.response?.error_code === 429) { + LogEngine.warn('Rate limit exceeded during message edit', { + chatId, + messageId, + retryAfter: telegramError.response.parameters?.retry_after + }); + return null; + } + + // For message not found or already edited, just log and continue + if (telegramError.response?.error_code === 400 && + (telegramError.response.description?.includes('message to edit not found') || + telegramError.response.description?.includes('message is not modified'))) { + LogEngine.debug('Message edit failed - message not found or already modified', { + chatId, + messageId + }); + return null; + } + + // For other errors, log and re-throw + LogEngine.error('Error editing message', { + error: telegramError.message, + chatId, + messageId, + textLength: text?.length + }); + throw error; + } +} + +/** + * Cleans up all local data associated with a chat when the bot is blocked or the chat is not found. + * + * Removes tickets and customer mappings related to the specified chat from persistent storage. User state data is not explicitly removed but will expire automatically. Errors during cleanup are logged but do not interrupt bot operation. + * + * @param chatId - The Telegram chat ID to clean up data for + */ +export async function cleanupBlockedUser(chatId: number): Promise { + try { + LogEngine.info('Starting cleanup for blocked user', { chatId }); + + // Get BotsStore instance + const botsStore = BotsStore.getInstance(); + + // 1. Get all tickets for this chat + const tickets = await botsStore.getTicketsForChat(chatId); + + if (tickets.length > 0) { + LogEngine.info(`Found ${tickets.length} tickets to clean up for blocked user`, { + chatId, + ticketIds: tickets.map((t: any) => t.conversationId) + }); + + // 2. Delete each ticket and its mappings + for (const ticket of tickets) { + await botsStore.deleteTicket(ticket.conversationId); + LogEngine.info(`Cleaned up ticket ${ticket.friendlyId} for blocked user`, { + chatId, + conversationId: ticket.conversationId + }); + } + } + + // 3. Clean up customer data for this chat + const customer = await botsStore.getCustomerByChatId(chatId); + if (customer) { + // Remove customer mappings (the customer still exists in Unthread, just remove local mappings) + await botsStore.storage.delete(`customer:telegram:${chatId}`); + await botsStore.storage.delete(`customer:id:${customer.unthreadCustomerId}`); + + LogEngine.info('Cleaned up customer mappings for blocked user', { + chatId, + customerId: customer.unthreadCustomerId + }); + } + + // 4. Clean up any user states + // Note: User states are keyed by telegram user ID, not chat ID + // So we can't clean them up directly without the user ID + // They will expire naturally due to TTL + + LogEngine.info('Successfully cleaned up blocked user data', { chatId }); + + } catch (error) { + const err = error as Error; + LogEngine.error('Error cleaning up blocked user data', { + error: err.message, + stack: err.stack, + chatId + }); + // Don't throw - cleanup failure shouldn't crash the bot + } +} diff --git a/src/commands/index.js b/src/commands/index.js deleted file mode 100644 index 1c5a79a..0000000 --- a/src/commands/index.js +++ /dev/null @@ -1,359 +0,0 @@ -/** - * Bot Commands Module - * - * This module defines command handlers for the Telegram bot. - * Each command is exported as a function that can be attached to the bot instance. - */ - -import packageJSON from '../../package.json' with { type: 'json' }; -import { LogEngine } from '@wgtechlabs/log-engine'; -import { Markup } from 'telegraf'; -import { BotsStore } from '../sdk/bots-brain/index.js'; -import * as unthreadService from '../services/unthread.js'; - -// Support form field enum -const SupportField = { - SUMMARY: 'summary', - EMAIL: 'email', - COMPLETE: 'complete' -}; - -/** - * Handler for the /start command - * - * This command welcomes the user and provides basic instructions. - * - * @param {object} ctx - The Telegraf context object - * - * Possible Bugs: - * - No personalization for different users - * - No tracking of new users - * - * Enhancement Opportunities: - * - Add personalized welcome message with user's name - * - Add onboarding flow for new users - * - Store user information for future interactions - * - Add rich media (images, buttons) to the welcome message - */ -const startCommand = (ctx) => { - ctx.reply('Welcome! Use /help to see available commands.'); -}; - -/** - * Handler for the /help command - * - * This command shows a list of available commands and their descriptions. - * - * @param {object} ctx - The Telegraf context object - * - * Possible Bugs: - * - Manual maintenance of command list can get out of sync with actual commands - * - No categorization or organization of commands - * - * Enhancement Opportunities: - * - Dynamically generate command list based on registered commands - * - Add command categories - * - Add examples of how to use commands - * - Add inline keyboard buttons for command selection - * - Add pagination for large command lists - */ -const helpCommand = (ctx) => { - ctx.reply('Available commands:\n/start - Start the bot\n/help - Show this help message\n/version - Show the bot version\n/support - Create a support ticket'); -}; - -/** - * Handler for the /version command - * - * This command shows the current version of the bot from package.json. - * - * @param {object} ctx - The Telegraf context object - * - * Possible Bugs: - * - Error handling if package.json doesn't exist or has no version - * - * Enhancement Opportunities: - * - Add more version-related information such as release date or changelog - * - Include git commit information if available - * - Add link to GitHub repository - */ -const versionCommand = (ctx) => { - try { - ctx.reply(`Bot version: ${packageJSON.version}`); - } catch (error) { - ctx.reply('Error retrieving version information.'); - LogEngine.error('Error in versionCommand', { - error: error.message, - stack: error.stack - }); - } -}; - -/** - * Initializes a support ticket conversation - * - * @param {object} ctx - The Telegraf context object - */ -const supportCommand = async (ctx) => { - try { - // Only allow support tickets in group chats - if (ctx.chat.type === 'private') { - await ctx.reply("Support tickets can only be created in group chats."); - return; - } - - // Initialize state for this user using BotsStore - const telegramUserId = ctx.from.id; - const userStateData = { - currentField: SupportField.SUMMARY, - ticket: { - summary: '', - email: '', - name: ctx.from.username || `${ctx.from.first_name} ${ctx.from.last_name || ''}`.trim(), - company: ctx.chat && ctx.chat.type !== 'private' ? ctx.chat.title : 'Individual Support', - chatId: ctx.chat.id - } - }; - - // Store user state using BotsStore - await BotsStore.setUserState(telegramUserId, userStateData); - - // Ask for the first field - await ctx.reply("Let's create a support ticket. Please provide your issue summary:"); - } catch (error) { - LogEngine.error('Error in supportCommand', { - error: error.message, - stack: error.stack, - telegramUserId: ctx.from?.id, - username: ctx.from?.username, - chatId: ctx.chat?.id, - chatType: ctx.chat?.type - }); - await ctx.reply("Sorry, there was an error starting the support ticket process. Please try again later."); - } -}; - -/** - * Processes a message in the context of an ongoing support ticket conversation - * - * @param {object} ctx - The Telegraf context object - * @returns {boolean} - True if the message was processed as part of a support conversation - */ -export const processSupportConversation = async (ctx) => { - try { - // Check if this user has an active support ticket conversation - const telegramUserId = ctx.from?.id; - if (!telegramUserId) { - return false; - } - - const userState = await BotsStore.getUserState(telegramUserId); - if (!userState) { - return false; - } - - // Handle callback queries (button clicks) - if (ctx.callbackQuery) { - if (ctx.callbackQuery.data === 'skip_email') { - if (userState.currentField === SupportField.EMAIL) { - // Process as if user typed "skip" - await handleEmailField(ctx, userState, 'skip'); - } - } - // Answer the callback query to remove the "loading" state of the button - await ctx.answerCbQuery(); - // Important: We need to return true here to indicate we handled the callback - return true; - } - - // Require text message for normal processing - if (!ctx.message?.text) { - return false; - } - - const messageText = ctx.message.text.trim(); - - // Handle commands in the middle of a conversation - if (messageText.startsWith('/')) { - // Allow /cancel to abort the process - if (messageText === '/cancel') { - await BotsStore.clearUserState(telegramUserId); - await ctx.reply("Support ticket creation cancelled."); - return true; - } - // Let other commands pass through - return false; - } - - // Update the current field and move to the next one - switch (userState.currentField) { - case SupportField.SUMMARY: - userState.ticket.summary = messageText; - userState.currentField = SupportField.EMAIL; - - // Update user state in BotsStore - await BotsStore.setUserState(telegramUserId, userState); - - // Ask for email with skip button - await ctx.reply( - "Please provide your email address or skip this step:", - Markup.inlineKeyboard([ - Markup.button.callback('Skip', 'skip_email') - ]) - ); - break; - - case SupportField.EMAIL: - await handleEmailField(ctx, userState, messageText); - break; - } - - // We handled this message as part of a support conversation - return true; - - } catch (error) { - LogEngine.error('Error in processSupportConversation', { - error: error.message, - stack: error.stack, - telegramUserId: ctx.from?.id, - username: ctx.from?.username, - chatId: ctx.chat?.id, - hasMessage: !!ctx.message, - isCallbackQuery: !!ctx.callbackQuery - }); - return false; - } -}; - -/** - * Handles the email field input and completes the ticket process - * - * @param {object} ctx - The Telegraf context object - * @param {object} userState - The user's conversation state - * @param {string} messageText - The text message from the user - */ -async function handleEmailField(ctx, userState, messageText) { - try { - const telegramUserId = ctx.from?.id; - - // Check if user wants to skip - if (messageText.toLowerCase() === 'skip') { - // Generate email in format {username_id@telegram.user} - const username = ctx.from.username || 'user'; - userState.ticket.email = `${username}_${telegramUserId}@telegram.user`; - } else { - userState.ticket.email = messageText; - } - - // Mark the ticket as complete - userState.currentField = SupportField.COMPLETE; - - // Get necessary information for ticket creation - const groupChatName = ctx.chat.title; - const username = ctx.from.username; - const summary = userState.ticket.summary; - - // Send a waiting message - const waitingMsg = await ctx.reply("Creating your support ticket... Please wait."); - - try { - // Step 1: Get or create customer for this group chat - const customerData = await unthreadService.getOrCreateCustomer(groupChatName, ctx.chat.id); - const customerId = customerData.id; - - // Step 2: Get or create user information - const userData = await unthreadService.getOrCreateUser(telegramUserId, username); - - // Step 3: Create a ticket with the customer ID and user data - const ticketResponse = await unthreadService.createTicket({ - groupChatName, - customerId, - summary, - onBehalfOf: userData - }); - - // Step 4: Generate success message with ticket ID - const ticketNumber = ticketResponse.friendlyId; - const ticketId = ticketResponse.id; - - // Create success message - const successMessage = `🎫 Support Ticket Created Successfully!\n\n` + - `Ticket #${ticketNumber}\n\n` + - `Your issue has been submitted and our team will be in touch soon. ` + - `Reply to this message to add more information to your ticket.`; - - // Send the success message - const confirmationMsg = await ctx.telegram.editMessageText( - ctx.chat.id, - waitingMsg.message_id, - null, - successMessage - ); - - // Register this confirmation message so we can track replies to it - await unthreadService.registerTicketConfirmation({ - messageId: confirmationMsg.message_id, - ticketId: ticketId, - friendlyId: ticketNumber, - customerId: customerId, - chatId: ctx.chat.id, - telegramUserId: telegramUserId - }); - - // Log successful ticket creation - LogEngine.info('Support ticket created successfully', { - ticketNumber, - ticketId, - customerId, - telegramUserId, - username, - groupChatName, - email: userState.ticket.email, - summaryLength: summary?.length - }); - - } catch (error) { - // Handle API errors - LogEngine.error('Error creating support ticket', { - error: error.message, - stack: error.stack, - groupChatName, - telegramUserId, - username, - summaryLength: summary?.length - }); - - // Update the waiting message with an error - await ctx.telegram.editMessageText( - ctx.chat.id, - waitingMsg.message_id, - null, - `⚠️ Error creating support ticket: ${error.message}. Please try again later.` - ); - } - - // Clear the user's state using BotsStore - if (telegramUserId) { - await BotsStore.clearUserState(telegramUserId); - } - } catch (error) { - LogEngine.error('Error in handleEmailField', { - error: error.message, - stack: error.stack, - telegramUserId: ctx.from?.id, - username: ctx.from?.username, - chatId: ctx.chat?.id, - messageText: messageText?.substring(0, 100) // Log first 100 chars for context - }); - await ctx.reply("Sorry, there was an error processing your support ticket. Please try again later."); - - // Clean up user state using BotsStore - await BotsStore.clearUserState(ctx.from?.id); - } -} - -export { - startCommand, - helpCommand, - versionCommand, - supportCommand, -}; \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..e1e1818 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,770 @@ +/** + * Bot Commands Module + * + * This module defines command handlers for the Telegram bot. + * Each command is exported as a function that can be attached to the bot instance. + */ + +import packageJSON from '../../package.json' with { type: 'json' }; +import { LogEngine } from '@wgtechlabs/log-engine'; +import { Markup } from 'telegraf'; +import * as unthreadService from '../services/unthread.js'; +import { safeReply, safeEditMessageText } from '../bot.js'; +import type { BotContext, SupportField, SupportFormState } from '../types/index.js'; +import { BotsStore } from '../sdk/bots-brain/index.js'; + +// Support form field enum +const SupportFieldEnum = { + SUMMARY: 'summary' as const, + EMAIL: 'email' as const, + COMPLETE: 'complete' as const +}; + +/** + * Handler for the /start command + * + * This command welcomes the user and provides different information based on chat type. + * For private chats, it shows bot information. For group chats, it provides support instructions. + */ +const startCommand = async (ctx: BotContext): Promise => { + if (ctx.chat?.type === 'private') { + // Private chat - show bot information + const botInfo = `πŸ€– **Unthread Support Bot** + +**Version:** ${packageJSON.version} +**Developer:** ${packageJSON.author} +**License:** ${packageJSON.license} + +**About:** +This bot is designed to help you create support tickets in group chats. It integrates with Unthread to streamline your customer support workflow. + +**How to use:** +β€’ Add this bot to your support group chat +β€’ Use \`/support\` command in the group to create tickets +β€’ The bot will guide you through the ticket creation process + +**Links:** +β€’ πŸ“š Documentation: [GitHub Repository](https://github.com/wgtechlabs/unthread-telegram-bot) +β€’ πŸ› Report Issues: [GitHub Issues](https://github.com/wgtechlabs/unthread-telegram-bot/issues) +β€’ πŸ’¬ Support: Contact through group chat where this bot is added + +**Note:** Support ticket creation is only available in group chats, not in private messages.`; + + await safeReply(ctx, botInfo, { parse_mode: 'Markdown' }); + } else { + // Group chat - show support instructions + await safeReply(ctx, `Welcome to the support bot! 🎫 + +Use \`/support\` to create a new support ticket. +Use \`/help\` to see all available commands.`, { parse_mode: 'Markdown' }); + } +}; + +/** + * Handler for the /help command + * + * This command shows available commands and usage information for both private and group chats. + */ +const helpCommand = async (ctx: BotContext): Promise => { + const helpText = `πŸ€– **Available Commands:** + +β€’ \`/start\` - Welcome message and instructions +β€’ \`/help\` - Show this help message +β€’ \`/version\` - Show bot version information +β€’ \`/about\` - Show comprehensive bot information +β€’ \`/support\` - Create a new support ticket +β€’ \`/cancel\` - Cancel ongoing support ticket creation +β€’ \`/reset\` - Reset your support conversation state + +**How to create a support ticket:** +1. Use \`/support\` command in a group chat +2. Provide your issue summary when prompted +3. Provide your email address when prompted +4. The bot will create a ticket and notify you + +**Note:** Support tickets can only be created in group chats.`; + + await safeReply(ctx, helpText, { parse_mode: 'Markdown' }); +}; + +/** + * Handler for the /version command + * + * This command shows the current version of the bot and additional information. + */ +const versionCommand = async (ctx: BotContext): Promise => { + try { + const versionInfo = `πŸ’œ **Unthread Telegram Bot** +*Ticketing support for customers and partnersβ€”right in Telegram.* + +**Version:** ${packageJSON.version} +**Developer:** Waren Gonzaga, WG Technology Labs +**License:** ${packageJSON.license} + +**Repository:** [GitHub](https://github.com/wgtechlabs/unthread-telegram-bot) +**Report Issues:** [GitHub Issues](https://github.com/wgtechlabs/unthread-telegram-bot/issues) + +**Runtime Information:** +β€’ Node.js Version: ${process.version} +β€’ Platform: ${process.platform} +β€’ Bot Name: ${packageJSON.name}`; + + await safeReply(ctx, versionInfo, { parse_mode: 'Markdown' }); + } catch (error) { + const err = error as Error; + await safeReply(ctx, 'Error retrieving version information.'); + LogEngine.error('Error in versionCommand', { + error: err.message, + stack: err.stack + }); + } +}; + +/** + * Handler for the /about command + * + * This command shows comprehensive information about the Unthread Telegram Bot. + */ +const aboutCommand = async (ctx: BotContext): Promise => { + try { + const aboutText = `πŸ’œ **Unthread Telegram Bot** +*Ticketing support for customers and partnersβ€”right in Telegram.* + +**Version:** ${packageJSON.version} +**Developer:** Waren Gonzaga, WG Technology Labs +**License:** AGPL-3.0-only + +**Overview:** +Enable customers and business partners to open support tickets directly within Telegram group chats. This bot connects to your Unthread dashboard, ensuring real-time updates, threaded discussions, and smooth issue tracking. + +**How it works:** + +1. Add to a Telegram group +2. Run \`/support\` +3. Follow the guided prompts + +**Links:** +β€’ [GitHub Repo](https://github.com/wgtechlabs/unthread-telegram-bot) +β€’ [Issue Tracker](https://github.com/wgtechlabs/unthread-telegram-bot/issues) + +⚠️ **Group chats only** β€” DMs not supported.`; + + await safeReply(ctx, aboutText, { parse_mode: 'Markdown' }); + } catch (error) { + const err = error as Error; + await safeReply(ctx, 'Error retrieving about information.'); + LogEngine.error('Error in aboutCommand', { + error: err.message, + stack: err.stack + }); + } +}; + +/** + * Initializes a support ticket conversation + */ +const supportCommand = async (ctx: BotContext): Promise => { + try { + // Only allow support tickets in group chats + if (ctx.chat?.type === 'private') { + const privateMessage = `❌ **Support tickets can only be created in group chats.** + +🎫 **How to get support:** +1. Add this bot to your support group chat +2. Use \`/support\` command in the group chat +3. Follow the prompts to create your ticket + +**Why group chats only?** +This bot is designed for team-based customer support workflows where multiple team members can collaborate on tickets. + +**Need help?** +β€’ πŸ“š Documentation: [GitHub Repository](https://github.com/wgtechlabs/unthread-telegram-bot) +β€’ πŸ› Report Issues: [GitHub Issues](https://github.com/wgtechlabs/unthread-telegram-bot/issues)`; + + await safeReply(ctx, privateMessage, { parse_mode: 'Markdown' }); + return; + } + + if (!ctx.from || !ctx.chat) { + await safeReply(ctx, "❌ Error: Unable to identify user or chat."); + return; + } + + // Initialize state for this user using BotsStore + const telegramUserId = ctx.from.id; + const chatTitle = 'title' in ctx.chat ? ctx.chat.title : 'Group Chat'; + const userStateData: SupportFormState & { ticket: any; messageIds?: number[] } = { + field: SupportFieldEnum.SUMMARY as SupportField, + initiatedBy: telegramUserId, // Track who initiated the support request + initiatedInChat: ctx.chat.id, // Track which chat the support was initiated in + messageIds: [], // Store message IDs to edit later + ticket: { + summary: '', + email: '', + name: ctx.from.username || `${ctx.from.first_name} ${ctx.from.last_name || ''}`.trim(), + company: chatTitle, + chatId: ctx.chat.id + } + }; + + // Store user state using BotsStore + await BotsStore.setUserState(telegramUserId, userStateData); + + // Ask for the first field and store the message ID + const summaryMessage = await safeReply(ctx, `🎫 **Let's create a support ticket!**\n\n${ctx.from.first_name || ctx.from.username}, please provide a brief summary of your issue:`, { parse_mode: 'HTML' }); + + // Store the message ID for later editing + if (summaryMessage) { + userStateData.messageIds = [summaryMessage.message_id]; + await BotsStore.setUserState(telegramUserId, userStateData); + } + } catch (error) { + const err = error as Error; + LogEngine.error('Error in supportCommand', { + error: err.message, + stack: err.stack, + telegramUserId: ctx.from?.id, + username: ctx.from?.username, + chatId: ctx.chat?.id, + chatType: ctx.chat?.type + }); + await safeReply(ctx, "❌ Sorry, there was an error starting the support ticket process. Please try again later."); + } +}; + +/** + * Processes a message in the context of an ongoing support ticket conversation + * + * @returns True if the message was processed as part of a support conversation + */ +export const processSupportConversation = async (ctx: BotContext): Promise => { + try { + // Check if this user has an active support ticket conversation + const telegramUserId = ctx.from?.id; + if (!telegramUserId) { + LogEngine.debug('No telegram user ID found in processSupportConversation'); + return false; + } + + LogEngine.debug('processSupportConversation called', { + telegramUserId, + chatId: ctx.chat?.id, + hasMessage: !!ctx.message, + messageText: ctx.message && 'text' in ctx.message ? ctx.message.text : 'no text' + }); + + const userState = await BotsStore.getUserState(telegramUserId); + + LogEngine.debug('User state retrieval result', { + telegramUserId, + hasUserState: !!userState, + userState: userState ? JSON.stringify(userState) : 'null' + }); + + if (!userState) { + LogEngine.debug('No user state found, returning false'); + return false; + } + + LogEngine.debug('Found active support conversation', { + telegramUserId, + currentField: userState.currentField || userState.field, + chatId: ctx.chat?.id + }); + + // Check if there's any active support conversation in this chat + const chatId = ctx.chat?.id; + if (chatId && !userState) { + // No active support conversation for this user + return false; + } + + // Check if this message is from the user who initiated the support request + // and in the same chat where it was initiated + if (userState && userState.initiatedBy && userState.initiatedBy !== telegramUserId) { + // This message is from a different user, ignore it silently + LogEngine.debug('Ignoring message from different user during support flow', { + messageFrom: telegramUserId, + supportInitiatedBy: userState.initiatedBy, + chatId: ctx.chat?.id + }); + return false; + } + + if (userState && userState.initiatedInChat && userState.initiatedInChat !== ctx.chat?.id) { + // This message is from a different chat, ignore it + LogEngine.debug('Ignoring message from different chat during support flow', { + messageFromChat: ctx.chat?.id, + supportInitiatedInChat: userState.initiatedInChat, + userId: telegramUserId + }); + return false; + } + + // Debug logging to understand the current state + LogEngine.info('Found active support conversation state', { + telegramUserId, + currentField: userState.currentField || userState.field, + hasTicket: !!userState.ticket, + chatType: ctx.chat?.type, + chatId: ctx.chat?.id + }); + + // Handle callback queries (button clicks) + if (ctx.callbackQuery) { + if ('data' in ctx.callbackQuery) { + const callbackData = ctx.callbackQuery.data; + + if (callbackData === 'skip_email') { + if ((userState.currentField || userState.field) === SupportFieldEnum.EMAIL) { + // Edit the message to remove buttons first + if (ctx.callbackQuery && 'message' in ctx.callbackQuery && ctx.callbackQuery.message) { + await safeEditMessageText( + ctx, + ctx.chat!.id, + ctx.callbackQuery.message.message_id, + undefined, + `πŸ“§ **Email skipped** - We'll use an auto-generated email for your ticket.`, + { parse_mode: 'Markdown' } + ); + } + + // Process as if user typed "skip" + await handleEmailField(ctx, userState, 'skip'); + } + } else if (callbackData === 'confirm_summary') { + // User confirmed the summary, move to email field + userState.currentField = SupportFieldEnum.EMAIL; + userState.field = SupportFieldEnum.EMAIL; + + // Update user state in BotsStore + await BotsStore.setUserState(telegramUserId, userState); + + // Edit the confirmation message to remove buttons and show confirmation + if (ctx.callbackQuery && 'message' in ctx.callbackQuery && ctx.callbackQuery.message) { + await safeEditMessageText( + ctx, + ctx.chat!.id, + ctx.callbackQuery.message.message_id, + undefined, + `βœ… **Summary Confirmed!**\n\n━━━━━━━━━━━━━━\n"${userState.ticket.summary}"\n━━━━━━━━━━━━━━`, + { parse_mode: 'Markdown' } + ); + } + + // Ask for email with skip button + await safeReply(ctx, + "πŸ“§ **Now let's get your contact information.**\n\nPlease provide your email address or skip this step:", + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + Markup.button.callback('Skip Email', 'skip_email') + ]) + } + ); + } else if (callbackData === 'revise_summary') { + // User wants to revise the summary, ask again + + // Edit the confirmation message to remove buttons + if (ctx.callbackQuery && 'message' in ctx.callbackQuery && ctx.callbackQuery.message) { + await safeEditMessageText( + ctx, + ctx.chat!.id, + ctx.callbackQuery.message.message_id, + undefined, + `πŸ“ **Please provide a revised description of your issue.**\n\nInclude any additional details that might help our team understand and resolve your problem:`, + { parse_mode: 'Markdown' } + ); + } + + // Clear the existing summary so they can provide a new one + userState.ticket.summary = ''; + // Reset the field to SUMMARY so user can provide a new summary + userState.currentField = SupportFieldEnum.SUMMARY; + userState.field = SupportFieldEnum.SUMMARY; + await BotsStore.setUserState(telegramUserId, userState); + } + } + // Answer the callback query to remove the "loading" state of the button + await ctx.answerCbQuery(); + // Important: We need to return true here to indicate we handled the callback + return true; + } + + // If we reach here but there's no message (e.g., callback query without message), return false + if (!ctx.message) { + return false; + } + + // Require text message for normal processing + if (!('text' in ctx.message) || !ctx.message.text) { + return false; + } + + const messageText = ctx.message.text.trim(); + + // Handle commands in the middle of a conversation + if (messageText.startsWith('/')) { + // Allow /cancel to abort the process + if (messageText === '/cancel') { + await BotsStore.clearUserState(telegramUserId); + await safeReply(ctx, "Support ticket creation cancelled."); + return true; + } + // Let other commands pass through + return false; + } + + // Update the current field and move to the next one + const currentField = userState.currentField || userState.field; + + switch (currentField) { + case SupportFieldEnum.SUMMARY: { + // Check if user already provided a summary and is waiting for confirmation + if (userState.ticket.summary && userState.ticket.summary.trim() !== '') { + // Check if user is trying to confirm or revise via text + const lowerText = messageText.toLowerCase().trim(); + if (lowerText === 'confirm' || lowerText === 'yes' || lowerText === 'proceed') { + // User confirmed via text, move to email field + userState.currentField = SupportFieldEnum.EMAIL; + userState.field = SupportFieldEnum.EMAIL; + + await BotsStore.setUserState(telegramUserId, userState); + + const emailMessage = await safeReply(ctx, + "πŸ“§ **Now let's get your contact information.**\n\nPlease provide your email address or skip this step:", + { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + Markup.button.callback('Skip Email', 'skip_email') + ]) + } + ); + + // Store the email message ID for later editing + if (emailMessage && userState.messageIds) { + userState.messageIds.push(emailMessage.message_id); + await BotsStore.setUserState(telegramUserId, userState); + } + return true; + } else if (lowerText === 'revise' || lowerText === 'no' || lowerText === 'edit') { + // User wants to revise via text + await safeReply(ctx, + "πŸ“ **Please provide a revised description of your issue.**\n\nInclude any additional details that might help our team understand and resolve your problem:", + { parse_mode: 'Markdown' } + ); + // Clear the existing summary so they can provide a new one + userState.ticket.summary = ''; + await BotsStore.setUserState(telegramUserId, userState); + return true; + } else { + // User provided a new description, replace the old one + userState.ticket.summary = messageText; + } + } else { + // First time providing summary + userState.ticket.summary = messageText; + } + + // Show confirmation message with the summary and ask for confirmation + const confirmationMessage = `πŸ“‹ **Ticket Summary Preview:**\n\n` + + `━━━━━━━━━━━━━━\n` + + `"${userState.ticket.summary}"\n` + + `━━━━━━━━━━━━━━\n\n` + + `❓ **Is this description complete?**\n\n` + + `β€’ **Yes**: Proceed to the next step\n` + + `β€’ **No**: Revise your description`; + + const confirmationReply = await safeReply(ctx, confirmationMessage, { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + [ + Markup.button.callback('βœ… Yes, complete', 'confirm_summary'), + Markup.button.callback('πŸ“ No, revise', 'revise_summary') + ] + ]) + }); + + // Store the confirmation message ID for later editing + if (confirmationReply && userState.messageIds) { + userState.messageIds.push(confirmationReply.message_id); + } + + // Update user state but don't change field yet - wait for confirmation + await BotsStore.setUserState(telegramUserId, userState); + break; + } + + case SupportFieldEnum.EMAIL: { + await handleEmailField(ctx, userState, messageText); + break; + } + } + + // We handled this message as part of a support conversation + return true; + + } catch (error) { + const err = error as Error; + LogEngine.error('Error in processSupportConversation', { + error: err.message, + stack: err.stack, + telegramUserId: ctx.from?.id, + username: ctx.from?.username, + chatId: ctx.chat?.id, + hasMessage: !!ctx.message, + isCallbackQuery: !!ctx.callbackQuery + }); + return false; + } +}; + +/** + * Processes the email input step of the support ticket conversation and completes ticket creation. + * + * If the user enters "skip", an auto-generated email is used. The function then finalizes the ticket by interacting with external services to create the customer, user, and ticket records, updates the user with confirmation or error messages, and clears the user's conversation state. + */ +async function handleEmailField(ctx: BotContext, userState: any, messageText: string): Promise { + try { + const telegramUserId = ctx.from?.id; + if (!telegramUserId || !ctx.chat) { + return; + } + + // Check if user wants to skip + if (messageText.toLowerCase() === 'skip') { + // Generate email in format {username_id@telegram.user} + const username = ctx.from?.username || 'user'; + userState.ticket.email = `${username}_${telegramUserId}@telegram.user`; + } else { + userState.ticket.email = messageText; + } + + // Mark the ticket as complete + userState.currentField = SupportFieldEnum.COMPLETE; + userState.field = SupportFieldEnum.COMPLETE; + + // Get necessary information for ticket creation + const groupChatName = 'title' in ctx.chat ? ctx.chat.title : 'Group Chat'; + const username = ctx.from?.username; + const summary = userState.ticket.summary; + + // Send a waiting message + const waitingMsg = await safeReply(ctx, "Creating your support ticket... Please wait."); + if (!waitingMsg) { + return; + } + + try { + // Step 1: Get or create customer for this group chat + const customerData = await unthreadService.getOrCreateCustomer(groupChatName, ctx.chat.id); + const customerId = customerData.id; + + // Step 2: Get or create user information + const userData = await unthreadService.getOrCreateUser(telegramUserId, username); + + // Step 3: Create a ticket with the customer ID and user data + const ticketResponse = await unthreadService.createTicket({ + groupChatName, + customerId, + summary, + onBehalfOf: userData + }); + + // Step 4: Generate success message with ticket ID + const ticketNumber = ticketResponse.friendlyId; + const ticketId = ticketResponse.id; + + // Create success message with user identification and summary + const userName = ctx.from?.first_name || ctx.from?.username || 'User'; + const successMessage = `πŸ“‹ **Support Ticket Created Successfully!**\n\n` + + `**Ticket #${ticketNumber}**\n` + + `**Started By:** ${userName}\n\n` + + `${summary}\n\n` + + `Your issue has been submitted and our team will be in touch soon.\n\n` + + `πŸ’¬ **Reply to this message** to add more information to your ticket.`; + + // Send the success message + const confirmationMsg = await safeEditMessageText( + ctx, + ctx.chat.id, + waitingMsg.message_id, + undefined, + successMessage, + { parse_mode: 'Markdown' } + ); + + if (confirmationMsg) { + // Register this confirmation message so we can track replies to it + await unthreadService.registerTicketConfirmation({ + messageId: confirmationMsg.message_id, + ticketId: ticketId, + friendlyId: ticketNumber, + customerId: customerId, + chatId: ctx.chat.id, + telegramUserId: telegramUserId + }); + + // Clean up previous messages to reduce clutter + if (userState.messageIds && userState.messageIds.length > 0) { + for (const messageId of userState.messageIds) { + try { + await ctx.telegram.editMessageText( + ctx.chat.id, + messageId, + undefined, + "βœ… _Support ticket creation completed._", + { parse_mode: 'Markdown' } + ); + } catch (error) { + // Ignore errors when editing messages (they might be deleted or too old) + LogEngine.debug('Could not edit previous message', { + messageId, + error: (error as Error).message + }); + } + } + } + } + + // Log successful ticket creation + LogEngine.info('Support ticket created successfully', { + ticketNumber, + ticketId, + customerId, + telegramUserId, + username, + groupChatName, + email: userState.ticket.email, + summaryLength: summary?.length + }); + + } catch (error) { + const err = error as Error; + // Handle API errors + LogEngine.error('Error creating support ticket', { + error: err.message, + stack: err.stack, + groupChatName, + telegramUserId, + username, + summaryLength: summary?.length + }); + + // Update the waiting message with an error + await safeEditMessageText( + ctx, + ctx.chat.id, + waitingMsg.message_id, + undefined, + `⚠️ Error creating support ticket: ${err.message}. Please try again later.` + ); + } + + // Clear the user's state using BotsStore + if (telegramUserId) { + await BotsStore.clearUserState(telegramUserId); + } + } catch (error) { + const err = error as Error; + LogEngine.error('Error in handleEmailField', { + error: err.message, + stack: err.stack, + telegramUserId: ctx.from?.id, + username: ctx.from?.username, + chatId: ctx.chat?.id, + messageText: messageText?.substring(0, 100) // Log first 100 chars for context + }); + await safeReply(ctx, "Sorry, there was an error processing your support ticket. Please try again later."); + + // Clean up user state using BotsStore + if (ctx.from?.id) { + await BotsStore.clearUserState(ctx.from.id); + } + } +} + +/** + * Handler for the /cancel command + * + * This command cancels any ongoing support ticket creation process. + */ +const cancelCommand = async (ctx: BotContext): Promise => { + try { + const telegramUserId = ctx.from?.id; + if (!telegramUserId) { + await safeReply(ctx, "Unable to process cancel request."); + return; + } + + // Check if user has an active support ticket conversation + const userState = await BotsStore.getUserState(telegramUserId); + + if (!userState) { + await safeReply(ctx, "❌ No active support ticket creation process to cancel."); + return; + } + + // Clear the user's state + await BotsStore.clearUserState(telegramUserId); + + await safeReply(ctx, "βœ… Support ticket creation has been cancelled."); + + LogEngine.info('Support ticket creation cancelled by user', { + telegramUserId, + username: ctx.from?.username, + chatId: ctx.chat?.id, + currentField: userState.currentField || userState.field + }); + + } catch (error) { + const err = error as Error; + LogEngine.error('Error in cancelCommand', { + error: err.message, + stack: err.stack, + telegramUserId: ctx.from?.id, + username: ctx.from?.username, + chatId: ctx.chat?.id + }); + await safeReply(ctx, "Sorry, there was an error cancelling the support ticket process."); + } +}; + +/** + * Resets the user's support conversation state (for debugging) + */ +const resetCommand = async (ctx: BotContext): Promise => { + try { + const telegramUserId = ctx.from?.id; + if (!telegramUserId) { + await safeReply(ctx, "Error: Unable to identify user."); + return; + } + + const userState = await BotsStore.getUserState(telegramUserId); + if (userState) { + await BotsStore.clearUserState(telegramUserId); + await safeReply(ctx, "βœ… Your support conversation state has been reset."); + LogEngine.info('User state cleared via reset command', { telegramUserId }); + } else { + await safeReply(ctx, "ℹ️ No active support conversation state found."); + } + } catch (error) { + const err = error as Error; + LogEngine.error('Error in resetCommand', { + error: err.message, + telegramUserId: ctx.from?.id + }); + await safeReply(ctx, "❌ Error resetting state. Please try again."); + } +}; + +export { + startCommand, + helpCommand, + versionCommand, + aboutCommand, + supportCommand, + cancelCommand, + resetCommand +}; diff --git a/src/database/connection.js b/src/database/connection.ts similarity index 63% rename from src/database/connection.js rename to src/database/connection.ts index 1e93548..5eedfce 100644 --- a/src/database/connection.js +++ b/src/database/connection.ts @@ -3,10 +3,17 @@ * * Provides PostgreSQL database connection with SSL support for Railway * and other cloud providers that require secure connections. + * + * Security Features: + * - SSL certificate validation enabled by default in production + * - Configurable SSL validation for development environments + * - Support for custom CA certificates via DATABASE_SSL_CA environment variable + * - Environment-aware SSL configuration to prevent MITM attacks */ import pkg from 'pg'; const { Pool } = pkg; +import type { Pool as PoolType, PoolClient, QueryResult } from 'pg'; import { LogEngine } from '@wgtechlabs/log-engine'; import dotenv from 'dotenv'; import fs from 'fs'; @@ -25,6 +32,8 @@ const __dirname = path.dirname(__filename); * Handles PostgreSQL connections with SSL support and connection pooling */ export class DatabaseConnection { + private pool: PoolType; + constructor() { // Validate required environment variable if (!process.env.POSTGRES_URL) { @@ -33,19 +42,21 @@ export class DatabaseConnection { throw new Error(error); } + // Configure SSL based on environment + const isProduction = process.env.NODE_ENV === 'production'; + const sslConfig = this.getSSLConfig(isProduction); + // Configure connection pool with SSL for Railway this.pool = new Pool({ connectionString: process.env.POSTGRES_URL, - ssl: { - rejectUnauthorized: false // Required for Railway and most cloud providers - }, + ssl: sslConfig, max: 10, // Maximum number of connections in pool idleTimeoutMillis: 30000, // Close idle connections after 30 seconds connectionTimeoutMillis: 10000, // Return error after 10 seconds if connection cannot be established }); // Handle pool errors - this.pool.on('error', (err) => { + this.pool.on('error', (err: Error) => { LogEngine.error('Unexpected error on idle client', { error: err.message, stack: err.stack @@ -55,19 +66,29 @@ export class DatabaseConnection { LogEngine.info('Database connection pool initialized', { maxConnections: 10, sslEnabled: true, + sslValidation: isProduction ? 'enabled' : (process.env.DATABASE_SSL_VALIDATE === 'true' ? 'enabled' : 'disabled'), + environment: process.env.NODE_ENV || 'development', provider: 'Railway' }); } + /** + * Get the database connection pool + * @returns The PostgreSQL connection pool + */ + get connectionPool(): PoolType { + return this.pool; + } + /** * Execute a database query * - * @param {string} text - SQL query string - * @param {Array} params - Query parameters - * @returns {Promise} Query result + * @param text - SQL query string + * @param params - Query parameters + * @returns Query result */ - async query(text, params = []) { - const client = await this.pool.connect(); + async query(text: string, params: any[] = []): Promise> { + const client: PoolClient = await this.pool.connect(); try { const start = Date.now(); const result = await client.query(text, params); @@ -82,11 +103,12 @@ export class DatabaseConnection { return result; } catch (error) { + const err = error as Error; LogEngine.error('Database query error', { - error: error.message, + error: err.message, query: text.substring(0, 100) + (text.length > 100 ? '...' : ''), paramCount: params.length, - stack: error.stack + stack: err.stack }); throw error; } finally { @@ -96,15 +118,13 @@ export class DatabaseConnection { /** * Test database connection and create schema if needed - * - * @returns {Promise} */ - async connect() { + async connect(): Promise { try { // Test connection const result = await this.query('SELECT NOW() as current_time'); LogEngine.info('Database connection established', { - currentTime: result.rows[0].current_time, + currentTime: result.rows[0]?.current_time, ssl: 'enabled' }); @@ -112,9 +132,10 @@ export class DatabaseConnection { await this.ensureSchema(); } catch (error) { + const err = error as Error; LogEngine.error('Failed to connect to database', { - error: error.message, - stack: error.stack, + error: err.message, + stack: err.stack, postgresUrl: process.env.POSTGRES_URL ? 'configured' : 'missing' }); throw error; @@ -123,10 +144,8 @@ export class DatabaseConnection { /** * Ensure database schema exists (Alpha version - auto-setup always) - * - * @returns {Promise} */ - async ensureSchema() { + async ensureSchema(): Promise { try { // Check if tables exist const tableCheck = await this.query(` @@ -137,7 +156,7 @@ export class DatabaseConnection { `); const requiredTables = ['customers', 'tickets', 'user_states']; - const foundTables = tableCheck.rows.map(row => row.table_name); + const foundTables = tableCheck.rows.map((row: any) => row.table_name); const missingTables = requiredTables.filter(table => !foundTables.includes(table)); if (missingTables.length > 0) { @@ -152,9 +171,10 @@ export class DatabaseConnection { }); } } catch (error) { + const err = error as Error; LogEngine.error('Error checking database schema', { - error: error.message, - stack: error.stack + error: err.message, + stack: err.stack }); throw error; } @@ -162,22 +182,22 @@ export class DatabaseConnection { /** * Initialize database schema from schema.sql file - * - * @returns {Promise} */ - async initializeSchema() { + async initializeSchema(): Promise { try { LogEngine.info('Starting database schema initialization...'); const schemaPath = path.join(__dirname, 'schema.sql'); - // Check if schema file exists - if (!fs.existsSync(schemaPath)) { + // Check if schema file exists asynchronously + try { + await fs.promises.access(schemaPath, fs.constants.F_OK); + } catch (accessError) { throw new Error(`Schema file not found: ${schemaPath}`); } - // Read schema file - const schema = fs.readFileSync(schemaPath, 'utf8'); + // Read schema file asynchronously + const schema = await fs.promises.readFile(schemaPath, 'utf8'); LogEngine.debug('Schema file loaded', { path: schemaPath, size: schema.length @@ -188,55 +208,55 @@ export class DatabaseConnection { LogEngine.info('Database schema created successfully'); } catch (error) { + const err = error as Error; LogEngine.error('Failed to initialize database schema', { - error: error.message, - stack: error.stack + error: err.message, + stack: err.stack }); throw error; } } /** - * Execute a transaction - * - * @param {Function} callback - Function that receives client and executes queries - * @returns {Promise} Transaction result + * Close all connections in the pool */ - async transaction(callback) { - const client = await this.pool.connect(); + async close(): Promise { try { - await client.query('BEGIN'); - const result = await callback(client); - await client.query('COMMIT'); - return result; + await this.pool.end(); + LogEngine.info('Database connection pool closed'); } catch (error) { - await client.query('ROLLBACK'); - LogEngine.error('Transaction failed and rolled back', { - error: error.message, - stack: error.stack + const err = error as Error; + LogEngine.error('Error closing database pool', { + error: err.message, + stack: err.stack }); throw error; - } finally { - client.release(); } } /** - * Close all connections in the pool - * - * @returns {Promise} + * Configure SSL settings based on environment + * @param isProduction - Whether running in production environment + * @returns SSL configuration object */ - async close() { - try { - await this.pool.end(); - LogEngine.info('Database connection pool closed'); - } catch (error) { - LogEngine.error('Error closing database pool', { - error: error.message, - stack: error.stack - }); - throw error; + private getSSLConfig(isProduction: boolean): any { + // In production, always validate SSL certificates for security + if (isProduction) { + return { + rejectUnauthorized: true, + // Allow custom CA certificate if provided + ca: process.env.DATABASE_SSL_CA || undefined + }; } + + // In development, allow flexibility for local development + // Check if explicit SSL validation is requested via environment variable + const forceSSLValidation = process.env.DATABASE_SSL_VALIDATE === 'true'; + + return { + rejectUnauthorized: forceSSLValidation, + ca: process.env.DATABASE_SSL_CA || undefined + }; } } diff --git a/src/events/message.js b/src/events/message.js deleted file mode 100644 index d6e3205..0000000 --- a/src/events/message.js +++ /dev/null @@ -1,379 +0,0 @@ -/** - * Message Event Handlers Module - * - * This module provides handlers for different types of Telegram messages. - * It includes functionality to detect and respond to messages from different - * chat types (private, group, supergroup, channel) and pattern matching for text messages. - * - * Potential Improvements: - * - Add more message type handlers (photos, documents, etc.) - * - Implement rate limiting for message handlers - * - Add user tracking/analytics - * - Implement conversation flows - * - Add priority mechanism for overlapping patterns - */ - -import { LogEngine } from '@wgtechlabs/log-engine'; -import { processSupportConversation } from '../commands/index.js'; -import * as unthreadService from '../services/unthread.js'; - -/** - * Checks if a message is from a group chat (not a channel) - * - * @param {object} ctx - The Telegraf context object - * @returns {boolean} True if the message is from a group chat, false otherwise - */ -export function isGroupChat(ctx) { - return ctx.chat && (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'); -} - -/** - * Checks if a message is from a private chat - * - * @param {object} ctx - The Telegraf context object - * @returns {boolean} True if the message is from a private chat, false otherwise - */ -export function isPrivateChat(ctx) { - return ctx.chat && ctx.chat.type === 'private'; -} - -/** - * Handles all incoming messages - * - * This function routes messages to appropriate handlers based on chat type - * and processes text messages against registered patterns - * - * @param {object} ctx - The Telegraf context object - * @param {Function} next - The next middleware function - */ -export async function handleMessage(ctx, next) { - try { - // Skip if there's no message or chat - if (!ctx.message || !ctx.chat) { - return await next(); - } - - // Log basic information about the message - LogEngine.debug('Processing message', { - chatType: ctx.chat.type, - chatId: ctx.chat.id, - userId: ctx.from?.id - }); - - // Check if this is part of a support conversation - const isSupportMessage = await processSupportConversation(ctx); - if (isSupportMessage) { - // Skip other handlers if this was a support conversation message - return await next(); - } - - // Check if this is a reply to a ticket confirmation - if (ctx.message.reply_to_message && ctx.message.text) { - const handled = await handleTicketReply(ctx); - if (handled) { - // Skip other handlers if this was a ticket reply - return await next(); - } - } - - // Handle different chat types - if (isPrivateChat(ctx)) { - await handlePrivateMessage(ctx); - } else if (isGroupChat(ctx)) { - await handleGroupMessage(ctx); - } - - // Continue processing with other handlers - return await next(); - } catch (error) { - LogEngine.error(`Error handling message: ${error.message}`); - return await next(); - } -} - -/** - * Handles replies to ticket confirmation messages - * - * @param {object} ctx - The Telegraf context object - * @returns {boolean} - True if the message was processed as a ticket reply - */ -async function handleTicketReply(ctx) { - try { - // Get the ID of the message being replied to - const replyToMessageId = ctx.message.reply_to_message.message_id; - - LogEngine.info('Processing potential ticket reply', { - replyToMessageId, - messageText: ctx.message.text?.substring(0, 100), - chatId: ctx.chat.id, - userId: ctx.from.id - }); - - // Check if this is a reply to a ticket confirmation - const ticketInfo = await unthreadService.getTicketFromReply(replyToMessageId); - if (ticketInfo) { - LogEngine.info('Found ticket for reply', { - ticketId: ticketInfo.ticketId, - friendlyId: ticketInfo.friendlyId, - replyToMessageId - }); - return await handleTicketConfirmationReply(ctx, ticketInfo); - } - - // Check if this is a reply to an agent message - const agentMessageInfo = await unthreadService.getAgentMessageFromReply(replyToMessageId); - if (agentMessageInfo) { - LogEngine.info('Found agent message for reply', { - conversationId: agentMessageInfo.conversationId, - friendlyId: agentMessageInfo.friendlyId, - replyToMessageId - }); - return await handleAgentMessageReply(ctx, agentMessageInfo); - } - - LogEngine.debug('No ticket or agent message found for reply', { - replyToMessageId, - chatId: ctx.chat.id - }); - - return false; - } catch (error) { - LogEngine.error('Error in handleTicketReply', { - error: error.message, - chatId: ctx.chat?.id - }); - return false; - } -} - -/** - * Handles replies to ticket confirmation messages - * - * @param {object} ctx - The Telegraf context object - * @param {object} ticketInfo - The ticket information - * @returns {boolean} - True if the message was processed - */ -async function handleTicketConfirmationReply(ctx, ticketInfo) { - try { - - // This is a reply to a ticket confirmation, send it to Unthread - const telegramUserId = ctx.from.id; - const username = ctx.from.username; - const message = ctx.message.text; - - LogEngine.info('Processing ticket confirmation reply', { - conversationId: ticketInfo.conversationId, - ticketId: ticketInfo.ticketId, - friendlyId: ticketInfo.friendlyId, - telegramUserId, - username, - messageLength: message?.length - }); - - // Send a waiting message - const waitingMsg = await ctx.reply("Adding your message to the ticket...", { - reply_to_message_id: ctx.message.message_id - }); - - try { - // Get user information from database - const userData = await unthreadService.getOrCreateUser(telegramUserId, username); - - LogEngine.info('Retrieved user data for ticket reply', { - userData: JSON.stringify(userData), - hasName: !!userData.name, - hasEmail: !!userData.email - }); - - // Send the message to the ticket using conversationId (which is the same as ticketId) - await unthreadService.sendMessage({ - conversationId: ticketInfo.conversationId || ticketInfo.ticketId, - message, - onBehalfOf: userData - }); - - // Update the waiting message with success - await ctx.telegram.editMessageText( - ctx.chat.id, - waitingMsg.message_id, - null, - `βœ… Your message has been added to Ticket #${ticketInfo.friendlyId}` - ); - - LogEngine.info('Added message to ticket', { - ticketNumber: ticketInfo.friendlyId, - conversationId: ticketInfo.conversationId || ticketInfo.ticketId, - telegramUserId, - username, - messageLength: message?.length, - chatId: ctx.chat.id - }); - return true; - - } catch (error) { - // Handle API errors - LogEngine.error('Error adding message to ticket', { - error: error.message, - stack: error.stack, - conversationId: ticketInfo.conversationId || ticketInfo.ticketId, - telegramUserId, - username - }); - - // Update the waiting message with error - await ctx.telegram.editMessageText( - ctx.chat.id, - waitingMsg.message_id, - null, - `⚠️ Error adding message to ticket: ${error.message}` - ); - - return true; - } - - } catch (error) { - LogEngine.error('Error in handleTicketReply', { - error: error.message, - stack: error.stack, - chatId: ctx.chat?.id - }); - return false; - } -} - -/** - * Handles replies to agent messages - * - * @param {object} ctx - The Telegraf context object - * @param {object} agentMessageInfo - The agent message information - * @returns {boolean} - True if the message was processed - */ -async function handleAgentMessageReply(ctx, agentMessageInfo) { - try { - // This is a reply to an agent message, send it back to Unthread - const telegramUserId = ctx.from.id; - const username = ctx.from.username; - const message = ctx.message.text; - - // Send a waiting message - const waitingMsg = await ctx.reply("Sending your reply to the agent...", { - reply_to_message_id: ctx.message.message_id - }); - - try { - // Get user information for proper onBehalfOf formatting - const userData = await unthreadService.getOrCreateUser(telegramUserId, username); - - // Send the message to the conversation - await unthreadService.sendMessage({ - conversationId: agentMessageInfo.conversationId, - message, - onBehalfOf: userData - }); - - // Update the waiting message with success - await ctx.telegram.editMessageText( - ctx.chat.id, - waitingMsg.message_id, - null, - `βœ… Your reply has been sent to the agent for Ticket #${agentMessageInfo.friendlyId}` - ); - - // Auto-delete the confirmation message after 1 minute - setTimeout(() => { - ctx.telegram.deleteMessage(ctx.chat.id, waitingMsg.message_id).catch(() => {}); - }, 5000); // 5,000 ms = 5 seconds - - LogEngine.info('Sent reply to agent', { - ticketNumber: agentMessageInfo.friendlyId, - conversationId: agentMessageInfo.conversationId, - telegramUserId, - username, - messageLength: message?.length, - chatId: ctx.chat.id - }); - return true; - - } catch (error) { - // Handle API errors - LogEngine.error('Error sending reply to agent', { - error: error.message, - conversationId: agentMessageInfo.conversationId - }); - - // Update the waiting message with error - await ctx.telegram.editMessageText( - ctx.chat.id, - waitingMsg.message_id, - null, - `⚠️ Error sending reply to agent: ${error.message}` - ); - - return true; - } - - } catch (error) { - LogEngine.error('Error in handleAgentMessageReply', { - error: error.message, - conversationId: agentMessageInfo?.conversationId - }); - return false; - } -} - -/** - * Handles messages from private chats (direct messages to the bot) - * - * @param {object} ctx - The Telegraf context object - */ -export async function handlePrivateMessage(ctx) { - try { - // Log information about the private message - LogEngine.info('Processing private message', { - telegramUserId: ctx.from?.id, - username: ctx.from?.username, - firstName: ctx.from?.first_name, - lastName: ctx.from?.last_name, - messageId: ctx.message?.message_id - }); - - // Inform the user that the bot doesn't support private conversations - await ctx.reply("Sorry, this bot does not have feature to assist you via Telegram DM"); - - } catch (error) { - LogEngine.error('Error in handlePrivateMessage', { - error: error.message, - stack: error.stack, - telegramUserId: ctx.from?.id, - username: ctx.from?.username - }); - } -} - -/** - * Handles messages from group chats - * - * @param {object} ctx - The Telegraf context object - */ -export async function handleGroupMessage(ctx) { - try { - // Log more detailed information about the group message - LogEngine.info(`Processing message from group: ${ctx.chat.title} (ID: ${ctx.chat.id})`); - - // Additional information about the sender if available - if (ctx.from) { - LogEngine.info(`Message sent by: ${ctx.from.first_name} ${ctx.from.last_name || ''} (ID: ${ctx.from.id})`); - } - - // Messages that reach here are general group messages that don't require special handling - // Ticket replies and agent message replies are handled by handleTicketReply function - LogEngine.debug('General group message - no special action needed', { - messageId: ctx.message?.message_id, - hasReply: !!ctx.message?.reply_to_message, - replyToId: ctx.message?.reply_to_message?.message_id - }); - - } catch (error) { - LogEngine.error(`Error in handleGroupMessage: ${error.message}`); - } -} \ No newline at end of file diff --git a/src/events/message.ts b/src/events/message.ts new file mode 100644 index 0000000..83045a8 --- /dev/null +++ b/src/events/message.ts @@ -0,0 +1,496 @@ +/** + * Message Event Handlers Module + * + * This module provides handlers for different types of Telegram messages. + * It implements functionality to detect and respond to messages from different + * chat types (private, group, supergroup, channel) and pattern matching for text messages. + */ + +import { LogEngine } from '@wgtechlabs/log-engine'; +import { processSupportConversation, aboutCommand } from '../commands/index.js'; +import * as unthreadService from '../services/unthread.js'; +import { safeReply, safeEditMessageText } from '../bot.js'; +import type { BotContext } from '../types/index.js'; + +/** + * Returns true if the message originates from a group or supergroup chat. + * + * @returns True if the chat type is 'group' or 'supergroup'; otherwise, false. + */ +export function isGroupChat(ctx: BotContext): boolean { + return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'; +} + +/** + * Determines whether the current chat is a private chat. + * + * @returns True if the chat type is 'private'; otherwise, false. + */ +export function isPrivateChat(ctx: BotContext): boolean { + return ctx.chat?.type === 'private'; +} + +/** + * Main handler for incoming Telegram messages, routing them to appropriate processors based on chat type and message context. + * + * Determines whether to process the message as a command, support conversation, ticket reply, private chat, or group chat, and delegates handling accordingly. Prevents automatic responses in group chats and ensures that only relevant handlers are invoked for each message type. + */ +export async function handleMessage(ctx: BotContext, next: () => Promise): Promise { + try { + // Skip if there's no message or chat + if (!ctx.message || !ctx.chat) { + return await next(); + } + + // Log basic information about the message + LogEngine.debug('Processing message', { + chatType: ctx.chat.type, + chatId: ctx.chat.id, + userId: ctx.from?.id, + messageText: 'text' in ctx.message ? ctx.message.text?.substring(0, 50) : undefined, + isCommand: 'text' in ctx.message ? ctx.message.text?.startsWith('/') : false, + hasFromUser: !!ctx.from, + messageType: 'text' in ctx.message ? 'text' : 'other' + }); + + // If this is a command, let Telegraf handle it and don't process further + if ('text' in ctx.message && ctx.message.text?.startsWith('/')) { + LogEngine.debug('Command detected, passing to command handlers', { + command: ctx.message.text, + chatType: ctx.chat.type + }); + return; // Don't call next() for commands, let Telegraf handle them + } + + // Check if this is part of a support conversation (BEFORE handling different chat types) + const isSupportMessage = await processSupportConversation(ctx); + + if (isSupportMessage) { + // Skip other handlers if this was a support conversation message + LogEngine.debug('Message processed as support conversation'); + return; // Don't call next() for support messages, we're done + } + + // Check if this is a reply to a ticket confirmation + if ('reply_to_message' in ctx.message && ctx.message.reply_to_message && 'text' in ctx.message && ctx.message.text) { + const handled = await handleTicketReply(ctx); + if (handled) { + // Skip other handlers if this was a ticket reply + LogEngine.debug('Message processed as ticket reply'); + return; // Don't call next() for ticket replies, we're done + } + } + + // Handle different chat types + if (isPrivateChat(ctx)) { + LogEngine.debug('Processing as private message'); + await handlePrivateMessage(ctx); + } else if (isGroupChat(ctx)) { + LogEngine.debug('Processing as group message - NO AUTO RESPONSES'); + await handleGroupMessage(ctx); + // For group chats, DO NOT continue processing to prevent auto-responses + LogEngine.debug('Stopping processing for group message to prevent auto-responses'); + return; + } + + // Continue processing with other handlers (only for private chats) + return await next(); + } catch (error) { + const err = error as Error; + LogEngine.error(`Error handling message: ${err.message}`); + return await next(); + } +} + +/** + * Processes replies to ticket confirmation or agent messages and routes them for handling. + * + * Checks if the incoming message is a reply to a ticket confirmation or agent message, and if so, processes the reply accordingly. Returns true if the reply was handled, or false otherwise. + * + * @returns True if the reply was processed as a ticket or agent message reply; false otherwise. + */ +async function handleTicketReply(ctx: BotContext): Promise { + try { + if (!ctx.message || !('reply_to_message' in ctx.message) || !ctx.message.reply_to_message) { + return false; + } + + // Get the ID of the message being replied to + const replyToMessageId = ctx.message.reply_to_message.message_id; + + LogEngine.info('Processing potential ticket reply', { + replyToMessageId, + messageText: 'text' in ctx.message ? ctx.message.text?.substring(0, 100) : undefined, + chatId: ctx.chat?.id, + userId: ctx.from?.id + }); + + // Check if this is a reply to a ticket confirmation + const ticketInfo = await unthreadService.getTicketFromReply(replyToMessageId); + if (ticketInfo) { + LogEngine.info('Found ticket for reply', { + ticketId: ticketInfo.ticketId, + friendlyId: ticketInfo.friendlyId, + replyToMessageId + }); + return await handleTicketConfirmationReply(ctx, ticketInfo); + } + + // Check if this is a reply to an agent message + const agentMessageInfo = await unthreadService.getAgentMessageFromReply(replyToMessageId); + if (agentMessageInfo) { + LogEngine.info('Found agent message for reply', { + conversationId: agentMessageInfo.conversationId, + friendlyId: agentMessageInfo.friendlyId, + replyToMessageId + }); + return await handleAgentMessageReply(ctx, agentMessageInfo); + } + + LogEngine.debug('No ticket or agent message found for reply', { + replyToMessageId, + chatId: ctx.chat?.id + }); + + return false; + } catch (error) { + const err = error as Error; + LogEngine.error('Error in handleTicketReply', { + error: err.message, + chatId: ctx.chat?.id + }); + return false; + } +} + +/** + * Processes a reply to a ticket confirmation message by validating the reply, sending the message to the ticket conversation, and updating the user with a status message. + * + * @param ctx - The Telegram bot context for the incoming message + * @param ticketInfo - Information about the ticket to which the reply is associated + * @returns True if the reply was processed (successfully or with an error status message), or false if validation failed or an unexpected error occurred + */ +async function handleTicketConfirmationReply(ctx: BotContext, ticketInfo: any): Promise { + try { + // Validate the reply context and ticket information + const validation = await validateTicketReply(ctx, ticketInfo); + if (!validation.isValid) { + return false; + } + + const { telegramUserId, username, message } = validation; + + // Send a minimal status message + const statusMsg = await safeReply(ctx, '⏳ Adding to ticket...', { + reply_parameters: { message_id: ctx.message!.message_id } + }); + + if (!statusMsg) { + return false; + } + + try { + // Process and send the ticket message to Unthread + await processTicketMessage(ticketInfo, telegramUserId, username, message); + + // Update status message to success + await updateStatusMessage(ctx, statusMsg, true); + return true; + + } catch (error) { + const err = error as Error; + // Handle API errors + LogEngine.error('Error adding message to ticket', { + error: err.message, + stack: err.stack, + conversationId: ticketInfo.conversationId || ticketInfo.ticketId, + telegramUserId, + username + }); + + // Update status message to error + await updateStatusMessage(ctx, statusMsg, false); + return true; + } + + } catch (error) { + const err = error as Error; + LogEngine.error('Error in handleTicketReply', { + error: err.message, + stack: err.stack, + chatId: ctx.chat?.id + }); + return false; + } +} + +/** + * Validates that the reply message and sender information are present and extracts user and message details for ticket processing. + * + * @returns An object indicating whether the reply is valid. If valid, includes the sender's Telegram user ID, username, and message text. + */ +async function validateTicketReply(ctx: BotContext, ticketInfo: any): Promise<{ isValid: false } | { isValid: true; telegramUserId: number; username: string | undefined; message: string }> { + if (!ctx.from || !ctx.message || !('text' in ctx.message)) { + return { isValid: false }; + } + + const telegramUserId = ctx.from.id; + const username = ctx.from.username; + const message = ctx.message.text || ''; + + LogEngine.info('Processing ticket confirmation reply', { + conversationId: ticketInfo.conversationId, + ticketId: ticketInfo.ticketId, + friendlyId: ticketInfo.friendlyId, + telegramUserId, + username, + messageLength: message?.length + }); + + return { + isValid: true, + telegramUserId, + username, + message + }; +} + +/** + * Sends a user's message to the specified ticket conversation in Unthread. + * + * Retrieves or creates user data based on the Telegram user ID and username, then sends the provided message to the ticket conversation identified by the ticket information. + */ +async function processTicketMessage(ticketInfo: any, telegramUserId: number, username: string | undefined, message: string): Promise { + // Get user information from database + const userData = await unthreadService.getOrCreateUser(telegramUserId, username); + + LogEngine.info('Retrieved user data for ticket reply', { + userData: JSON.stringify(userData), + hasName: !!userData.name, + hasEmail: !!userData.email + }); + + // Send the message to the ticket using conversationId (which is the same as ticketId) + await unthreadService.sendMessage({ + conversationId: ticketInfo.conversationId || ticketInfo.ticketId, + message: message || 'No message content', + onBehalfOf: userData + }); + + LogEngine.info('Added message to ticket', { + ticketNumber: ticketInfo.friendlyId, + conversationId: ticketInfo.conversationId || ticketInfo.ticketId, + telegramUserId, + username, + messageLength: message?.length + }); +} + +/** + * Updates a status message to indicate success or error, then deletes it after a short delay. + * + * The message is updated to show a checkmark for success or an error icon for failure, and is automatically removed after 3 seconds (success) or 5 seconds (error). + */ +async function updateStatusMessage(ctx: BotContext, statusMsg: any, isSuccess: boolean): Promise { + if (isSuccess) { + // Update status message to success + await safeEditMessageText( + ctx, + ctx.chat!.id, + statusMsg.message_id, + undefined, + 'βœ… Added!' + ); + + // Delete status message after 3 seconds + setTimeout(() => { + ctx.telegram.deleteMessage(ctx.chat!.id, statusMsg.message_id).catch(() => {}); + }, 3000); + } else { + // Update status message to error + await safeEditMessageText( + ctx, + ctx.chat!.id, + statusMsg.message_id, + undefined, + '❌ Error!' + ); + + // Delete status message after 5 seconds + setTimeout(() => { + ctx.telegram.deleteMessage(ctx.chat!.id, statusMsg.message_id).catch(() => {}); + }, 5000); + } +} + +/** + * Processes a user's reply to an agent message by forwarding it to the corresponding Unthread conversation. + * + * Sends a status message indicating progress, updates it upon success or error, and deletes the status message after a delay. Returns true if the reply was processed, false otherwise. + * + * @returns True if the reply was handled (successfully sent or error occurred), false if the context was invalid. + */ +async function handleAgentMessageReply(ctx: BotContext, agentMessageInfo: any): Promise { + try { + if (!ctx.from || !ctx.message || !('text' in ctx.message)) { + return false; + } + + // This is a reply to an agent message, send it back to Unthread + const telegramUserId = ctx.from.id; + const username = ctx.from.username; + const message = ctx.message.text || ''; + + // Send a minimal status message + const statusMsg = await safeReply(ctx, '⏳ Sending...', { + reply_parameters: { message_id: ctx.message.message_id } + }); + + if (!statusMsg) { + return false; + } + + try { + // Get user information for proper onBehalfOf formatting + const userData = await unthreadService.getOrCreateUser(telegramUserId, username); + + // Send the message to the conversation + await unthreadService.sendMessage({ + conversationId: agentMessageInfo.conversationId, + message: message || 'No message content', + onBehalfOf: userData + }); + + // Update status message to success + await safeEditMessageText( + ctx, + ctx.chat!.id, + statusMsg.message_id, + undefined, + 'βœ… Sent!' + ); + + // Delete status message after 3 seconds + setTimeout(() => { + ctx.telegram.deleteMessage(ctx.chat!.id, statusMsg.message_id).catch(() => {}); + }, 3000); + + LogEngine.info('Sent reply to agent', { + ticketNumber: agentMessageInfo.friendlyId, + conversationId: agentMessageInfo.conversationId, + telegramUserId, + username, + messageLength: message?.length, + chatId: ctx.chat?.id + }); + return true; + + } catch (error) { + const err = error as Error; + // Handle API errors + LogEngine.error('Error sending reply to agent', { + error: err.message, + conversationId: agentMessageInfo.conversationId + }); + + // Update status message to error + await safeEditMessageText( + ctx, + ctx.chat!.id, + statusMsg.message_id, + undefined, + '❌ Error!' + ); + + // Delete status message after 5 seconds + setTimeout(() => { + ctx.telegram.deleteMessage(ctx.chat!.id, statusMsg.message_id).catch(() => {}); + }, 5000); + + return true; + } + + } catch (error) { + const err = error as Error; + LogEngine.error('Error in handleAgentMessageReply', { + error: err.message, + conversationId: agentMessageInfo?.conversationId + }); + return false; + } +} + +/** + * Processes incoming messages from private chats and responds with an about message for non-command texts. + * + * Skips messages that are commands, allowing them to be handled by their respective handlers. + */ +export async function handlePrivateMessage(ctx: BotContext): Promise { + try { + // Log information about the private message + LogEngine.info('Processing private message', { + telegramUserId: ctx.from?.id, + username: ctx.from?.username, + firstName: ctx.from?.first_name, + lastName: ctx.from?.last_name, + messageId: ctx.message?.message_id, + messageText: ctx.message && 'text' in ctx.message ? ctx.message.text?.substring(0, 100) : undefined + }); + + // Only respond to private messages if they're not commands + // Commands should be handled by their respective handlers + if (ctx.message && 'text' in ctx.message && ctx.message.text?.startsWith('/')) { + LogEngine.debug('Skipping private message - it\'s a command', { + command: ctx.message.text.split(' ')[0] + }); + return; + } + + // Send the about message for any non-command private message + await aboutCommand(ctx); + + } catch (error) { + const err = error as Error; + LogEngine.error('Error in handlePrivateMessage', { + error: err.message, + stack: err.stack, + telegramUserId: ctx.from?.id, + username: ctx.from?.username + }); + } +} + +/** + * Processes incoming messages from group chats without sending automatic responses. + * + * Logs detailed information about the group, sender, and message content for monitoring and debugging purposes. + */ +export async function handleGroupMessage(ctx: BotContext): Promise { + try { + // Log more detailed information about the group message + const chatTitle = ctx.chat && ('title' in ctx.chat) ? ctx.chat.title : 'Unknown'; + LogEngine.info(`Processing message from group: ${chatTitle} (ID: ${ctx.chat?.id})`); + + // Additional information about the sender if available + if (ctx.from) { + LogEngine.info(`Message sent by: ${ctx.from.first_name} ${ctx.from.last_name || ''} (ID: ${ctx.from.id})`); + } + + // Log the message content for debugging + LogEngine.debug('Group message details', { + messageId: ctx.message?.message_id, + messageText: ctx.message && 'text' in ctx.message ? ctx.message.text?.substring(0, 100) : undefined, + messageType: ctx.message && 'photo' in ctx.message ? 'photo' : + ctx.message && 'document' in ctx.message ? 'document' : + ctx.message && 'text' in ctx.message ? 'text' : 'other', + hasReply: ctx.message && 'reply_to_message' in ctx.message && !!ctx.message.reply_to_message, + replyToId: ctx.message && 'reply_to_message' in ctx.message ? ctx.message.reply_to_message?.message_id : undefined + }); + + LogEngine.debug('Group message processed - no automatic responses sent'); + + } catch (error) { + const err = error as Error; + LogEngine.error(`Error in handleGroupMessage: ${err.message}`); + } +} diff --git a/src/handlers/webhookMessage.js b/src/handlers/webhookMessage.js deleted file mode 100644 index ef372a2..0000000 --- a/src/handlers/webhookMessage.js +++ /dev/null @@ -1,209 +0,0 @@ -import { LogEngine } from '@wgtechlabs/log-engine'; - -/** - * Handles incoming webhook messages from Unthread agents - * Sends agent responses as replies to original ticket messages in Telegram - */ -export class TelegramWebhookHandler { - constructor(bot, botsStore) { - this.bot = bot; - this.botsStore = botsStore; - } - - /** - * Handle agent message created events from Unthread - * @param {Object} event - The webhook event - * @param {string} event.data.conversationId - Unthread conversation ID - * @param {string} event.data.text - Agent message text - * @param {string} event.data.sentByUserId - ID of the agent who sent the message - * @param {string} event.timestamp - Event timestamp - */ - async handleMessageCreated(event) { - try { - LogEngine.info('πŸ”„ Processing agent message webhook', { - conversationId: event.data.conversationId, - textLength: event.data.content?.length || 0, - sentBy: event.data.userId, - timestamp: event.timestamp - }); - - // 1. Get conversation ID from webhook event - const conversationId = event.data.conversationId; - if (!conversationId) { - LogEngine.warn('❌ No conversation ID in webhook event', { event }); - return; - } - - LogEngine.info('πŸ” Looking up ticket for conversation', { conversationId }); - - // 2. Look up original ticket message using bots-brain - const ticketData = await this.botsStore.getTicketByConversationId(conversationId); - if (!ticketData) { - LogEngine.warn(`❌ No ticket found for conversation: ${conversationId}`); - return; - } - - LogEngine.info('βœ… Ticket found', { - conversationId, - friendlyId: ticketData.friendlyId, - chatId: ticketData.chatId, - messageId: ticketData.messageId - }); - - // 3. Validate message content - check both 'content' and 'text' fields - const messageText = event.data.content || event.data.text; - if (!messageText || messageText.trim().length === 0) { - LogEngine.warn('❌ Empty message text in webhook event', { - conversationId, - hasContent: !!event.data.content, - hasText: !!event.data.text - }); - return; - } - - LogEngine.info('βœ… Message content validated', { - conversationId, - messageLength: messageText.length, - messagePreview: messageText.substring(0, 100) + (messageText.length > 100 ? '...' : '') - }); - - // 4. Format agent message for Telegram - const formattedMessage = this.formatAgentMessage(messageText, ticketData.friendlyId); - - LogEngine.info('βœ… Message formatted for Telegram', { - conversationId, - formattedLength: formattedMessage.length - }); - - // 5. Send agent message as reply to original ticket message - LogEngine.info('πŸ“€ Attempting to send message to Telegram', { - conversationId, - chatId: ticketData.chatId, - replyToMessageId: ticketData.messageId - }); - - try { - const sentMessage = await this.bot.telegram.sendMessage( - ticketData.chatId, - formattedMessage, - { - reply_to_message_id: ticketData.messageId, - parse_mode: 'Markdown', - disable_web_page_preview: true - } - ); - - // 6. Store agent message for reply tracking - await this.botsStore.storeAgentMessage({ - messageId: sentMessage.message_id, - conversationId: conversationId, - chatId: ticketData.chatId, - friendlyId: ticketData.friendlyId, - originalTicketMessageId: ticketData.messageId, - sentAt: new Date().toISOString() - }); - - LogEngine.info('βœ…πŸŽ‰ Agent message delivered to Telegram successfully!', { - conversationId, - chatId: ticketData.chatId, - replyToMessageId: ticketData.messageId, - sentMessageId: sentMessage.message_id, - friendlyId: ticketData.friendlyId - }); - - } catch (telegramError) { - LogEngine.error('Failed to send message to Telegram', { - error: telegramError.message, - chatId: ticketData.chatId, - messageId: ticketData.messageId, - conversationId - }); - - // Try sending without reply if reply fails (original message might be deleted) - try { - await this.bot.telegram.sendMessage( - ticketData.chatId, - `${formattedMessage}\n\n_Note: Sent as new message (original ticket message not found)_`, - { - parse_mode: 'Markdown', - disable_web_page_preview: true - } - ); - - LogEngine.info('Agent message sent as new message (fallback)', { - conversationId, - chatId: ticketData.chatId - }); - - } catch (fallbackError) { - LogEngine.error('Failed to send fallback message to Telegram', { - error: fallbackError.message, - chatId: ticketData.chatId, - conversationId - }); - throw fallbackError; - } - } - - } catch (error) { - LogEngine.error('Error handling webhook message', { - error: error.message, - stack: error.stack, - event: event - }); - throw error; - } - } - - /** - * Format agent message for display in Telegram - * @param {string} text - The agent message text - * @param {string} friendlyId - The ticket friendly ID (e.g., TKT-001) - * @returns {string} Formatted message - */ - formatAgentMessage(text, friendlyId) { - // Clean and truncate message if too long - const cleanText = this.sanitizeMessageText(text); - const maxLength = 4000; // Telegram message limit is 4096, leave some room - let truncatedText = cleanText; - if (cleanText.length > maxLength) { - truncatedText = cleanText.substring(0, maxLength - 50) + '...\n\n_Message truncated_'; - } - return `🎫 Ticket #${friendlyId}\n\nπŸ’¬ Response:\n${truncatedText}\n\n──────────\nπŸ“ Reply to this message to respond or add more info to your ticket.`; - } - - /** - * Sanitize message text for Telegram Markdown - * @param {string} text - Raw message text - * @returns {string} Sanitized text - */ - sanitizeMessageText(text) { - if (!text) return ''; - - // Basic cleanup - let cleaned = text.trim(); - - // Escape common Markdown characters that might break formatting - // But preserve basic formatting like *bold* and _italic_ - cleaned = cleaned - .replace(/\\/g, '\\\\') // Escape backslashes - .replace(/`/g, '\\`') // Escape backticks - .replace(/\[/g, '\\[') // Escape square brackets - .replace(/\]/g, '\\]'); // Escape square brackets - - return cleaned; - } - - /** - * Handle other webhook events (for future expansion) - * @param {string} eventType - Type of webhook event - * @param {Object} event - The webhook event data - */ - async handleOtherEvent(eventType, event) { - LogEngine.info(`Received ${eventType} event (not processed)`, { - eventType, - conversationId: event.data?.conversationId, - timestamp: event.timestamp - }); - } -} diff --git a/src/handlers/webhookMessage.ts b/src/handlers/webhookMessage.ts new file mode 100644 index 0000000..7b8a69f --- /dev/null +++ b/src/handlers/webhookMessage.ts @@ -0,0 +1,516 @@ +import { LogEngine } from '@wgtechlabs/log-engine'; +import type { Telegraf } from 'telegraf'; +import type { BotContext } from '../types/index.js'; +import type { IBotsStore } from '../sdk/types.js'; + +/** + * Handles incoming webhook messages from Unthread agents + * Sends agent responses as replies to original ticket messages in Telegram + */ +export class TelegramWebhookHandler { + private bot: Telegraf; + private botsStore: IBotsStore; // SDK type, properly typed with IBotsStore interface + + constructor(bot: Telegraf, botsStore: IBotsStore) { + this.bot = bot; + this.botsStore = botsStore; + } + + /** + * Safely send a message with error handling for blocked users and other common errors + * + * @param chatId - The chat ID to send the message to + * @param text - The message text + * @param options - Additional options for sendMessage + * @returns The sent message object or null if failed + */ + async safeSendMessage(chatId: number, text: string, options: any = {}): Promise { + try { + return await this.bot.telegram.sendMessage(chatId, text, options); + } catch (error: any) { + if (error.response?.error_code === 403) { + if (error.response.description?.includes('bot was blocked by the user')) { + LogEngine.warn('Bot was blocked by user - cleaning up user data', { chatId }); + + // Clean up blocked user from storage (solution from GitHub issue #1513) + await this.cleanupBlockedUser(chatId); + + return null; + } + if (error.response.description?.includes('chat not found')) { + LogEngine.warn('Chat not found - cleaning up chat data', { chatId }); + + // Clean up chat that no longer exists + await this.cleanupBlockedUser(chatId); + + return null; + } + } + + if (error.response?.error_code === 429) { + LogEngine.warn('Rate limit exceeded when sending message', { + chatId, + retryAfter: error.response.parameters?.retry_after + }); + return null; + } + + // For other errors, log and re-throw + LogEngine.error('Error sending message', { + error: error.message, + chatId, + textLength: text?.length + }); + throw error; + } + } + + /** + * Handle agent message created events from Unthread + * @param event - The webhook event + */ + async handleMessageCreated(event: any): Promise { + try { + LogEngine.info('πŸ”„ Processing agent message webhook', { + conversationId: event.data.conversationId, + textLength: event.data.content?.length || 0, + sentBy: event.data.userId, + timestamp: event.timestamp + }); + + // 1. Get conversation ID from webhook event + const conversationId = event.data.conversationId; + if (!conversationId) { + LogEngine.warn('❌ No conversation ID in webhook event', { event }); + return; + } + + LogEngine.info('πŸ” Looking up ticket for conversation', { conversationId }); + + // 2. Look up original ticket message using bots-brain + const ticketData = await this.botsStore.getTicketByConversationId(conversationId); + if (!ticketData) { + LogEngine.warn(`❌ No ticket found for conversation: ${conversationId}`); + return; + } + + LogEngine.info('βœ… Ticket found', { + conversationId, + friendlyId: ticketData.friendlyId, + chatId: ticketData.chatId, + messageId: ticketData.messageId + }); + + // 3. Validate message content - check both 'content' and 'text' fields + const messageText = event.data.content || event.data.text; + if (!messageText || messageText.trim().length === 0) { + LogEngine.warn('❌ Empty message text in webhook event', { + conversationId, + hasContent: !!event.data.content, + hasText: !!event.data.text + }); + return; + } + + LogEngine.info('βœ… Message content validated', { + conversationId, + messageLength: messageText.length, + messagePreview: messageText.substring(0, 100) + (messageText.length > 100 ? '...' : '') + }); + + // 4. Format agent message for Telegram + const formattedMessage = this.formatAgentMessage(messageText, ticketData.friendlyId); + + LogEngine.info('βœ… Message formatted for Telegram', { + conversationId, + formattedLength: formattedMessage.length + }); + + // 5. Send agent message as reply to original ticket message + LogEngine.info('πŸ“€ Attempting to send message to Telegram', { + conversationId, + chatId: ticketData.chatId, + replyToMessageId: ticketData.messageId + }); + + try { + const sentMessage = await this.safeSendMessage( + ticketData.chatId, + formattedMessage, + { + reply_to_message_id: ticketData.messageId, + parse_mode: 'Markdown', + disable_web_page_preview: true + } + ); + + if (sentMessage) { + // 6. Store agent message for reply tracking + await this.botsStore.storeAgentMessage({ + messageId: sentMessage.message_id, + conversationId: conversationId, + chatId: ticketData.chatId, + friendlyId: ticketData.friendlyId, + originalTicketMessageId: ticketData.messageId, + sentAt: new Date().toISOString() + }); + + LogEngine.info('βœ…πŸŽ‰ Agent message delivered to Telegram successfully!', { + conversationId, + chatId: ticketData.chatId, + replyToMessageId: ticketData.messageId, + sentMessageId: sentMessage.message_id, + friendlyId: ticketData.friendlyId + }); + } else { + LogEngine.warn('Message not sent - user may have blocked bot or chat not found', { + conversationId, + chatId: ticketData.chatId, + friendlyId: ticketData.friendlyId + }); + } + + } catch (telegramError) { + const err = telegramError as Error; + LogEngine.error('Failed to send message to Telegram', { + error: err.message, + chatId: ticketData.chatId, + messageId: ticketData.messageId, + conversationId + }); + + // Try sending without reply if reply fails (original message might be deleted) + try { + const fallbackMessage = await this.safeSendMessage( + ticketData.chatId, + `${formattedMessage}\n\n_Note: Sent as new message (original ticket message not found)_`, + { + parse_mode: 'Markdown', + disable_web_page_preview: true + } + ); + + if (fallbackMessage) { + LogEngine.info('Agent message sent as new message (fallback)', { + conversationId, + chatId: ticketData.chatId + }); + } else { + LogEngine.warn('Fallback message also failed - user may have blocked bot', { + conversationId, + chatId: ticketData.chatId + }); + } + + } catch (fallbackError) { + const fallbackErr = fallbackError as Error; + LogEngine.error('Failed to send fallback message to Telegram', { + error: fallbackErr.message, + chatId: ticketData.chatId, + conversationId + }); + throw fallbackError; + } + } + + } catch (error) { + const err = error as Error; + LogEngine.error('Error handling webhook message', { + error: err.message, + stack: err.stack, + event: event + }); + throw error; + } + } + + /** + * Format agent message for display in Telegram + * @param text - The agent message text + * @param friendlyId - The ticket friendly ID (e.g., TKT-001) + * @returns Formatted message + */ + formatAgentMessage(text: string, friendlyId: string): string { + // Clean and truncate message if too long + const cleanText = this.sanitizeMessageText(text); + const maxLength = 4000; // Telegram message limit is 4096, leave some room + let truncatedText = cleanText; + if (cleanText.length > maxLength) { + truncatedText = cleanText.substring(0, maxLength - 50) + '...\n\n_Message truncated_'; + } + return `🎫 Ticket #${friendlyId}\n\nπŸ’¬ Response:\n${truncatedText}\n\n──────────\nπŸ“ Reply to this message to respond or add more info to your ticket.`; + } + + /** + * Sanitize message text for Telegram Markdown + * @param text - Raw message text + * @returns Sanitized text + */ + sanitizeMessageText(text: string): string { + if (!text) return ''; + + // Basic cleanup + let cleaned = text.trim(); + + // Escape common Markdown characters that might break formatting + // But preserve basic formatting like *bold* and _italic_ + cleaned = cleaned + .replace(/\\/g, '\\\\') // Escape backslashes + .replace(/`/g, '\\`') // Escape backticks + .replace(/\[/g, '\\[') // Escape square brackets + .replace(/\]/g, '\\]'); // Escape square brackets + + return cleaned; + } + + /** + * Handle other webhook events (for future expansion) + * @param eventType - Type of webhook event + * @param event - The webhook event data + */ + async handleOtherEvent(eventType: string, event: any): Promise { + LogEngine.info(`Received ${eventType} event (not processed)`, { + eventType, + conversationId: event.data?.conversationId, + timestamp: event.timestamp + }); + } + + /** + * Handle conversation updated events from Unthread (status changes) + * @param event - The webhook event + */ + async handleConversationUpdated(event: any): Promise { + try { + // 1. Get conversation ID from webhook event (try both fields) + const conversationId = event.data.conversationId || event.data.id; + + LogEngine.info('πŸ”„ Processing conversation status update webhook', { + conversationId: conversationId, + newStatus: event.data.status, + previousStatus: event.data.previousStatus, + timestamp: event.timestamp + }); + + const newStatus = typeof event.data.status === 'string' + ? event.data.status.toLowerCase() + : String(event.data.status || '').toLowerCase(); + + if (!conversationId) { + LogEngine.warn('❌ No conversation ID in webhook event', { event }); + return; + } + + if (!newStatus || !['open', 'closed'].includes(newStatus)) { + LogEngine.warn('❌ Invalid or missing status in webhook event', { + status: event.data.status, + conversationId + }); + return; + } + + LogEngine.info('πŸ” Looking up ticket for status update', { conversationId, newStatus }); + + // 2. Look up original ticket message using bots-brain + const ticketData = await this.botsStore.getTicketByConversationId(conversationId); + if (!ticketData) { + LogEngine.warn(`❌ No ticket found for conversation: ${conversationId}`); + return; + } + + LogEngine.info('βœ… Ticket found for status update', { + conversationId, + friendlyId: ticketData.friendlyId, + chatId: ticketData.chatId, + messageId: ticketData.messageId, + newStatus + }); + + // 3. Format status update message for Telegram + const statusMessage = this.formatStatusUpdateMessage(newStatus, ticketData.friendlyId); + + LogEngine.info('βœ… Status message formatted for Telegram', { + conversationId, + newStatus, + messageLength: statusMessage.length + }); + + // 4. Send status notification as reply to original ticket message + LogEngine.info('πŸ“€ Attempting to send status notification to Telegram', { + conversationId, + chatId: ticketData.chatId, + replyToMessageId: ticketData.messageId, + newStatus + }); + + try { + const sentMessage = await this.safeSendMessage( + ticketData.chatId, + statusMessage, + { + reply_to_message_id: ticketData.messageId, + parse_mode: 'Markdown', + disable_web_page_preview: true + } + ); + + if (sentMessage) { + LogEngine.info('βœ…πŸŽ‰ Status notification delivered to Telegram successfully!', { + conversationId, + chatId: ticketData.chatId, + replyToMessageId: ticketData.messageId, + sentMessageId: sentMessage.message_id, + friendlyId: ticketData.friendlyId, + newStatus + }); + } else { + LogEngine.warn('Status notification not sent - user may have blocked bot', { + conversationId, + chatId: ticketData.chatId, + friendlyId: ticketData.friendlyId, + newStatus + }); + } + + } catch (telegramError) { + const err = telegramError as Error; + LogEngine.error('Failed to send status notification to Telegram', { + error: err.message, + chatId: ticketData.chatId, + messageId: ticketData.messageId, + conversationId, + newStatus + }); + + // Try sending without reply if reply fails (original message might be deleted) + try { + const fallbackMessage = await this.safeSendMessage( + ticketData.chatId, + `${statusMessage}\n\n_Note: Sent as new message (original ticket message not found)_`, + { + parse_mode: 'Markdown', + disable_web_page_preview: true + } + ); + + if (fallbackMessage) { + LogEngine.info('Status notification sent as new message (fallback)', { + conversationId, + chatId: ticketData.chatId, + newStatus + }); + } else { + LogEngine.warn('Fallback status notification also failed - user may have blocked bot', { + conversationId, + chatId: ticketData.chatId, + newStatus + }); + } + + } catch (fallbackError) { + const fallbackErr = fallbackError as Error; + LogEngine.error('Failed to send fallback status notification to Telegram', { + error: fallbackErr.message, + chatId: ticketData.chatId, + conversationId, + newStatus + }); + throw fallbackError; + } + } + + } catch (error) { + const err = error as Error; + LogEngine.error('Error handling conversation update webhook', { + error: err.message, + stack: err.stack, + event: event + }); + throw error; + } + } + + /** + * Format status update message for display in Telegram + * @param status - The new status (open/closed) + * @param friendlyId - The ticket friendly ID (e.g., TKT-001) + * @returns Formatted status message + */ + formatStatusUpdateMessage(status: string, friendlyId: string): string { + const statusIcon = status === 'closed' ? 'πŸ”’' : 'πŸ“‚'; + const statusText = status === 'closed' ? 'CLOSED' : 'OPEN'; + const statusEmoji = status === 'closed' ? 'βœ…' : 'πŸ”„'; + + let message = `${statusIcon} *Ticket Status Update*\n\n`; + message += `🎫 Ticket #${friendlyId}\n`; + message += `${statusEmoji} Status: *${statusText}*\n\n`; + + if (status === 'closed') { + message += `Your ticket has been resolved and closed. If you need further assistance, please create a new ticket using /support.`; + } else { + message += `Your ticket has been reopened and is now active. An agent will assist you shortly.`; + } + + return message; + } + + /** + * Clean up user data when bot is blocked or chat is not found + * This implements the fix from GitHub issue telegraf/telegraf#1513 + * + * @param chatId - The chat ID of the blocked user + */ + async cleanupBlockedUser(chatId: number): Promise { + try { + LogEngine.info('Starting cleanup for blocked user', { chatId }); + + // 1. Get all tickets for this chat + const tickets = await this.botsStore.getTicketsForChat(chatId); + + if (tickets.length > 0) { + LogEngine.info(`Found ${tickets.length} tickets to clean up for blocked user`, { + chatId, + ticketIds: tickets.map((t: any) => t.conversationId) + }); + + // 2. Delete each ticket and its mappings + for (const ticket of tickets) { + await this.botsStore.deleteTicket(ticket.conversationId); + LogEngine.info(`Cleaned up ticket ${ticket.friendlyId} for blocked user`, { + chatId, + conversationId: ticket.conversationId + }); + } + } + + // 3. Clean up customer data for this chat + const customer = await this.botsStore.getCustomerByChatId(chatId); + if (customer) { + // Remove customer mappings (the customer still exists in Unthread, just remove local mappings) + await this.botsStore.storage.delete(`customer:telegram:${chatId}`); + await this.botsStore.storage.delete(`customer:id:${customer.unthreadCustomerId}`); + + LogEngine.info('Cleaned up customer mappings for blocked user', { + chatId, + customerId: customer.unthreadCustomerId + }); + } + + // 4. Clean up any user states + // Note: User states are keyed by telegram user ID, not chat ID + // So we can't clean them up directly without the user ID + // They will expire naturally due to TTL + + LogEngine.info('Successfully cleaned up blocked user data', { chatId }); + + } catch (error) { + const err = error as Error; + LogEngine.error('Error cleaning up blocked user data', { + error: err.message, + stack: err.stack, + chatId + }); + // Don't throw - cleanup failure shouldn't crash the bot + } + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 57bab49..0000000 --- a/src/index.js +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Main Bot Application Entry Point - * - * This file is the entry point for the Telegram bot application. It handles the bot - * initialization, configures middleware, sets up command handlers, and starts the bot. - * - * Potential Improvements: - * - Add more robust error handling - * - Implement structured logging - * - Add graceful shutdown hooks - * - Add configuration validation - */ -import dotenv from 'dotenv'; - -// Load environment variables from .env file -dotenv.config(); - -import { createBot, configureCommands, startPolling } from './bot.js'; -import { startCommand, helpCommand, versionCommand, supportCommand, processSupportConversation } from './commands/index.js'; -import { handleMessage } from './events/message.js'; -import { db } from './database/connection.js'; -import { BotsStore } from './sdk/bots-brain/index.js'; -import { WebhookConsumer } from './sdk/unthread-webhook/index.js'; -import { TelegramWebhookHandler } from './handlers/webhookMessage.js'; -import packageJSON from '../package.json' with { type: 'json' }; -import { LogEngine } from '@wgtechlabs/log-engine'; - -/** - * Initialize the bot with the token from environment variables - * - * Possible Bugs: - * - No validation if TELEGRAM_BOT_TOKEN is missing or invalid - * - No error handling for bot creation failures - * - * Enhancement Opportunities: - * - Add environment variable validation - * - Add fallback mechanisms for missing configuration - */ -const bot = createBot(process.env.TELEGRAM_BOT_TOKEN); - -/** - * Global middleware for logging incoming messages - * - * Possible Bugs: - * - No error handling if ctx.message is undefined or doesn't have text property - * - Middleware doesn't handle non-text messages - * - * Enhancement Opportunities: - * - Add more comprehensive logging for different message types - * - Add performance metrics collection - * - Add rate limiting middleware - * - Add user tracking/analytics - */ -bot.use(async (ctx, next) => { - if (ctx.message) { - LogEngine.debug('Message received', { - chatId: ctx.chat.id, - userId: ctx.from?.id, - type: ctx.message.text ? 'text' : 'media' - }); - } - await next(); -}); - -/** - * Command handler registration - * - * Possible Bugs: - * - Limited set of commands - * - No help text for commands - * - * Enhancement Opportunities: - * - Add more useful commands - * - Add command categorization - * - Add dynamic command registration - * - Implement command access control - */ -bot.start(startCommand); -bot.help(helpCommand); -bot.command('version', versionCommand); -bot.command('support', supportCommand); - -// Register message handlers -bot.on('message', handleMessage); - -// Register callback query handler for buttons -bot.on('callback_query', async (ctx) => { - try { - // Route callback queries through the processSupportConversation function - await processSupportConversation(ctx); - } catch (error) { - LogEngine.error('Error handling callback query', { - error: error.message, - userId: ctx.from?.id - }); - } -}); - -/** - * Database and Storage initialization - * - * Initialize database connection and storage layers before starting the bot - */ -try { - await db.connect(); - LogEngine.info('Database initialized successfully'); - - // Initialize the BotsStore with database connection and platform Redis URL - await BotsStore.initialize(db, process.env.PLATFORM_REDIS_URL); - LogEngine.info('BotsStore initialized successfully'); -} catch (error) { - LogEngine.error('Failed to initialize database or storage', { - error: error.message - }); - process.exit(1); -} - -/** - * Webhook Consumer and Handler initialization - * - * Initialize the webhook consumer to listen for Unthread events - * and the handler to process agent messages - */ -let webhookConsumer; -let webhookHandler; - -try { - // Check if webhook Redis URL is available before initializing webhook consumer - if (process.env.WEBHOOK_REDIS_URL) { - // Initialize webhook consumer with dedicated webhook Redis URL - webhookConsumer = new WebhookConsumer({ - redisUrl: process.env.WEBHOOK_REDIS_URL, - queueName: 'unthread-events' - }); - - // Initialize webhook handler - const botsStore = BotsStore.getInstance(); - webhookHandler = new TelegramWebhookHandler(bot, botsStore); - - // Subscribe to agent message events from dashboard - webhookConsumer.subscribe('message_created', 'dashboard', - webhookHandler.handleMessageCreated.bind(webhookHandler) - ); - - // Start the webhook consumer - await webhookConsumer.start(); - LogEngine.info('Webhook consumer started successfully'); - } else { - LogEngine.warn('Webhook Redis URL not configured - webhook processing disabled'); - LogEngine.info('Bot will run in basic mode (ticket creation only)'); - } - -} catch (error) { - LogEngine.error('Failed to initialize webhook consumer', { - error: error.message - }); - // Don't exit - bot can still work for ticket creation without webhook processing - LogEngine.warn('Bot will continue without webhook processing capabilities'); -} - -/** - * Bot initialization and startup - * - * Possible Bugs: - * - No error handling if bot.telegram.getMe() fails - * - No retry mechanism for connection issues - * - * Enhancement Opportunities: - * - Add health check endpoint - * - Implement proper shutdown mechanism - * - Add startup status reporting - * - Consider webhook mode for production - */ -bot.botInfo = await bot.telegram.getMe(); -LogEngine.info('Bot initialized successfully', { - username: bot.botInfo.username, - botId: bot.botInfo.id, - version: packageJSON.version, - nodeVersion: process.version, - platform: process.platform -}); -LogEngine.info('Bot is running and listening for messages...'); -/** - * Start polling for updates - * - * Possible Bugs: - * - No error handling for polling failures - * - No timeout or retry mechanism for polling - * - * Enhancement Opportunities: - * - Implement webhook mode for better performance - * - Add graceful shutdown on SIGINT/SIGTERM - */ -startPolling(bot); - -/** - * Graceful shutdown handler - * - * Properly close database connections, stop webhook consumer, and stop the bot on shutdown - */ -process.on('SIGINT', async () => { - LogEngine.info('Received SIGINT, shutting down gracefully...'); - try { - if (webhookConsumer) { - await webhookConsumer.stop(); - LogEngine.info('Webhook consumer stopped'); - } - await BotsStore.shutdown(); - LogEngine.info('BotsStore shutdown complete'); - await db.close(); - LogEngine.info('Database connections closed'); - process.exit(0); - } catch (error) { - LogEngine.error('Error during shutdown', { error: error.message }); - process.exit(1); - } -}); - -process.on('SIGTERM', async () => { - LogEngine.info('Received SIGTERM, shutting down gracefully...'); - try { - if (webhookConsumer) { - await webhookConsumer.stop(); - LogEngine.info('Webhook consumer stopped'); - } - await BotsStore.shutdown(); - LogEngine.info('BotsStore shutdown complete'); - await db.close(); - LogEngine.info('Database connections closed'); - process.exit(0); - } catch (error) { - LogEngine.error('Error during shutdown', { error: error.message }); - process.exit(1); - } -}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7dd0825 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,453 @@ +/** + * Main Bot Application Entry Point + * + * This file is the entry point for the Telegram bot application. It handles the bot + * initialization, configures middleware, sets up command handlers, and starts the bot. + */ +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); + +import { createBot, startPolling, safeReply, cleanupBlockedUser } from './bot.js'; +import { + startCommand, + helpCommand, + versionCommand, + aboutCommand, + supportCommand, + cancelCommand, + resetCommand, + processSupportConversation +} from './commands/index.js'; +import { handleMessage } from './events/message.js'; +import { db } from './database/connection.js'; +import { BotsStore } from './sdk/bots-brain/index.js'; +import { WebhookConsumer } from './sdk/unthread-webhook/index.js'; +import { TelegramWebhookHandler } from './handlers/webhookMessage.js'; +import packageJSON from '../package.json' with { type: 'json' }; +import { LogEngine } from '@wgtechlabs/log-engine'; +import type { BotContext } from './types/index.js'; + +/** + * Initialize the bot with the token from environment variables + */ +const telegramToken = process.env.TELEGRAM_BOT_TOKEN; +if (!telegramToken) { + LogEngine.error('TELEGRAM_BOT_TOKEN is required but not found in environment variables'); + process.exit(1); +} + +const bot = createBot(telegramToken); + +/** + * Global middleware for logging incoming messages + */ +bot.use(async (ctx: BotContext, next) => { + try { + if (ctx.message) { + // Determine message type + let messageType = 'text'; + if ('photo' in ctx.message) messageType = 'photo'; + else if ('document' in ctx.message) messageType = 'document'; + else if ('video' in ctx.message) messageType = 'video'; + else if ('audio' in ctx.message) messageType = 'audio'; + else if ('voice' in ctx.message) messageType = 'voice'; + else if ('video_note' in ctx.message) messageType = 'video_note'; + else if ('sticker' in ctx.message) messageType = 'sticker'; + else if (!('text' in ctx.message)) messageType = 'other'; + + LogEngine.debug('Message received', { + chatId: ctx.chat?.id, + userId: ctx.from?.id, + type: messageType, + hasText: 'text' in ctx.message && !!ctx.message.text, + isCommand: 'text' in ctx.message && ctx.message.text?.startsWith('/'), + textPreview: 'text' in ctx.message ? ctx.message.text?.substring(0, 30) : undefined + }); + } + await next(); + } catch (error) { + const err = error as Error; + LogEngine.error('Error in bot middleware', { + error: err.message, + stack: err.stack, + chatId: ctx.chat?.id, + userId: ctx.from?.id + }); + + // Don't re-throw the error to prevent bot crash + // Just log it and continue + } +}); + +/** + * Command handler registration + * + * Commands are restricted to private chats only to prevent spam in group chats + */ + +// Middleware for command handling with proper chat type support +const commandMiddleware = async (ctx: BotContext, next: () => Promise) => { + try { + // Allow all commands in both private and group chats + // The individual command handlers will determine the appropriate response + return await next(); + } catch (error) { + const err = error as Error; + LogEngine.error('Error in command middleware', { + error: err.message, + chatId: ctx.chat?.id, + command: ctx.message && 'text' in ctx.message ? ctx.message.text : undefined + }); + } +}; + +// Wrap command handlers with error handling +const wrapCommandHandler = (handler: (ctx: BotContext) => Promise, commandName: string) => { + return async (ctx: BotContext) => { + try { + LogEngine.debug(`Executing ${commandName} command`, { + chatId: ctx.chat?.id, + chatType: ctx.chat?.type, + userId: ctx.from?.id + }); + await handler(ctx); + } catch (error) { + const err = error as Error; + LogEngine.error(`Error in ${commandName} command`, { + error: err.message, + stack: err.stack, + chatId: ctx.chat?.id, + userId: ctx.from?.id + }); + + // Try to send error message safely + try { + await safeReply(ctx, `Sorry, there was an error processing the ${commandName} command.`); + } catch (replyError) { + const replyErr = replyError as Error; + LogEngine.error(`Failed to send error reply for ${commandName}`, { + error: replyErr.message, + chatId: ctx.chat?.id + }); + } + } + }; +}; + +bot.start(commandMiddleware, wrapCommandHandler(startCommand, 'start')); +bot.help(commandMiddleware, wrapCommandHandler(helpCommand, 'help')); +bot.command('version', commandMiddleware, wrapCommandHandler(versionCommand, 'version')); +bot.command('about', commandMiddleware, wrapCommandHandler(aboutCommand, 'about')); +bot.command('support', commandMiddleware, wrapCommandHandler(supportCommand, 'support')); +bot.command('cancel', commandMiddleware, wrapCommandHandler(cancelCommand, 'cancel')); +bot.command('reset', commandMiddleware, wrapCommandHandler(resetCommand, 'reset')); + +// Register message handlers with middleware +bot.on('text', async (ctx, next) => { + // Skip commands - let Telegraf handle them with the command handlers + if (ctx.message.text?.startsWith('/')) { + return; + } + + await handleMessage(ctx, next); +}); + +// Also register the original message handler for non-text messages (photos, etc.) +bot.on('message', async (ctx, next) => { + // Only handle non-text messages here + if ('text' in ctx.message) { + return; // Text messages are handled by the 'text' handler above + } + + await handleMessage(ctx, next); +}); + +// Register callback query handler for buttons +bot.on('callback_query', async (ctx) => { + try { + // Route callback queries through the processSupportConversation function + await processSupportConversation(ctx); + } catch (error) { + const err = error as Error; + LogEngine.error('Error handling callback query', { + error: err.message, + userId: ctx.from?.id + }); + } +}); + +/** + * Executes an asynchronous operation with retries and exponential backoff on failure. + * + * Retries the provided async operation up to a specified number of times, increasing the delay between attempts exponentially up to a maximum delay. Logs warnings on each retry and an error if all attempts fail. + * + * @param operation - The asynchronous function to execute and retry on failure + * @param maxRetries - Maximum number of retry attempts (default: 5) + * @param initialDelayMs - Initial delay in milliseconds before the first retry (default: 1000) + * @param maxDelayMs - Maximum delay in milliseconds between retries (default: 30000) + * @param operationName - Name used in log messages to identify the operation (default: 'operation') + * @returns The result of the successful operation + * @throws The last encountered error if all retries fail + */ +async function retryWithBackoff( + operation: () => Promise, + maxRetries: number = 5, + initialDelayMs: number = 1000, + maxDelayMs: number = 30000, + operationName: string = 'operation' +): Promise { + let lastError: Error; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + + if (attempt === maxRetries) { + LogEngine.error(`${operationName} failed after ${maxRetries} attempts`, { + error: lastError.message, + attempts: maxRetries + }); + throw lastError; + } + + const delayMs = Math.min(initialDelayMs * Math.pow(2, attempt - 1), maxDelayMs); + LogEngine.warn(`${operationName} failed (attempt ${attempt}/${maxRetries}), retrying in ${delayMs}ms`, { + error: lastError.message, + attempt, + nextRetryIn: `${delayMs}ms` + }); + + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + + throw lastError!; +} + +/** + * Database and Storage initialization with retry logic + * + * Initialize database connection and storage layers before starting the bot + * Implements retry mechanism to handle transient failures gracefully + */ +try { + // Initialize database connection with retry logic + await retryWithBackoff( + async () => { + await db.connect(); + LogEngine.info('Database connection established'); + }, + 5, // max retries + 2000, // initial delay: 2 seconds + 30000, // max delay: 30 seconds + 'Database connection' + ); + LogEngine.info('Database initialized successfully'); + + // Initialize the BotsStore with retry logic + await retryWithBackoff( + async () => { + await BotsStore.initialize(db, process.env.PLATFORM_REDIS_URL); + LogEngine.info('BotsStore connection established'); + }, + 5, // max retries + 2000, // initial delay: 2 seconds + 30000, // max delay: 30 seconds + 'BotsStore initialization' + ); + LogEngine.info('BotsStore initialized successfully'); +} catch (error) { + const err = error as Error; + LogEngine.error('Failed to initialize database or storage after all retry attempts', { + error: err.message, + maxRetries: 5 + }); + process.exit(1); +} + +/** + * Webhook Consumer and Handler initialization + * + * Initialize the webhook consumer to listen for Unthread events + * and the handler to process agent messages + */ +let webhookConsumer: WebhookConsumer | undefined; +let webhookHandler: TelegramWebhookHandler | undefined; + +try { + // Check if webhook Redis URL is available before initializing webhook consumer + if (process.env.WEBHOOK_REDIS_URL) { + // Initialize webhook consumer with dedicated webhook Redis URL + webhookConsumer = new WebhookConsumer({ + redisUrl: process.env.WEBHOOK_REDIS_URL, + queueName: 'unthread-events' + }); + + // Initialize webhook handler + const botsStore = BotsStore.getInstance(); + webhookHandler = new TelegramWebhookHandler(bot, botsStore); + + // Subscribe to agent message events from dashboard + webhookConsumer.subscribe('message_created', 'dashboard', + webhookHandler.handleMessageCreated.bind(webhookHandler) + ); + + // Subscribe to conversation status update events from dashboard + if (typeof webhookHandler.handleConversationUpdated === 'function') { + webhookConsumer.subscribe('conversation_updated', 'dashboard', + webhookHandler.handleConversationUpdated.bind(webhookHandler) + ); + } else { + LogEngine.warn('Webhook handler does not implement handleConversationUpdated; skipping subscription.'); + } + + // Start the webhook consumer + await webhookConsumer.start(); + LogEngine.info('Webhook consumer started successfully'); + } else { + LogEngine.warn('Webhook Redis URL not configured - webhook processing disabled'); + LogEngine.info('Bot will run in basic mode (ticket creation only)'); + } + +} catch (error) { + const err = error as Error; + LogEngine.error('Failed to initialize webhook consumer', { + error: err.message + }); + // Don't exit - bot can still work for ticket creation without webhook processing + LogEngine.warn('Bot will continue without webhook processing capabilities'); +} + +/** + * Bot initialization and startup + */ +bot.botInfo = await bot.telegram.getMe(); + +// Set bot commands for Telegram UI +await bot.telegram.setMyCommands([ + { command: 'start', description: 'Start the bot and get welcome message' }, + { command: 'help', description: 'Show available commands and their descriptions' }, + { command: 'version', description: 'Show the bot version' }, + { command: 'about', description: 'Show comprehensive bot information' }, + { command: 'support', description: 'Create a support ticket (group chats only)' }, + { command: 'cancel', description: 'Cancel ongoing support ticket creation' }, + { command: 'reset', description: 'Reset your support conversation state' } +]); + +LogEngine.info('Bot initialized successfully', { + username: bot.botInfo.username, + botId: bot.botInfo.id, + version: packageJSON.version, + nodeVersion: process.version, + platform: process.platform +}); + +LogEngine.info('Bot is running and listening for messages...'); + +/** + * Start polling for updates + */ +startPolling(bot); + +/** + * Global error handling middleware for Telegram API errors + * + * Catches and handles common Telegram API errors like: + * - 403: Bot was blocked by user + * - 400: Chat not found + * - 429: Too Many Requests + */ +bot.catch(async (error: any, ctx?: BotContext) => { + LogEngine.error('Telegram Bot Error', { + error: error.message, + errorCode: error.response?.error_code, + description: error.response?.description, + chatId: ctx?.chat?.id, + userId: ctx?.from?.id, + method: error.on?.method, + payload: error.on?.payload + }); + + // Handle specific error types + if (error.response?.error_code === 403) { + if (error.response.description?.includes('bot was blocked by the user')) { + LogEngine.warn('Bot was blocked by user - cleaning up user data', { + chatId: ctx?.chat?.id, + userId: ctx?.from?.id + }); + + // Clean up blocked user data (solution from GitHub issue #1513) + if (ctx?.chat?.id) { + await cleanupBlockedUser(ctx.chat.id); + } + + return; // Silently skip blocked users + } + if (error.response.description?.includes('chat not found')) { + LogEngine.warn('Chat not found - cleaning up chat data', { + chatId: ctx?.chat?.id + }); + + // Clean up chat that no longer exists + if (ctx?.chat?.id) { + await cleanupBlockedUser(ctx.chat.id); + } + + return; + } + } + + if (error.response?.error_code === 429) { + LogEngine.warn('Rate limit exceeded, backing off', { + chatId: ctx?.chat?.id, + retryAfter: error.response.parameters?.retry_after + }); + return; + } + + // For other errors, log but don't crash + LogEngine.error('Unhandled Telegram error', { + error: error.message, + stack: error.stack + }); +}); + +/** + * Shuts down all services and resources used by the bot, ensuring a clean exit. + * + * Stops the webhook consumer if running, shuts down the BotsStore, closes database connections, and exits the process. Logs each shutdown step and exits with an error code if any shutdown operation fails. + */ +async function gracefulShutdown(): Promise { + try { + if (webhookConsumer) { + await webhookConsumer.stop(); + LogEngine.info('Webhook consumer stopped'); + } + await BotsStore.shutdown(); + LogEngine.info('BotsStore shutdown complete'); + await db.close(); + LogEngine.info('Database connections closed'); + process.exit(0); + } catch (error) { + const err = error as Error; + LogEngine.error('Error during shutdown', { error: err.message }); + process.exit(1); + } +} + +/** + * Signal handlers for graceful shutdown + */ +process.on('SIGINT', async () => { + LogEngine.info('Received SIGINT, shutting down gracefully...'); + await gracefulShutdown(); +}); + +process.on('SIGTERM', async () => { + LogEngine.info('Received SIGTERM, shutting down gracefully...'); + await gracefulShutdown(); +}); diff --git a/src/sdk/bots-brain/BotsStore.js b/src/sdk/bots-brain/BotsStore.ts similarity index 56% rename from src/sdk/bots-brain/BotsStore.js rename to src/sdk/bots-brain/BotsStore.ts index 0e77e6e..0bd7b50 100644 --- a/src/sdk/bots-brain/BotsStore.js +++ b/src/sdk/bots-brain/BotsStore.ts @@ -5,21 +5,33 @@ */ import { UnifiedStorage } from './UnifiedStorage.js'; import { LogEngine } from '@wgtechlabs/log-engine'; +import type { + TicketData, + UserState, + CustomerData, + UserData, + AgentMessageData, + TicketInfo, + IBotsStore, + StorageConfig +} from '../types.js'; +import type { DatabaseConnection } from '../../database/connection.js'; -export class BotsStore { - static instance = null; +export class BotsStore implements IBotsStore { + private static instance: BotsStore | null = null; + public storage: UnifiedStorage; - constructor(unifiedStorage) { + constructor(unifiedStorage: UnifiedStorage) { this.storage = unifiedStorage; } /** * Initialize the BotsStore singleton with database connection */ - static async initialize(dbConnection, platformRedisUrl = null) { + static async initialize(dbConnection: DatabaseConnection, platformRedisUrl?: string): Promise { if (!BotsStore.instance) { const unifiedStorageConfig = { - postgres: dbConnection.pool, // Pass the existing pool instead of config + postgres: dbConnection.connectionPool, // Use the getter to access the pool redisUrl: platformRedisUrl }; const unifiedStorage = new UnifiedStorage(unifiedStorageConfig); @@ -32,7 +44,7 @@ export class BotsStore { /** * Get the singleton instance */ - static getInstance() { + static getInstance(): BotsStore { if (!BotsStore.instance) { throw new Error('BotsStore not initialized. Call BotsStore.initialize() first.'); } @@ -42,53 +54,53 @@ export class BotsStore { /** * Static methods for convenience */ - static async storeTicket(ticketData) { + static async storeTicket(ticketData: TicketData): Promise { return BotsStore.getInstance().storeTicket(ticketData); } - static async getTicketByTelegramMessageId(messageId) { + static async getTicketByTelegramMessageId(messageId: number): Promise { return BotsStore.getInstance().getTicketByMessageId(messageId); } - static async getTicketByConversationId(conversationId) { + static async getTicketByConversationId(conversationId: string): Promise { return BotsStore.getInstance().getTicketByConversationId(conversationId); } - static async setUserState(telegramUserId, state) { + static async setUserState(telegramUserId: number, state: UserState): Promise { return BotsStore.getInstance().storeUserState(telegramUserId, state); } - static async getUserState(telegramUserId) { + static async getUserState(telegramUserId: number): Promise { return BotsStore.getInstance().getUserState(telegramUserId); } - static async clearUserState(telegramUserId) { + static async clearUserState(telegramUserId: number): Promise { return BotsStore.getInstance().clearUserState(telegramUserId); } // Static methods for customer operations - static async storeCustomer(customerData) { + static async storeCustomer(customerData: CustomerData): Promise { return BotsStore.getInstance().storeCustomer(customerData); } - static async getCustomerById(customerId) { + static async getCustomerById(customerId: string): Promise { return BotsStore.getInstance().getCustomerById(customerId); } - static async getCustomerByChatId(chatId) { + static async getCustomerByChatId(chatId: number): Promise { return BotsStore.getInstance().getCustomerByChatId(chatId); } // Static methods for user operations - static async storeUser(userData) { + static async storeUser(userData: UserData): Promise { return BotsStore.getInstance().storeUser(userData); } - static async getUserByTelegramId(telegramUserId) { + static async getUserByTelegramId(telegramUserId: number): Promise { return BotsStore.getInstance().getUserByTelegramId(telegramUserId); } - static async shutdown() { + static async shutdown(): Promise { if (BotsStore.instance) { await BotsStore.instance.storage.disconnect(); BotsStore.instance = null; @@ -99,7 +111,7 @@ export class BotsStore { * Store ticket data with bidirectional mapping * Creates multiple keys for different lookup patterns */ - async storeTicket(ticketData) { + async storeTicket(ticketData: TicketData): Promise { const { chatId, messageId, @@ -111,7 +123,7 @@ export class BotsStore { } = ticketData; // Enhanced ticket data with metadata - const enrichedTicketData = { + const enrichedTicketData: TicketData = { ...ticketData, platform: 'telegram', storedAt: new Date().toISOString(), @@ -130,7 +142,7 @@ export class BotsStore { // Lookup by Unthread ticket ID (if different from conversation ID) ticketId !== conversationId ? this.storage.set(`ticket:unthread:${ticketId}`, enrichedTicketData) : - Promise.resolve(true), + Promise.resolve(), // Lookup by friendly ID this.storage.set(`ticket:friendly:${friendlyId}`, enrichedTicketData), @@ -143,9 +155,10 @@ export class BotsStore { LogEngine.info(`Ticket stored: ${friendlyId} (${conversationId})`); return true; } catch (error) { + const err = error as Error; LogEngine.error('Failed to store ticket', { - error: error.message, - stack: error.stack + error: err.message, + stack: err.stack }); return false; } @@ -154,37 +167,36 @@ export class BotsStore { /** * Get ticket by Unthread conversation ID */ - async getTicketByConversationId(conversationId) { + async getTicketByConversationId(conversationId: string): Promise { return await this.storage.get(`ticket:unthread:${conversationId}`); } /** * Get ticket by Telegram message ID */ - async getTicketByMessageId(messageId) { + async getTicketByMessageId(messageId: number): Promise { return await this.storage.get(`ticket:telegram:${messageId}`); } /** * Get ticket by friendly ID */ - async getTicketByFriendlyId(friendlyId) { + async getTicketByFriendlyId(friendlyId: string): Promise { return await this.storage.get(`ticket:friendly:${friendlyId}`); } /** * Get ticket by Unthread ticket ID */ - async getTicketByTicketId(ticketId) { + async getTicketByTicketId(ticketId: string): Promise { return await this.storage.get(`ticket:unthread:${ticketId}`); } /** * Get all tickets for a specific chat */ - async getTicketsForChat(chatId) { - const chatTickets = await this.storage.get(`chat:tickets:${chatId}`); - if (!chatTickets) return []; + async getTicketsForChat(chatId: number): Promise { + const chatTickets: TicketInfo[] = await this.storage.get(`chat:tickets:${chatId}`) || []; // Get full ticket data for each ticket const ticketPromises = chatTickets.map(ticketInfo => @@ -192,43 +204,97 @@ export class BotsStore { ); const tickets = await Promise.all(ticketPromises); - return tickets.filter(ticket => ticket !== null); + return tickets.filter((ticket): ticket is TicketData => ticket !== null); } /** * Store user state for ongoing ticket creation */ - async storeUserState(telegramUserId, state) { - return await this.storage.set(`user:state:${telegramUserId}`, { - ...state, - updatedAt: new Date().toISOString() - }); + async storeUserState(telegramUserId: number, state: UserState): Promise { + try { + const stateData = { + ...state, + updatedAt: new Date().toISOString() + }; + + LogEngine.debug('Storing user state', { + telegramUserId, + key: `user:state:${telegramUserId}`, + state: JSON.stringify(stateData) + }); + + await this.storage.set(`user:state:${telegramUserId}`, stateData); + + LogEngine.debug('User state stored successfully', { telegramUserId }); + return true; + } catch (error) { + const err = error as Error; + LogEngine.error('Failed to store user state', { + error: err.message, + stack: err.stack, + telegramUserId + }); + return false; + } } /** * Get user state for ongoing ticket creation */ - async getUserState(telegramUserId) { - return await this.storage.get(`user:state:${telegramUserId}`); + async getUserState(telegramUserId: number): Promise { + try { + LogEngine.debug('Getting user state', { + telegramUserId, + key: `user:state:${telegramUserId}` + }); + + const state = await this.storage.get(`user:state:${telegramUserId}`); + + LogEngine.debug('User state retrieved', { + telegramUserId, + found: !!state, + state: state ? JSON.stringify(state) : 'null' + }); + + return state; + } catch (error) { + const err = error as Error; + LogEngine.error('Failed to get user state', { + error: err.message, + stack: err.stack, + telegramUserId + }); + return null; + } } /** * Clear user state */ - async clearUserState(telegramUserId) { - return await this.storage.delete(`user:state:${telegramUserId}`); + async clearUserState(telegramUserId: number): Promise { + try { + await this.storage.delete(`user:state:${telegramUserId}`); + return true; + } catch (error) { + const err = error as Error; + LogEngine.error('Failed to clear user state', { + error: err.message, + telegramUserId + }); + return false; + } } /** * Store customer mapping (Telegram chat to Unthread customer) */ - async storeCustomer(customerData) { - const { chatId, unthreadCustomerId, chatTitle, customerName } = customerData; + async storeCustomer(customerData: CustomerData): Promise { + const { telegramChatId, unthreadCustomerId, name, company } = customerData; - const enrichedCustomerData = { + const enrichedCustomerData: CustomerData = { ...customerData, - storedAt: new Date().toISOString(), - platform: 'telegram' + createdAt: customerData.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString() }; try { @@ -237,15 +303,16 @@ export class BotsStore { this.storage.set(`customer:id:${unthreadCustomerId}`, enrichedCustomerData), // Lookup by chat ID for quick access - this.storage.set(`customer:telegram:${chatId}`, enrichedCustomerData) + this.storage.set(`customer:telegram:${telegramChatId}`, enrichedCustomerData) ]); - LogEngine.info(`Customer stored: ${customerName || chatTitle} (${unthreadCustomerId})`); + LogEngine.info(`Customer stored: ${name || company} (${unthreadCustomerId})`); return true; } catch (error) { + const err = error as Error; LogEngine.error('Failed to store customer', { - error: error.message, - stack: error.stack + error: err.message, + stack: err.stack }); return false; } @@ -254,37 +321,41 @@ export class BotsStore { /** * Get customer by Unthread customer ID (primary identifier) */ - async getCustomerById(customerId) { + async getCustomerById(customerId: string): Promise { return await this.storage.get(`customer:id:${customerId}`); } /** * Get customer by Telegram chat ID */ - async getCustomerByChatId(chatId) { + async getCustomerByChatId(chatId: number): Promise { return await this.storage.get(`customer:telegram:${chatId}`); } /** * Store user information */ - async storeUser(userData) { - const { telegramUserId, telegramUsername, unthreadName, unthreadEmail } = userData; + async storeUser(userData: UserData): Promise { + const { telegramUserId, username, firstName, lastName } = userData; - const enrichedUserData = { + const enrichedUserData: UserData = { ...userData, - storedAt: new Date().toISOString(), - platform: 'telegram' + createdAt: userData.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString() }; try { // Primary lookup by Telegram user ID await this.storage.set(`user:telegram:${telegramUserId}`, enrichedUserData); - LogEngine.info(`User stored: ${unthreadName} (${telegramUserId})`); + LogEngine.info(`User stored: ${firstName} ${lastName || ''} (${telegramUserId})`); return true; } catch (error) { - LogEngine.error('Failed to store user:', error); + const err = error as Error; + LogEngine.error('Failed to store user', { + error: err.message, + telegramUserId + }); return false; } } @@ -292,14 +363,14 @@ export class BotsStore { /** * Get user by Telegram user ID (primary identifier) */ - async getUserByTelegramId(telegramUserId) { + async getUserByTelegramId(telegramUserId: number): Promise { return await this.storage.get(`user:telegram:${telegramUserId}`); } /** * Get customer by Unthread customer ID (legacy method for backwards compatibility) */ - async getCustomerByUnthreadId(unthreadCustomerId) { + async getCustomerByUnthreadId(unthreadCustomerId: string): Promise { // Try new format first, then fall back to old format const customer = await this.storage.get(`customer:id:${unthreadCustomerId}`); if (customer) return customer; @@ -309,14 +380,12 @@ export class BotsStore { /** * Get or create customer for chat ID with proper cache hierarchy - * This method encapsulates the complete cache-first logic - * - * @param {number} chatId - Telegram chat ID - * @param {string} chatTitle - Chat title for new customer creation - * @param {function} createCustomerFn - Function to create new customer if not found - * @returns {object} - Customer data with unthreadCustomerId */ - async getOrCreateCustomer(chatId, chatTitle, createCustomerFn) { + async getOrCreateCustomer( + chatId: number, + chatTitle: string, + createCustomerFn: (title: string) => Promise<{ id: string }> + ): Promise { try { // Step 1: Try to get existing customer (uses cache hierarchy automatically) const existingCustomer = await this.getCustomerByChatId(chatId); @@ -332,25 +401,25 @@ export class BotsStore { const unthreadCustomerId = newCustomerResponse.id; // Step 3: Store new customer (populates all cache layers) - const customerData = { - chatId, + const customerData: CustomerData = { + id: unthreadCustomerId, unthreadCustomerId, - chatTitle + telegramChatId: chatId, + company: chatTitle, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() }; await this.storeCustomer(customerData); LogEngine.info(`Created and cached new customer: ${unthreadCustomerId}`); - return { - ...customerData, - storedAt: new Date().toISOString(), - platform: 'telegram' - }; + return customerData; } catch (error) { + const err = error as Error; LogEngine.error(`Error in getOrCreateCustomer for chat ${chatId}`, { - error: error.message, - stack: error.stack + error: err.message, + stack: err.stack }); throw error; } @@ -358,55 +427,27 @@ export class BotsStore { /** * Check if customer exists in cache (fast check without creating) - * - * @param {number} chatId - Telegram chat ID - * @returns {boolean} - True if customer exists in cache */ - async hasCustomer(chatId) { + async hasCustomer(chatId: number): Promise { try { const customer = await this.getCustomerByChatId(chatId); return !!customer; } catch (error) { + const err = error as Error; LogEngine.error(`Error checking customer existence for chat ${chatId}`, { - error: error.message, - stack: error.stack + error: err.message, + stack: err.stack }); return false; } } - /** - * Get cache statistics for customers - * - * @returns {object} - Cache statistics - */ - async getCustomerCacheStats() { - const storageStats = this.storage.getStats(); - - // Count customer keys in memory cache - let customerKeysInMemory = 0; - if (this.storage.memoryCache) { - for (const key of this.storage.memoryCache.keys()) { - if (key.startsWith('customer:')) { - customerKeysInMemory++; - } - } - } - - return { - ...storageStats, - customerKeysInMemory, - cacheHierarchy: 'Memory β†’ Redis β†’ PostgreSQL', - sdkVersion: 'bots-brain-1.0.0' - }; - } - /** * Helper: Add ticket to chat's ticket list */ - async addToChatTickets(chatId, messageId, conversationId) { + private async addToChatTickets(chatId: number, messageId: number, conversationId: string): Promise { const key = `chat:tickets:${chatId}`; - const existingTickets = await this.storage.get(key) || []; + const existingTickets: TicketInfo[] = await this.storage.get(key) || []; // Check if ticket already exists const ticketExists = existingTickets.some(t => t.conversationId === conversationId); @@ -414,7 +455,7 @@ export class BotsStore { existingTickets.push({ messageId, conversationId, - addedAt: new Date().toISOString() + friendlyId: '', // Will be filled from ticket data if needed }); await this.storage.set(key, existingTickets); @@ -424,7 +465,7 @@ export class BotsStore { /** * Delete ticket and all its mappings */ - async deleteTicket(conversationId) { + async deleteTicket(conversationId: string): Promise { try { // Get ticket data first to know all the keys to delete const ticket = await this.getTicketByConversationId(conversationId); @@ -448,9 +489,10 @@ export class BotsStore { LogEngine.info(`Ticket deleted: ${ticket.friendlyId}`); return true; } catch (error) { + const err = error as Error; LogEngine.error('Failed to delete ticket', { - error: error.message, - stack: error.stack + error: err.message, + stack: err.stack }); return false; } @@ -459,32 +501,18 @@ export class BotsStore { /** * Helper: Remove ticket from chat's ticket list */ - async removeFromChatTickets(chatId, conversationId) { + private async removeFromChatTickets(chatId: number, conversationId: string): Promise { const key = `chat:tickets:${chatId}`; - const existingTickets = await this.storage.get(key) || []; + const existingTickets: TicketInfo[] = await this.storage.get(key) || []; const filteredTickets = existingTickets.filter(t => t.conversationId !== conversationId); await this.storage.set(key, filteredTickets); } - /** - * Get storage statistics - */ - async getStats() { - const storageStats = this.storage.getStats(); - - return { - ...storageStats, - sdk: 'bots-brain', - version: '1.0.0' - }; - } - /** * Store agent message data for reply tracking - * This allows us to track when users reply to agent messages */ - async storeAgentMessage(agentMessageData) { + async storeAgentMessage(agentMessageData: AgentMessageData): Promise { const { messageId, // Telegram message ID of the agent message conversationId, // Unthread conversation ID @@ -507,9 +535,10 @@ export class BotsStore { LogEngine.info(`Agent message stored: ${messageId} for conversation ${conversationId}`); return true; } catch (error) { + const err = error as Error; LogEngine.error('Failed to store agent message', { - error: error.message, - stack: error.stack, + error: err.message, + stack: err.stack, messageId, conversationId }); @@ -520,39 +549,60 @@ export class BotsStore { /** * Get agent message data by Telegram message ID */ - async getAgentMessageByTelegramId(messageId) { + async getAgentMessage(messageId: number): Promise { return await this.storage.get(`agent_message:telegram:${messageId}`); } /** * Static methods for agent message tracking */ - static async storeAgentMessage(agentMessageData) { + static async storeAgentMessage(agentMessageData: AgentMessageData): Promise { return BotsStore.getInstance().storeAgentMessage(agentMessageData); } - static async getAgentMessageByTelegramId(messageId) { - return BotsStore.getInstance().getAgentMessageByTelegramId(messageId); + static async getAgentMessageByTelegramId(messageId: number): Promise { + return BotsStore.getInstance().getAgentMessage(messageId); } // Static methods for cache-aware customer management - static async getOrCreateCustomer(chatId, chatTitle, createCustomerFn) { + static async getOrCreateCustomer( + chatId: number, + chatTitle: string, + createCustomerFn: (title: string) => Promise<{ id: string }> + ): Promise { return BotsStore.getInstance().getOrCreateCustomer(chatId, chatTitle, createCustomerFn); } - static async getCustomerByChatId(chatId) { - return BotsStore.getInstance().getCustomerByChatId(chatId); + static async hasCustomer(chatId: number): Promise { + return BotsStore.getInstance().hasCustomer(chatId); } - static async storeCustomer(customerData) { - return BotsStore.getInstance().storeCustomer(customerData); + static async getCustomerCacheStats(): Promise { + const instance = BotsStore.getInstance(); + const storageStats = instance.storage.getStats(); + + return { + ...storageStats, + cacheHierarchy: 'Memory β†’ Redis β†’ PostgreSQL', + sdkVersion: 'bots-brain-1.0.0' + }; } - static async hasCustomer(chatId) { - return BotsStore.getInstance().hasCustomer(chatId); + // Memory inspection methods + static async getMemoryStats(): Promise { + return BotsStore.getInstance().storage.getMemoryStats(); + } + + static async getMemoryContents(): Promise { + return BotsStore.getInstance().storage.getMemoryContents(); + } + + static async getMemoryContentsByPattern(pattern: string): Promise { + const allContents = BotsStore.getInstance().storage.getMemoryContents(); + return allContents.filter((entry: any) => entry.key.includes(pattern)); } - static async getCustomerCacheStats() { - return BotsStore.getInstance().getCustomerCacheStats(); + static async cleanupExpiredMemory(): Promise { + return BotsStore.getInstance().storage.cleanupExpiredMemory(); } } diff --git a/src/sdk/bots-brain/UnifiedStorage.js b/src/sdk/bots-brain/UnifiedStorage.js deleted file mode 100644 index c868cec..0000000 --- a/src/sdk/bots-brain/UnifiedStorage.js +++ /dev/null @@ -1,267 +0,0 @@ -import { createClient } from 'redis'; -import pkg from 'pg'; -const { Pool } = pkg; -import { LogEngine } from '@wgtechlabs/log-engine'; - -/** - * UnifiedStorage - Multi-layer storage architecture - * Layer 1: Memory cache (24hr TTL) - fastest access - * Layer 2: Redis cache (3-day TTL) - fast distributed cache - * Layer 3: PostgreSQL (permanent) - persistent storage - */ -export class UnifiedStorage { - constructor(config) { - // Layer 1: Memory cache with TTL - this.memoryCache = new Map(); - this.memoryCacheTTL = new Map(); // Store expiration times - this.memoryTTL = config.memoryTTL || 24 * 60 * 60 * 1000; // 24 hours default - - // Layer 2: Redis configuration - this.redisConfig = { - url: config.redisUrl, - ttl: config.redisTTL || 3 * 24 * 60 * 60 // 3 days default - }; - this.redisClient = null; - - // Layer 3: PostgreSQL configuration - this.dbConfig = config.postgres; - this.db = null; - - // Connection status - this.connected = false; - - // Start memory cleanup interval - this.startMemoryCleanup(); - } - - async connect() { - try { - // Connect to Redis (optional) - if (this.redisConfig.url) { - try { - this.redisClient = createClient({ url: this.redisConfig.url }); - await this.redisClient.connect(); - LogEngine.info('Redis connected for bots-brain'); - } catch (error) { - LogEngine.warn('Redis not available, using Memory + PostgreSQL only'); - this.redisClient = null; - } - } else { - LogEngine.info('Redis URL not provided, using Memory + PostgreSQL only'); - } - - // Connect to PostgreSQL - if (this.dbConfig) { - // If dbConfig is already a Pool instance, use it directly - if (this.dbConfig.query && typeof this.dbConfig.query === 'function') { - this.db = this.dbConfig; - } else { - // Otherwise create a new Pool with the config - this.db = new Pool(this.dbConfig); - } - await this.db.query('SELECT 1'); // Test connection - LogEngine.info('PostgreSQL connected for bots-brain'); - } - - this.connected = true; - LogEngine.info('UnifiedStorage initialized with multi-layer architecture'); - } catch (error) { - LogEngine.error('UnifiedStorage connection failed', { - error: error.message, - stack: error.stack - }); - throw error; - } - } - - async disconnect() { - if (this.redisClient) { - await this.redisClient.quit(); - } - // Only close the database pool if we created it ourselves - // If it was passed in as an existing pool, let the caller manage it - if (this.db && this.dbConfig && !this.dbConfig.query) { - await this.db.end(); - } - this.connected = false; - LogEngine.info('UnifiedStorage disconnected'); - } - - /** - * Get value from storage (Memory β†’ Redis β†’ PostgreSQL) - */ - async get(key) { - try { - // Layer 1: Check memory cache first - const memoryCached = this.getFromMemory(key); - if (memoryCached !== null) { - return memoryCached; - } - - // Layer 2: Check Redis cache - if (this.redisClient) { - const redisCached = await this.redisClient.get(key); - if (redisCached) { - const value = JSON.parse(redisCached); - // Store back in memory for next time - this.setInMemory(key, value); - return value; - } - } - - // Layer 3: Check PostgreSQL - if (this.db) { - const pgValue = await this.getFromPostgres(key); - if (pgValue !== null) { - // Store back in Redis and memory for next time - if (this.redisClient) { - await this.redisClient.setEx(key, this.redisConfig.ttl, JSON.stringify(pgValue)); - } - this.setInMemory(key, pgValue); - return pgValue; - } - } - - return null; - } catch (error) { - LogEngine.error(`Error getting ${key}`, { - error: error.message, - stack: error.stack - }); - return null; - } - } - - /** - * Set value in all storage layers - */ - async set(key, value) { - try { - // Store in all layers - this.setInMemory(key, value); - - if (this.redisClient) { - await this.redisClient.setEx(key, this.redisConfig.ttl, JSON.stringify(value)); - } - - if (this.db) { - await this.setInPostgres(key, value); - } - - return true; - } catch (error) { - LogEngine.error(`Error setting ${key}`, { - error: error.message, - stack: error.stack - }); - return false; - } - } - - /** - * Delete from all storage layers - */ - async delete(key) { - try { - // Delete from all layers - this.memoryCache.delete(key); - this.memoryCacheTTL.delete(key); - - if (this.redisClient) { - await this.redisClient.del(key); - } - - if (this.db) { - await this.deleteFromPostgres(key); - } - - return true; - } catch (error) { - LogEngine.error(`Error deleting ${key}`, { - error: error.message, - stack: error.stack - }); - return false; - } - } - - // Memory cache operations - getFromMemory(key) { - const expiration = this.memoryCacheTTL.get(key); - if (expiration && Date.now() > expiration) { - // Expired, clean up - this.memoryCache.delete(key); - this.memoryCacheTTL.delete(key); - return null; - } - return this.memoryCache.get(key) || null; - } - - setInMemory(key, value) { - this.memoryCache.set(key, value); - this.memoryCacheTTL.set(key, Date.now() + this.memoryTTL); - } - - // PostgreSQL operations using key-value table - async getFromPostgres(key) { - try { - const result = await this.db.query( - 'SELECT value FROM storage_cache WHERE key = $1 AND expires_at > NOW()', - [key] - ); - return result.rows.length > 0 ? JSON.parse(result.rows[0].value) : null; - } catch (error) { - // Table might not exist, that's okay for now - return null; - } - } - - async setInPostgres(key, value) { - try { - const expiresAt = new Date(Date.now() + (this.redisConfig.ttl * 1000)); - await this.db.query(` - INSERT INTO storage_cache (key, value, expires_at) - VALUES ($1, $2, $3) - ON CONFLICT (key) - DO UPDATE SET value = $2, expires_at = $3, updated_at = NOW() - `, [key, JSON.stringify(value), expiresAt]); - } catch (error) { - // Table might not exist, that's okay for now - LogEngine.debug('storage_cache table not found, using Redis + Memory only'); - } - } - - async deleteFromPostgres(key) { - try { - await this.db.query('DELETE FROM storage_cache WHERE key = $1', [key]); - } catch (error) { - // Table might not exist, that's okay - } - } - - // Memory cleanup - startMemoryCleanup() { - setInterval(() => { - const now = Date.now(); - for (const [key, expiration] of this.memoryCacheTTL.entries()) { - if (now > expiration) { - this.memoryCache.delete(key); - this.memoryCacheTTL.delete(key); - } - } - }, 60000); // Clean up every minute - } - - // Utility methods - getStats() { - return { - memoryKeys: this.memoryCache.size, - connected: this.connected, - layers: { - memory: true, - redis: !!this.redisClient, - postgres: !!this.db - } - }; - } -} diff --git a/src/sdk/bots-brain/UnifiedStorage.ts b/src/sdk/bots-brain/UnifiedStorage.ts new file mode 100644 index 0000000..0768ca1 --- /dev/null +++ b/src/sdk/bots-brain/UnifiedStorage.ts @@ -0,0 +1,439 @@ +import { createClient, RedisClientType } from 'redis'; +import pkg from 'pg'; +const { Pool } = pkg; +import type { Pool as PoolType } from 'pg'; +import { LogEngine } from '@wgtechlabs/log-engine'; +import type { StorageConfig, Storage } from '../types.js'; + +/** + * UnifiedStorage - Multi-layer storage architecture + * Layer 1: Memory cache (24hr TTL) - fastest access + * Layer 2: Redis cache (3-day TTL) - fast distributed cache + * Layer 3: PostgreSQL (permanent) - persistent storage + */ +export class UnifiedStorage implements Storage { + private memoryCache: Map; + private memoryCacheTTL: Map; + private memoryTTL: number; + private redisConfig: { url?: string; ttl: number }; + private redisClient: RedisClientType | null; + private dbConfig: any; + private db: PoolType | null; + private connected: boolean; + private cleanupInterval: NodeJS.Timeout | null; + + constructor(config: StorageConfig) { + // Layer 1: Memory cache with TTL + this.memoryCache = new Map(); + this.memoryCacheTTL = new Map(); + this.memoryTTL = 24 * 60 * 60 * 1000; // 24 hours default + + // Layer 2: Redis configuration + this.redisConfig = { + url: config.redisUrl || '', + ttl: 3 * 24 * 60 * 60 // 3 days default + }; + this.redisClient = null; + + // Layer 3: PostgreSQL configuration + this.dbConfig = config.postgres; + this.db = null; + + // Connection status + this.connected = false; + this.cleanupInterval = null; + + // Start memory cleanup interval + this.startMemoryCleanup(); + } + + async connect(): Promise { + try { + // Connect to Redis (optional) + if (this.redisConfig.url) { + try { + this.redisClient = createClient({ url: this.redisConfig.url }); + await this.redisClient.connect(); + LogEngine.info('Redis connected for bots-brain'); + } catch (error) { + LogEngine.warn('Redis not available, using Memory + PostgreSQL only'); + this.redisClient = null; + } + } else { + LogEngine.info('Redis URL not provided, using Memory + PostgreSQL only'); + } + + // Connect to PostgreSQL + if (this.dbConfig) { + // If dbConfig is already a Pool instance, use it directly + if (this.dbConfig.query && typeof this.dbConfig.query === 'function') { + this.db = this.dbConfig; + } else { + // Otherwise create a new Pool with the config + this.db = new Pool(this.dbConfig); + } + if (this.db) { + await this.db.query('SELECT 1'); // Test connection + } + LogEngine.info('PostgreSQL connected for bots-brain'); + } + + this.connected = true; + LogEngine.info('UnifiedStorage initialized with multi-layer architecture'); + } catch (error) { + const err = error as Error; + LogEngine.error('UnifiedStorage connection failed', { + error: err.message, + stack: err.stack + }); + throw error; + } + } + + async disconnect(): Promise { + if (this.redisClient) { + await this.redisClient.quit(); + } + // Only close the database pool if we created it ourselves + // If it was passed in as an existing pool, let the caller manage it + if (this.db && this.dbConfig && !this.dbConfig.query) { + await this.db.end(); + } + + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + this.connected = false; + LogEngine.info('UnifiedStorage disconnected'); + } + + /** + * Get value from storage (Memory β†’ Redis β†’ PostgreSQL) + */ + async get(key: string): Promise { + try { + // Layer 1: Check memory cache first + const memoryCached = this.getFromMemory(key); + if (memoryCached !== null) { + return memoryCached; + } + + // Layer 2: Check Redis cache + if (this.redisClient) { + const redisCached = await this.redisClient.get(key); + if (redisCached) { + const value = JSON.parse(redisCached); + // Store back in memory for next time + this.setInMemory(key, value); + return value; + } + } + + // Layer 3: Check PostgreSQL + if (this.db) { + const pgValue = await this.getFromPostgres(key); + if (pgValue !== null) { + // Store back in Redis and memory for next time + if (this.redisClient) { + await this.redisClient.setEx(key, this.redisConfig.ttl, JSON.stringify(pgValue)); + } + this.setInMemory(key, pgValue); + return pgValue; + } + } + + return null; + } catch (error) { + const err = error as Error; + LogEngine.error(`Error getting ${key}`, { + error: err.message, + stack: err.stack + }); + return null; + } + } + + /** + * Set value in all storage layers + */ + async set(key: string, value: any, ttl?: number): Promise { + try { + // Store in all layers + this.setInMemory(key, value); + + if (this.redisClient) { + const redisTTL = ttl || this.redisConfig.ttl; + await this.redisClient.setEx(key, redisTTL, JSON.stringify(value)); + } + + if (this.db) { + await this.setInPostgres(key, value, ttl); + } + } catch (error) { + const err = error as Error; + LogEngine.error(`Error setting ${key}`, { + error: err.message, + stack: err.stack + }); + throw error; + } + } + + /** + * Delete from all storage layers + */ + async delete(key: string): Promise { + try { + // Delete from all layers + this.memoryCache.delete(key); + this.memoryCacheTTL.delete(key); + + if (this.redisClient) { + await this.redisClient.del(key); + } + + if (this.db) { + await this.deleteFromPostgres(key); + } + } catch (error) { + const err = error as Error; + LogEngine.error(`Error deleting ${key}`, { + error: err.message, + stack: err.stack + }); + throw error; + } + } + + /** + * Check if key exists in any storage layer + */ + async exists(key: string): Promise { + try { + // Check memory first + if (this.getFromMemory(key) !== null) { + return true; + } + + // Check Redis + if (this.redisClient) { + const exists = await this.redisClient.exists(key); + if (exists) return true; + } + + // Check PostgreSQL + if (this.db) { + const pgValue = await this.getFromPostgres(key); + return pgValue !== null; + } + + return false; + } catch (error) { + const err = error as Error; + LogEngine.error(`Error checking existence of ${key}`, { + error: err.message, + stack: err.stack + }); + return false; + } + } + + // Memory cache operations + private getFromMemory(key: string): any { + const expiration = this.memoryCacheTTL.get(key); + if (expiration && Date.now() > expiration) { + // Expired, clean up + this.memoryCache.delete(key); + this.memoryCacheTTL.delete(key); + return null; + } + return this.memoryCache.get(key) || null; + } + + private setInMemory(key: string, value: any): void { + this.memoryCache.set(key, value); + this.memoryCacheTTL.set(key, Date.now() + this.memoryTTL); + } + + // PostgreSQL operations using key-value table + private async getFromPostgres(key: string): Promise { + try { + const result = await this.db!.query( + 'SELECT value FROM storage_cache WHERE key = $1 AND expires_at > NOW()', + [key] + ); + return result.rows.length > 0 ? JSON.parse(result.rows[0].value) : null; + } catch (error) { + // Table might not exist, that's okay for now + return null; + } + } + + private async setInPostgres(key: string, value: any, ttl?: number): Promise { + try { + const expiresAt = new Date(Date.now() + ((ttl || this.redisConfig.ttl) * 1000)); + await this.db!.query(` + INSERT INTO storage_cache (key, value, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (key) + DO UPDATE SET value = $2, expires_at = $3, updated_at = NOW() + `, [key, JSON.stringify(value), expiresAt]); + } catch (error) { + // Table might not exist, that's okay for now + LogEngine.debug('storage_cache table not found, using Redis + Memory only'); + } + } + + private async deleteFromPostgres(key: string): Promise { + try { + await this.db!.query('DELETE FROM storage_cache WHERE key = $1', [key]); + } catch (error) { + // Table might not exist, that's okay + } + } + + // Memory cleanup + private startMemoryCleanup(): void { + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, expiration] of this.memoryCacheTTL.entries()) { + if (now > expiration) { + this.memoryCache.delete(key); + this.memoryCacheTTL.delete(key); + } + } + }, 60000); // Clean up every minute + } + + // Utility methods + getStats(): { + memoryKeys: number; + connected: boolean; + layers: { + memory: boolean; + redis: boolean; + postgres: boolean; + }; + } { + return { + memoryKeys: this.memoryCache.size, + connected: this.connected, + layers: { + memory: true, + redis: !!this.redisClient, + postgres: !!this.db + } + }; + } + + // New methods for inspecting in-memory storage + getMemoryContents(): Array<{ + key: string; + value: any; + expiresAt: string; + isExpired: boolean; + size: number; + }> { + const now = Date.now(); + const contents: Array<{ + key: string; + value: any; + expiresAt: string; + isExpired: boolean; + size: number; + }> = []; + + for (const [key, value] of this.memoryCache.entries()) { + const expiration = this.memoryCacheTTL.get(key); + const isExpired = expiration ? now > expiration : false; + + contents.push({ + key, + value, + expiresAt: expiration ? new Date(expiration).toISOString() : 'never', + isExpired, + size: JSON.stringify(value).length + }); + } + + return contents; + } + + getMemoryStats(): { + totalKeys: number; + activeKeys: number; + expiredKeys: number; + totalSizeBytes: number; + totalSizeKB: number; + keyTypes: Record; + memoryTTL: number; + connected: boolean; + layers: { + memory: boolean; + redis: boolean; + postgres: boolean; + }; + } { + const now = Date.now(); + let totalSize = 0; + let expiredCount = 0; + let activeCount = 0; + const keyTypes: Record = {}; + + for (const [key, value] of this.memoryCache.entries()) { + const expiration = this.memoryCacheTTL.get(key); + const isExpired = expiration ? now > expiration : false; + const size = JSON.stringify(value).length; + + totalSize += size; + + if (isExpired) { + expiredCount++; + } else { + activeCount++; + } + + // Categorize by key prefix + const keyType = key.split(':')[0] || 'unknown'; + if (!keyTypes[keyType]) { + keyTypes[keyType] = { count: 0, size: 0 }; + } + keyTypes[keyType].count++; + keyTypes[keyType].size += size; + } + + return { + totalKeys: this.memoryCache.size, + activeKeys: activeCount, + expiredKeys: expiredCount, + totalSizeBytes: totalSize, + totalSizeKB: Math.round(totalSize / 1024 * 100) / 100, + keyTypes, + memoryTTL: this.memoryTTL, + connected: this.connected, + layers: { + memory: true, + redis: !!this.redisClient, + postgres: !!this.db + } + }; + } + + // Clean up expired memory entries manually + cleanupExpiredMemory(): number { + const now = Date.now(); + let cleanedCount = 0; + + for (const [key, expiration] of this.memoryCacheTTL.entries()) { + if (now > expiration) { + this.memoryCache.delete(key); + this.memoryCacheTTL.delete(key); + cleanedCount++; + } + } + + return cleanedCount; + } +} diff --git a/src/sdk/bots-brain/index.js b/src/sdk/bots-brain/index.ts similarity index 100% rename from src/sdk/bots-brain/index.js rename to src/sdk/bots-brain/index.ts diff --git a/src/sdk/types.ts b/src/sdk/types.ts new file mode 100644 index 0000000..ce9d9e8 --- /dev/null +++ b/src/sdk/types.ts @@ -0,0 +1,171 @@ +import type { Pool } from 'pg'; + +/** + * SDK Type Definitions + * Core interfaces for the bots-brain and unthread-webhook SDKs + */ + +// Database connection interface +export interface DatabaseConnection { + readonly connectionPool: Pool; // PostgreSQL pool accessor + query(text: string, params?: any[]): Promise; +} + +// Storage interfaces +export interface StorageConfig { + postgres?: Pool; + redisUrl?: string | undefined; +} + +export interface Storage { + get(key: string): Promise; + set(key: string, value: any, ttl?: number): Promise; + delete(key: string): Promise; + exists(key: string): Promise; + connect(): Promise; + disconnect(): Promise; +} + +// Ticket data structures +export interface TicketData { + chatId: number; + messageId: number; + conversationId: string; + ticketId: string; + friendlyId: string; + telegramUserId: number; + createdAt: string; + customerId?: string; + summary?: string; + status?: string; + platform?: string; + storedAt?: string; + version?: string; + metadata?: Record; +} + +export interface TicketInfo { + messageId: number; + conversationId: string; + friendlyId: string; +} + +// Customer data structures +export interface CustomerData { + id: string; + unthreadCustomerId: string; + telegramChatId: number; + chatId?: number; + chatTitle?: string; + customerName?: string; + email?: string; + name?: string; + company?: string; + createdAt: string; + updatedAt: string; +} + +// User data structures +export interface UserData { + id: string; + telegramUserId: number; + telegramUsername?: string; + unthreadName?: string; + unthreadEmail?: string; + username?: string; + firstName?: string; + lastName?: string; + email?: string; + createdAt: string; + updatedAt: string; +} + +// User state for conversations +export interface UserState { + currentField?: string; + field?: string; + ticket?: any; + [key: string]: any; +} + +// Agent message data +export interface AgentMessageData { + messageId: number; + conversationId: string; + chatId: number; + friendlyId: string; + originalTicketMessageId: number; + sentAt: string; +} + +// Webhook event interfaces +export interface WebhookEvent { + type: 'message_created' | 'conversation_updated'; + sourcePlatform: 'dashboard'; + timestamp: string; + data: MessageData | ConversationData; +} + +export interface MessageCreatedEvent extends WebhookEvent { + type: 'message_created'; + data: MessageData; +} + +export interface ConversationUpdatedEvent extends WebhookEvent { + type: 'conversation_updated'; + data: ConversationData; +} + +export interface MessageData { + conversationId?: string; + id?: string; + content?: string; + text?: string; + [key: string]: any; +} + +export interface ConversationData { + conversationId?: string; + id?: string; + status?: string; + [key: string]: any; +} + +export interface WebhookConsumerConfig { + redisUrl: string; + queueName: string; +} + +export type EventHandler = (event: WebhookEvent) => Promise; + +// BotsStore interface +export interface IBotsStore { + storage: Storage; + + // Ticket operations + storeTicket(ticketData: TicketData): Promise; + getTicketByConversationId(conversationId: string): Promise; + getTicketByMessageId(messageId: number): Promise; + getTicketByFriendlyId(friendlyId: string): Promise; + getTicketByTicketId(ticketId: string): Promise; + getTicketsForChat(chatId: number): Promise; + deleteTicket(conversationId: string): Promise; + + // User state operations + storeUserState(telegramUserId: number, state: UserState): Promise; + getUserState(telegramUserId: number): Promise; + clearUserState(telegramUserId: number): Promise; + + // Customer operations + storeCustomer(customerData: CustomerData): Promise; + getCustomerById(customerId: string): Promise; + getCustomerByChatId(chatId: number): Promise; + + // User operations + storeUser(userData: UserData): Promise; + getUserByTelegramId(telegramUserId: number): Promise; + + // Agent message operations + storeAgentMessage(messageData: AgentMessageData): Promise; + getAgentMessage(messageId: number): Promise; +} diff --git a/src/sdk/unthread-webhook/EventValidator.js b/src/sdk/unthread-webhook/EventValidator.js deleted file mode 100644 index f5a9e57..0000000 --- a/src/sdk/unthread-webhook/EventValidator.js +++ /dev/null @@ -1,59 +0,0 @@ -import { LogEngine } from '@wgtechlabs/log-engine'; - -/** - * EventValidator - Simple validation for Unthread webhook events - * - * Validates message_created events from dashboard for agent responses. - */ - -export class EventValidator { - /** - * Validate message_created event structure - * @param {Object} event - The webhook event to validate - * @returns {boolean} - True if valid - */ - static validate(event) { - LogEngine.debug('πŸ” EventValidator: Starting validation for event:', { event }); - - // Check each condition individually for detailed logging - const hasEvent = !!event; - LogEngine.debug('βœ… Has event object:', { hasEvent }); - - if (!hasEvent) return false; - - const hasCorrectType = event.type === 'message_created'; - LogEngine.debug('βœ… Type is message_created:', { hasCorrectType, actual: event.type }); - - const hasCorrectPlatform = event.sourcePlatform === 'dashboard'; - LogEngine.debug('βœ… Source is dashboard:', { hasCorrectPlatform, actual: event.sourcePlatform }); - - const hasData = !!event.data; - LogEngine.debug('βœ… Has data object:', { hasData }); - - if (!hasData) return false; - - // Log the actual data structure for debugging - LogEngine.debug('πŸ” Event data structure:', { data: event.data }); - - const hasConversationId = !!event.data.conversationId; - LogEngine.debug('βœ… Has conversationId:', { hasConversationId, actual: event.data.conversationId }); - - // Check for both 'content' and 'text' fields (webhook server sends 'text') - const hasContent = !!(event.data.content || event.data.text); - LogEngine.debug('βœ… Has content/text:', { hasContent, content: event.data.content, text: event.data.text }); - - // Additional checks for debugging - if (!hasConversationId) { - LogEngine.warn('❌ Missing conversationId - data keys:', { keys: Object.keys(event.data || {}) }); - } - - if (!hasContent) { - LogEngine.warn('❌ Missing content/text - data keys:', { keys: Object.keys(event.data || {}) }); - } - - const isValid = hasEvent && hasCorrectType && hasCorrectPlatform && hasData && hasConversationId && hasContent; - LogEngine.info('🎯 Final validation result:', { isValid }); - - return isValid; - } -} diff --git a/src/sdk/unthread-webhook/EventValidator.ts b/src/sdk/unthread-webhook/EventValidator.ts new file mode 100644 index 0000000..5027cb1 --- /dev/null +++ b/src/sdk/unthread-webhook/EventValidator.ts @@ -0,0 +1,123 @@ +import { LogEngine } from '@wgtechlabs/log-engine'; +import type { WebhookEvent, MessageCreatedEvent, ConversationUpdatedEvent } from '../types.js'; + +/** + * EventValidator - Simple validation for Unthread webhook events + * + * Validates message_created and conversation_updated events from dashboard. + */ + +export class EventValidator { + /** + * Validate webhook event structure for supported event types + * @param event - The webhook event to validate + * @returns True if valid + */ + static validate(event: unknown): event is WebhookEvent { + // Perform all basic validation checks + const hasEvent = !!event && typeof event === 'object'; + if (!hasEvent) { + // Only log validation failures to reduce noise + LogEngine.warn('❌ Event validation failed: Invalid event object'); + return false; + } + + const eventObj = event as Record; + const hasCorrectType = ['message_created', 'conversation_updated'].includes(eventObj.type as string); + const hasCorrectPlatform = eventObj.sourcePlatform === 'dashboard'; + const hasData = !!eventObj.data && typeof eventObj.data === 'object'; + + // Early validation failure logging + if (!hasCorrectType || !hasCorrectPlatform || !hasData) { + LogEngine.warn('❌ Event validation failed: Basic structure invalid', { + type: eventObj.type, + sourcePlatform: eventObj.sourcePlatform, + hasData, + hasCorrectType, + hasCorrectPlatform + }); + return false; + } + + const data = eventObj.data as Record; + const hasConversationId = !!(data.conversationId || data.id); + + if (!hasConversationId) { + LogEngine.warn('❌ Event validation failed: Missing conversation ID', { + type: eventObj.type, + availableKeys: Object.keys(data || {}) + }); + return false; + } + + // Only log detailed validation info in verbose mode or for failures + if (process.env.LOG_LEVEL === 'debug' || process.env.VERBOSE_LOGGING === 'true') { + LogEngine.debug('πŸ” Event validation checks passed', { + type: eventObj.type, + sourcePlatform: eventObj.sourcePlatform, + conversationId: data.conversationId || data.id + }); + } + + // Validate based on event type + if (eventObj.type === 'message_created') { + // Check for both 'content' and 'text' fields (webhook server sends 'text') + const hasContent = !!(data.content || data.text); + + if (!hasContent) { + LogEngine.warn('❌ Message validation failed: Missing content/text', { + conversationId: data.conversationId || data.id, + availableKeys: Object.keys(data || {}) + }); + return false; + } + + // Success - only log in verbose mode + if (process.env.LOG_LEVEL === 'debug' || process.env.VERBOSE_LOGGING === 'true') { + LogEngine.debug('βœ… Message event validated successfully', { + conversationId: data.conversationId || data.id, + hasContent: true + }); + } + return true; + } + + if (eventObj.type === 'conversation_updated') { + // Check for status information + const hasStatus = !!(data.status); + const validStatus = hasStatus && typeof data.status === 'string' && ['open', 'closed'].includes((data.status as string).toLowerCase()); + + if (!hasStatus) { + LogEngine.warn('❌ Conversation validation failed: Missing status', { + conversationId: data.conversationId || data.id, + availableKeys: Object.keys(data || {}) + }); + return false; + } + + if (!validStatus) { + LogEngine.warn('❌ Conversation validation failed: Invalid status value', { + conversationId: data.conversationId || data.id, + status: data.status + }); + return false; + } + + // Success - log conversation update with redaction enabled (info level for business events) + LogEngine.info('βœ… Conversation updated event validated', { + conversationId: data.conversationId || data.id, + status: data.status, + eventType: eventObj.type, + sourcePlatform: eventObj.sourcePlatform, + timestamp: eventObj.timestamp, + // Use LogEngine's built-in redaction to safely log event data + eventData: eventObj.data + }); + + return true; + } + + LogEngine.warn('❌ Unsupported event type:', { type: eventObj.type }); + return false; + } +} diff --git a/src/sdk/unthread-webhook/WebhookConsumer.js b/src/sdk/unthread-webhook/WebhookConsumer.ts similarity index 57% rename from src/sdk/unthread-webhook/WebhookConsumer.js rename to src/sdk/unthread-webhook/WebhookConsumer.ts index abae5b2..0e19fb0 100644 --- a/src/sdk/unthread-webhook/WebhookConsumer.js +++ b/src/sdk/unthread-webhook/WebhookConsumer.ts @@ -1,6 +1,21 @@ -import { createClient } from 'redis'; +import { createClient, RedisClientType } from 'redis'; import { EventValidator } from './EventValidator.js'; import { LogEngine } from '@wgtechlabs/log-engine'; +import type { WebhookEvent } from '../types.js'; + +/** + * WebhookConsumer configuration + */ +export interface WebhookConsumerConfig { + redisUrl: string; + queueName?: string; + pollInterval?: number; +} + +/** + * Event handler function type + */ +export type EventHandler = (event: WebhookEvent) => Promise; /** * WebhookConsumer - Simple Redis queue consumer for Unthread webhook events @@ -9,25 +24,29 @@ import { LogEngine } from '@wgtechlabs/log-engine'; * Polls Redis queue, validates events, and routes message_created events to handlers. */ export class WebhookConsumer { - constructor(config) { + private redisUrl: string; + private queueName: string; + private pollInterval: number; + + // Event handlers map: "eventType:sourcePlatform" -> handler function + private eventHandlers: Map = new Map(); + + // Redis clients - separate clients for blocking and non-blocking operations + private redisClient: RedisClientType | null = null; + private blockingRedisClient: RedisClientType | null = null; // Dedicated client for blPop operations + private isRunning: boolean = false; + private pollTimer: NodeJS.Timeout | null = null; + + constructor(config: WebhookConsumerConfig) { this.redisUrl = config.redisUrl; this.queueName = config.queueName || 'unthread-events'; this.pollInterval = config.pollInterval || 1000; // 1 second default - - // Event handlers map: "eventType:sourcePlatform" -> handler function - this.eventHandlers = new Map(); - - // Redis clients - separate clients for blocking and non-blocking operations - this.redisClient = null; - this.blockingRedisClient = null; // Dedicated client for blPop operations - this.isRunning = false; - this.pollTimer = null; } /** * Initialize Redis connection */ - async connect() { + async connect(): Promise { try { if (!this.redisUrl) { throw new Error('Redis URL is required for webhook consumer'); @@ -52,7 +71,7 @@ export class WebhookConsumer { /** * Disconnect from Redis */ - async disconnect() { + async disconnect(): Promise { try { this.isRunning = false; @@ -78,11 +97,11 @@ export class WebhookConsumer { /** * Subscribe to a specific event type and platform - * @param {string} eventType - Type of event to listen for (e.g., 'message_created') - * @param {string} sourcePlatform - Source platform to filter by (e.g., 'dashboard') - * @param {Function} handler - Handler function to call for matching events + * @param eventType - Type of event to listen for (e.g., 'message_created') + * @param sourcePlatform - Source platform to filter by (e.g., 'dashboard') + * @param handler - Handler function to call for matching events */ - subscribe(eventType, sourcePlatform, handler) { + subscribe(eventType: string, sourcePlatform: string, handler: EventHandler): void { const key = `${eventType}:${sourcePlatform}`; this.eventHandlers.set(key, handler); LogEngine.info(`Subscribed to ${eventType} events from ${sourcePlatform}`); @@ -91,7 +110,7 @@ export class WebhookConsumer { /** * Start polling for events */ - async start() { + async start(): Promise { if (this.isRunning) { LogEngine.warn('Webhook consumer is already running'); return; @@ -108,7 +127,7 @@ export class WebhookConsumer { /** * Stop polling for events */ - async stop() { + async stop(): Promise { this.isRunning = false; await this.disconnect(); LogEngine.info('Webhook consumer stopped'); @@ -117,7 +136,7 @@ export class WebhookConsumer { /** * Schedule the next poll */ - scheduleNextPoll() { + private scheduleNextPoll(): void { if (!this.isRunning) return; this.pollTimer = setTimeout(async () => { @@ -125,16 +144,17 @@ export class WebhookConsumer { await this.pollForEvents(); } catch (error) { LogEngine.error('Error during event polling:', error); + } finally { + // Schedule next poll only once per cycle, regardless of success or failure + this.scheduleNextPoll(); } - - // Schedule next poll - this.scheduleNextPoll(); }, this.pollInterval); } - /** + + /** * Poll Redis queue for new events */ - async pollForEvents() { + private async pollForEvents(): Promise { if (!this.blockingRedisClient || !this.blockingRedisClient.isOpen) { LogEngine.warn('Blocking Redis client not connected, skipping poll'); return; @@ -144,9 +164,11 @@ export class WebhookConsumer { LogEngine.debug(`Polling Redis queue: ${this.queueName}`); // Check queue length first for debugging - const queueLength = await this.redisClient.lLen(this.queueName); - if (queueLength > 0) { - LogEngine.info(`Found ${queueLength} events in queue ${this.queueName}`); + if (this.redisClient && this.redisClient.isOpen) { + const queueLength = await this.redisClient.lLen(this.queueName); + if (queueLength > 0) { + LogEngine.info(`Found ${queueLength} events in queue ${this.queueName}`); + } } // Get the next event from the queue using dedicated blocking client (1 second timeout) @@ -163,11 +185,12 @@ export class WebhookConsumer { LogEngine.error('Error polling for events:', error); } } - /** + + /** * Process a single event - * @param {string} eventData - JSON string of the event + * @param eventData - JSON string of the event */ - async processEvent(eventData) { + private async processEvent(eventData: string): Promise { try { LogEngine.info('πŸ”„ Starting event processing', { eventDataLength: eventData.length, @@ -175,38 +198,50 @@ export class WebhookConsumer { }); // Parse the event - let event; + let event: unknown; try { event = JSON.parse(eventData); + const eventObj = event as Record; LogEngine.info('βœ… Event parsed successfully', { - type: event.type, - sourcePlatform: event.sourcePlatform, - conversationId: event.data?.conversationId + type: eventObj.type, + sourcePlatform: eventObj.sourcePlatform, + conversationId: (eventObj.data as any)?.conversationId || (eventObj.data as any)?.id }); } catch (parseError) { LogEngine.error('❌ Failed to parse event JSON', { - error: parseError.message, + error: (parseError as Error).message, eventData: eventData.substring(0, 500) }); return; } + const eventObj = event as Record; + const data = eventObj.data as Record; + LogEngine.info('πŸ” Processing webhook event', { - type: event.type, - sourcePlatform: event.sourcePlatform, - conversationId: event.data?.conversationId + type: eventObj.type, + sourcePlatform: eventObj.sourcePlatform, + conversationId: data?.conversationId || data?.id, + timestamp: eventObj.timestamp, + dataKeys: data ? Object.keys(data) : [] + }); + + // Log full event payload at debug level to avoid log bloat + LogEngine.debug('πŸ” Complete webhook event payload', { + completeEvent: JSON.stringify(event, null, 2) }); // Validate the event LogEngine.info('πŸ” Validating event...', { - eventType: event.type, - sourcePlatform: event.sourcePlatform, - hasData: !!event.data, - conversationId: event.data?.conversationId, - hasContent: !!event.data?.content, - hasText: !!event.data?.text, - eventDataKeys: event.data ? Object.keys(event.data) : [] + eventType: eventObj.type, + sourcePlatform: eventObj.sourcePlatform, + hasData: !!eventObj.data, + conversationId: data?.conversationId || data?.id, + hasContent: !!data?.content, + hasText: !!data?.text, + eventDataKeys: data ? Object.keys(data) : [] }); + if (!EventValidator.validate(event)) { LogEngine.warn('❌ Invalid event, skipping', { event: JSON.stringify(event, null, 2).substring(0, 1000) + '...' @@ -216,7 +251,8 @@ export class WebhookConsumer { LogEngine.info('βœ… Event validation passed'); // Find handler for this event - const handlerKey = `${event.type}:${event.sourcePlatform}`; + const validatedEvent = event as WebhookEvent; + const handlerKey = `${validatedEvent.type}:${validatedEvent.sourcePlatform}`; LogEngine.info('πŸ” Looking for handler', { handlerKey }); const handler = this.eventHandlers.get(handlerKey); @@ -228,23 +264,23 @@ export class WebhookConsumer { } // Execute the handler - LogEngine.info(`πŸš€ Executing handler for ${event.type} event from ${event.sourcePlatform}`); + LogEngine.info(`πŸš€ Executing handler for ${validatedEvent.type} event from ${validatedEvent.sourcePlatform}`); try { - await handler(event); - LogEngine.info(`βœ… Event processed successfully: ${event.type} from ${event.sourcePlatform}`); + await handler(validatedEvent); + LogEngine.info(`βœ… Event processed successfully: ${validatedEvent.type} from ${validatedEvent.sourcePlatform}`); } catch (handlerError) { - LogEngine.error(`❌ Handler execution failed for ${event.type}:${event.sourcePlatform}`, { - error: handlerError.message, - stack: handlerError.stack, - conversationId: event.data?.conversationId + LogEngine.error(`❌ Handler execution failed for ${validatedEvent.type}:${validatedEvent.sourcePlatform}`, { + error: (handlerError as Error).message, + stack: (handlerError as Error).stack, + conversationId: data?.conversationId || data?.id }); throw handlerError; } } catch (error) { LogEngine.error('❌ Error processing event:', { - error: error.message, - stack: error.stack, + error: (error as Error).message, + stack: (error as Error).stack, eventDataPreview: eventData ? eventData.substring(0, 500) : 'null' }); } @@ -252,13 +288,19 @@ export class WebhookConsumer { /** * Get connection status - * @returns {Object} Status information + * @returns Status information */ - getStatus() { + getStatus(): { + isRunning: boolean; + isConnected: boolean; + isBlockingClientConnected: boolean; + subscribedEvents: string[]; + queueName: string; + } { return { isRunning: this.isRunning, - isConnected: this.redisClient && this.redisClient.isOpen, - isBlockingClientConnected: this.blockingRedisClient && this.blockingRedisClient.isOpen, + isConnected: this.redisClient !== null && this.redisClient.isOpen, + isBlockingClientConnected: this.blockingRedisClient !== null && this.blockingRedisClient.isOpen, subscribedEvents: Array.from(this.eventHandlers.keys()), queueName: this.queueName }; diff --git a/src/sdk/unthread-webhook/index.js b/src/sdk/unthread-webhook/index.ts similarity index 97% rename from src/sdk/unthread-webhook/index.js rename to src/sdk/unthread-webhook/index.ts index 2cdce82..8d7e56d 100644 --- a/src/sdk/unthread-webhook/index.js +++ b/src/sdk/unthread-webhook/index.ts @@ -4,7 +4,7 @@ * Consumes agent responses from Unthread dashboard and delivers them to Telegram. * * Usage: - * ```javascript + * ```typescript * import { WebhookConsumer } from './unthread-webhook'; * * const consumer = new WebhookConsumer({ diff --git a/src/services/unthread.js b/src/services/unthread.ts similarity index 51% rename from src/services/unthread.js rename to src/services/unthread.ts index 5c1e23b..038a1f8 100644 --- a/src/services/unthread.js +++ b/src/services/unthread.ts @@ -8,19 +8,85 @@ import fetch from 'node-fetch'; import { LogEngine } from '@wgtechlabs/log-engine'; import { BotsStore } from '../sdk/bots-brain/index.js'; +import { TicketData } from '../sdk/types.js'; import dotenv from 'dotenv'; // Load environment variables dotenv.config(); /** - * Extracts customer company name from group chat title by removing the bot's company name - * Handles formats like "thirdweb x relay", "thirdweb <> relay", "relay x apple", etc. - * - * @param {string} groupChatTitle - The original group chat title - * @returns {string} - The extracted customer company name, capitalized + * Customer data structure + */ +interface Customer { + id: string; + name: string; +} + +/** + * User data for onBehalfOf + */ +interface OnBehalfOfUser { + name: string; + email: string; +} + +/** + * Ticket creation parameters + */ +interface CreateTicketParams { + groupChatName: string; + customerId: string; + summary: string; + onBehalfOf: OnBehalfOfUser; +} + +/** + * Message sending parameters + */ +interface SendMessageParams { + conversationId: string; + message: string; + onBehalfOf: OnBehalfOfUser; +} + +/** + * Ticket confirmation parameters + */ +interface RegisterTicketConfirmationParams { + messageId: number; + ticketId: string; + friendlyId: string; + customerId: string; + chatId: number; + telegramUserId: number; +} + +/** + * Ticket JSON creation parameters + */ +interface CreateTicketJSONParams { + title: string; + summary: string; + customerId: string; + onBehalfOf: OnBehalfOfUser; +} + +/** + * Message JSON sending parameters + */ +interface SendMessageJSONParams { + conversationId: string; + message: string; + onBehalfOf: OnBehalfOfUser; +} + +/** + * Extracts and formats the customer company name from a Telegram group chat title by removing the bot's company name and handling various separators. + * + * @param groupChatTitle - The original group chat title + * @returns The extracted and capitalized customer company name, or "Unknown Company" if extraction fails */ -function extractCustomerCompanyName(groupChatTitle) { +function extractCustomerCompanyName(groupChatTitle: string): string { if (!groupChatTitle) { return 'Unknown Company'; } @@ -49,10 +115,10 @@ function extractCustomerCompanyName(groupChatTitle) { // Find which part is NOT our company name const [part1, part2] = parts; - if (part1 === lowerCompanyName && part2 !== lowerCompanyName) { + if (part1 === lowerCompanyName && part2 !== lowerCompanyName && part2) { // Our company is first, customer is second return capitalizeCompanyName(part2); - } else if (part2 === lowerCompanyName && part1 !== lowerCompanyName) { + } else if (part2 === lowerCompanyName && part1 !== lowerCompanyName && part1) { // Customer is first, our company is second return capitalizeCompanyName(part1); } @@ -77,12 +143,14 @@ function extractCustomerCompanyName(groupChatTitle) { } /** - * Capitalizes company name properly (first letter of each word) - * - * @param {string} name - The company name to capitalize - * @returns {string} - The capitalized company name + * Formats a company name by capitalizing each word, replacing spaces with hyphens, and removing invalid characters. + * + * Returns 'Unknown-Company' if the input is empty. + * + * @param name - The company name to format + * @returns The formatted and capitalized company name */ -function capitalizeCompanyName(name) { +function capitalizeCompanyName(name: string): string { if (!name) return 'Unknown-Company'; return name @@ -104,24 +172,24 @@ const CHANNEL_ID = process.env.UNTHREAD_CHANNEL_ID; // Validate required environment variables if (!UNTHREAD_API_KEY) { LogEngine.error('UNTHREAD_API_KEY environment variable is required but not defined'); - process.exit(1); + throw new Error('Missing required environment variable: UNTHREAD_API_KEY'); } if (!CHANNEL_ID) { LogEngine.error('UNTHREAD_CHANNEL_ID environment variable is required but not defined'); - process.exit(1); + throw new Error('Missing required environment variable: UNTHREAD_CHANNEL_ID'); } // Customer ID cache to avoid creating duplicates -const customerCache = new Map(); +const customerCache = new Map(); /** - * Creates a new customer in Unthread - * - * @param {string} groupChatName - The name of the Telegram group chat - * @returns {Promise} - The created customer object with ID + * Creates a new customer in Unthread using the extracted company name from a Telegram group chat title. + * + * @param groupChatName - The name of the Telegram group chat + * @returns The created customer object containing its ID and name */ -export async function createCustomer(groupChatName) { +export async function createCustomer(groupChatName: string): Promise { try { // Extract the actual customer company name from the group chat title const customerName = extractCustomerCompanyName(groupChatName); @@ -130,7 +198,7 @@ export async function createCustomer(groupChatName) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-API-KEY': UNTHREAD_API_KEY + 'X-API-KEY': UNTHREAD_API_KEY! }, body: JSON.stringify({ name: customerName @@ -142,7 +210,7 @@ export async function createCustomer(groupChatName) { throw new Error(`Failed to create customer: ${response.status} ${errorText}`); } - const result = await response.json(); + const result = await response.json() as Customer; // Log the extraction for debugging LogEngine.info('Customer created with extracted name', { @@ -154,7 +222,7 @@ export async function createCustomer(groupChatName) { return result; } catch (error) { LogEngine.error('Error creating customer', { - error: error.message, + error: (error as Error).message, groupChatName }); throw error; @@ -162,137 +230,138 @@ export async function createCustomer(groupChatName) { } /** - * Creates a new support ticket (conversation) in Unthread - * - * @param {object} params - The ticket parameters - * @param {string} params.groupChatName - The name of the Telegram group chat - * @param {string} params.customerId - The Unthread customer ID - * @param {string} params.summary - The ticket summary/description - * @param {object} params.onBehalfOf - The user information for onBehalfOf - * @param {string} params.onBehalfOf.name - The user's name - * @param {string} params.onBehalfOf.email - The user's email - * @returns {Promise} - The created ticket object + * Creates a new support ticket in Unthread for a given customer and group chat. + * + * @param params - Includes group chat name, customer ID, ticket summary, and user information on whose behalf the ticket is created. + * @returns The created ticket object from Unthread. */ -export async function createTicket({ groupChatName, customerId, summary, onBehalfOf }) { +export async function createTicket(params: CreateTicketParams): Promise { try { + const { groupChatName, customerId, summary, onBehalfOf } = params; + // Extract the customer company name for the ticket title const customerCompanyName = extractCustomerCompanyName(groupChatName); const title = `[Telegram Ticket] ${customerCompanyName}`; - // Create the ticket payload - const payload = { - type: "slack", - title: title, - markdown: summary, - status: "open", - channelId: CHANNEL_ID, - customerId: customerId, - onBehalfOf: onBehalfOf - }; - - const response = await fetch(`${API_BASE_URL}/conversations`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-KEY': UNTHREAD_API_KEY - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to create ticket: ${response.status} ${errorText}`); - } - - const result = await response.json(); - - // Log the ticket creation with extracted names - LogEngine.info('Ticket created with extracted customer name', { - originalGroupChatName: groupChatName, - extractedCustomerName: customerCompanyName, - ticketTitle: title, - ticketId: result.id, - friendlyId: result.friendlyId, - customerId: customerId, - onBehalfOf: onBehalfOf - }); - - return result; + return await createTicketJSON({ title, summary, customerId, onBehalfOf }); } catch (error) { LogEngine.error('Error creating ticket', { - error: error.message, - customerId + error: (error as Error).message, + customerId: params.customerId }); throw error; } } /** - * Sends a message to an existing conversation - * - * @param {object} params - The message parameters - * @param {string} params.conversationId - The ID of the conversation to send a message to - * @param {string} params.message - The message text - * @param {object} params.onBehalfOf - The user information for onBehalfOf - * @param {string} params.onBehalfOf.name - The user's name - * @param {string} params.onBehalfOf.email - The user's email - * @returns {Promise} - The response from the API + * Creates a new support ticket in Unthread using a JSON payload. + * + * Sends a POST request to the Unthread API to create a ticket with the specified title, summary, customer, and user information. Returns the created ticket's identifiers. + * + * @param params - Ticket creation details including title, summary, customer ID, and user information + * @returns An object containing the ticket's unique ID and friendly ID + * @throws If the API request fails or returns a non-OK response */ -export async function sendMessage({ conversationId, message, onBehalfOf }) { - try { - const payload = { - body: { - type: "markdown", - value: message - }, - onBehalfOf: onBehalfOf - }; - - const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}/messages`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-KEY': UNTHREAD_API_KEY - }, - body: JSON.stringify(payload) - }); +async function createTicketJSON(params: CreateTicketJSONParams): Promise { + const { title, summary, customerId, onBehalfOf } = params; + + const payload = { + type: "slack", + title: title, + markdown: summary, + status: "open", + channelId: CHANNEL_ID, + customerId: customerId, + onBehalfOf: onBehalfOf + }; + + const response = await fetch(`${API_BASE_URL}/conversations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': UNTHREAD_API_KEY! + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to create ticket: ${response.status} ${errorText}`); + } - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to send message: ${response.status} ${errorText}`); - } + const result = await response.json() as { id: string; friendlyId: string }; + + LogEngine.info('Ticket created (JSON)', { + ticketTitle: title, + ticketId: result.id, + friendlyId: result.friendlyId, + customerId: customerId + }); + + return result; +} - return await response.json(); +/** + * Sends a message to an existing Unthread conversation. + * + * @param params - Contains the conversation ID, message content, and user information. + * @returns The API response for the sent message. + */ +export async function sendMessage(params: SendMessageParams): Promise { + try { + return await sendMessageJSON(params); } catch (error) { LogEngine.error('Error sending message', { - error: error.message, - conversationId + error: (error as Error).message, + conversationId: params.conversationId }); throw error; } } /** - * Registers a ticket confirmation message using BotsStore - * - * @param {object} ticketInfo - The ticket information to store - * @param {number} ticketInfo.messageId - The Telegram message ID of the confirmation - * @param {string} ticketInfo.ticketId - The Unthread ticket/conversation ID - * @param {string} ticketInfo.friendlyId - The human-readable ticket number - * @param {string} ticketInfo.customerId - The Unthread customer ID - * @param {number} ticketInfo.chatId - The Telegram chat ID - * @param {number} ticketInfo.telegramUserId - The Telegram user ID of the ticket creator + * Sends a markdown-formatted message to a conversation in Unthread without attachments. + * + * @param params - Contains the conversation ID, message content, and user information for attribution. + * @returns The response data from the Unthread API after sending the message. + * @throws If the API request fails or returns a non-OK status. + */ +async function sendMessageJSON(params: SendMessageJSONParams): Promise { + const { conversationId, message, onBehalfOf } = params; + + const payload = { + body: { + type: "markdown", + value: message + }, + onBehalfOf: onBehalfOf + }; + + const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': UNTHREAD_API_KEY! + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to send message: ${response.status} ${errorText}`); + } + + return await response.json(); +} + +/** + * Stores ticket confirmation details in the BotsStore for later retrieval. + * + * @param params - Ticket confirmation data including message and ticket identifiers, chat and user IDs, and related metadata. */ -export async function registerTicketConfirmation({ messageId, ticketId, friendlyId, customerId, chatId, telegramUserId }) { +export async function registerTicketConfirmation(params: RegisterTicketConfirmationParams): Promise { try { - const ticketData = { - ticketId, - friendlyId, - customerId, - chatId, - telegramUserId, - createdAt: Date.now() - }; + const { messageId, ticketId, friendlyId, customerId, chatId, telegramUserId } = params; // Store ticket mapping using BotsStore await BotsStore.storeTicket({ @@ -302,7 +371,7 @@ export async function registerTicketConfirmation({ messageId, ticketId, friendly chatId: chatId, telegramUserId: telegramUserId, ticketId: ticketId, - createdAt: Date.now() + createdAt: Date.now().toString() }); LogEngine.info('Registered ticket confirmation', { @@ -315,20 +384,20 @@ export async function registerTicketConfirmation({ messageId, ticketId, friendly }); } catch (error) { LogEngine.error('Error registering ticket confirmation', { - error: error.message, - ticketId + error: (error as Error).message, + ticketId: params.ticketId }); throw error; } } /** - * Checks if a message is a reply to a ticket confirmation using BotsStore - * - * @param {number} replyToMessageId - The message ID this message is replying to - * @returns {object|null} - The ticket information or null if not a ticket reply + * Retrieves ticket information associated with a replied-to Telegram message. + * + * @param replyToMessageId - The Telegram message ID being replied to + * @returns The ticket data if found, or null if no ticket is associated with the message */ -export async function getTicketFromReply(replyToMessageId) { +export async function getTicketFromReply(replyToMessageId: number): Promise { try { const ticketData = await BotsStore.getTicketByTelegramMessageId(replyToMessageId); @@ -344,8 +413,8 @@ export async function getTicketFromReply(replyToMessageId) { return ticketData || null; } catch (error) { LogEngine.error('Error getting ticket from reply', { - error: error.message, - stack: error.stack, + error: (error as Error).message, + stack: (error as Error).stack, replyToMessageId }); return null; @@ -353,19 +422,19 @@ export async function getTicketFromReply(replyToMessageId) { } /** - * Checks if a message is a reply to an agent message using BotsStore - * - * @param {number} replyToMessageId - The message ID this message is replying to - * @returns {object|null} - The agent message information or null if not an agent message reply + * Retrieves agent message information from BotsStore by the replied message ID. + * + * @param replyToMessageId - The Telegram message ID being replied to + * @returns The agent message data if found, or null if not found or on error */ -export async function getAgentMessageFromReply(replyToMessageId) { +export async function getAgentMessageFromReply(replyToMessageId: number): Promise { try { const agentMessageData = await BotsStore.getAgentMessageByTelegramId(replyToMessageId); return agentMessageData || null; } catch (error) { LogEngine.error('Error getting agent message from reply', { - error: error.message, - stack: error.stack, + error: (error as Error).message, + stack: (error as Error).stack, replyToMessageId }); return null; @@ -373,12 +442,14 @@ export async function getAgentMessageFromReply(replyToMessageId) { } /** - * Gets all active ticket confirmations for a specific chat using BotsStore - * - * @param {number} chatId - The Telegram chat ID - * @returns {Array} - Array of ticket confirmation info for this chat + * Retrieves all active ticket confirmations for a given Telegram chat. + * + * Currently returns an empty array as the functionality is not yet implemented. + * + * @param chatId - The Telegram chat ID + * @returns An array of ticket confirmation information for the specified chat */ -export async function getTicketsForChat(chatId) { +export async function getTicketsForChat(chatId: number): Promise { try { // Note: This would require a new method in BotsStore to search by chatId // For now, we'll return an empty array and implement this if needed @@ -386,8 +457,8 @@ export async function getTicketsForChat(chatId) { return []; } catch (error) { LogEngine.error('Error getting tickets for chat', { - error: error.message, - stack: error.stack, + error: (error as Error).message, + stack: (error as Error).stack, chatId }); return []; @@ -395,25 +466,25 @@ export async function getTicketsForChat(chatId) { } /** - * Gets or creates a customer, ensuring it's stored in the database - * - * @param {string} groupChatName - The name of the Telegram group chat - * @param {string} chatId - The Telegram chat ID - * @returns {Promise} - Customer data with ID and name + * Retrieves an existing customer by Telegram chat ID or creates a new customer in Unthread and stores it locally. + * + * @param groupChatName - The name of the Telegram group chat. + * @param chatId - The Telegram chat ID. + * @returns The customer object containing the Unthread customer ID and name. */ -export async function getOrCreateCustomer(groupChatName, chatId) { +export async function getOrCreateCustomer(groupChatName: string, chatId: number): Promise { try { // First, check if we already have this customer in our database by chat ID const existingCustomer = await BotsStore.getCustomerByChatId(chatId); if (existingCustomer) { LogEngine.info('Using existing customer from database', { customerId: existingCustomer.unthreadCustomerId, - customerName: existingCustomer.customerName, + customerName: existingCustomer.customerName || existingCustomer.name, chatId: chatId }); return { id: existingCustomer.unthreadCustomerId, - name: existingCustomer.customerName + name: existingCustomer.customerName || existingCustomer.name || 'Unknown Customer' }; } @@ -425,7 +496,7 @@ export async function getOrCreateCustomer(groupChatName, chatId) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-API-KEY': UNTHREAD_API_KEY + 'X-API-KEY': UNTHREAD_API_KEY! }, body: JSON.stringify({ name: customerName @@ -437,15 +508,19 @@ export async function getOrCreateCustomer(groupChatName, chatId) { throw new Error(`Failed to create customer: ${response.status} ${errorText}`); } - const result = await response.json(); + const result = await response.json() as Customer; // Store customer in our database await BotsStore.storeCustomer({ - chatId: chatId, + id: `customer_${chatId}`, unthreadCustomerId: result.id, + telegramChatId: chatId, + chatId: chatId, chatTitle: groupChatName, customerName: customerName, - createdAt: new Date().toISOString() + name: customerName, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() }); LogEngine.info('Created and stored new customer', { @@ -461,8 +536,8 @@ export async function getOrCreateCustomer(groupChatName, chatId) { }; } catch (error) { LogEngine.error('Error getting or creating customer', { - error: error.message, - stack: error.stack, + error: (error as Error).message, + stack: (error as Error).stack, groupChatName, chatId, apiUrl: `${API_BASE_URL}/customers` @@ -472,13 +547,15 @@ export async function getOrCreateCustomer(groupChatName, chatId) { } /** - * Gets or creates user information, ensuring it's stored in the database - * - * @param {string} telegramUserId - The Telegram user ID - * @param {string} username - The Telegram username (without @) - * @returns {Promise} - User data with onBehalf information + * Retrieves user information by Telegram user ID, creating and storing a new user record if one does not exist. + * + * If the user is not found in the database, a new user is created with a generated name and email, optionally using the provided username. + * + * @param telegramUserId - The Telegram user ID + * @param username - Optional Telegram username (without @) + * @returns An object containing the user's name and email for use as onBehalfOf information */ -export async function getOrCreateUser(telegramUserId, username) { +export async function getOrCreateUser(telegramUserId: number, username?: string): Promise { try { // First, check if we already have this user in our database const existingUser = await BotsStore.getUserByTelegramId(telegramUserId); @@ -489,8 +566,8 @@ export async function getOrCreateUser(telegramUserId, username) { unthreadEmail: existingUser.unthreadEmail }); return { - name: existingUser.unthreadName, - email: existingUser.unthreadEmail + name: existingUser.unthreadName || `User ${existingUser.telegramUserId}`, + email: existingUser.unthreadEmail || `user_${existingUser.telegramUserId}@telegram.user` }; } @@ -501,13 +578,21 @@ export async function getOrCreateUser(telegramUserId, username) { : `user_${telegramUserId}@telegram.user`; // Store user in our database - await BotsStore.storeUser({ + const userData: any = { + id: `user_${telegramUserId}`, telegramUserId: telegramUserId, - telegramUsername: username || null, unthreadName: unthreadName, unthreadEmail: unthreadEmail, - createdAt: new Date().toISOString() - }); + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + if (username) { + userData.telegramUsername = username; + userData.username = username; + } + + await BotsStore.storeUser(userData); LogEngine.info('Created and stored new user', { telegramUserId: telegramUserId, @@ -522,8 +607,8 @@ export async function getOrCreateUser(telegramUserId, username) { }; } catch (error) { LogEngine.error('Error getting or creating user', { - error: error.message, - stack: error.stack, + error: (error as Error).message, + stack: (error as Error).stack, telegramUserId, username }); @@ -532,4 +617,4 @@ export async function getOrCreateUser(telegramUserId, username) { } // Export the customer cache for potential use in other modules -export { customerCache }; \ No newline at end of file +export { customerCache }; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..6ced685 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,171 @@ +import { Context, Telegraf } from 'telegraf'; +import { Message, Update, UserFromGetMe } from 'telegraf/typings/core/types/typegram'; + +// Bot context extensions - extending the base context +export interface BotContext extends Context { + botInfo: UserFromGetMe; +} + +// Message types +export interface TextMessage extends Message.TextMessage { + text: string; +} + +export interface PhotoMessage extends Message.PhotoMessage { + photo: Array<{ + file_id: string; + file_unique_id: string; + width: number; + height: number; + file_size?: number; + }>; +} + +// Command handler type +export type CommandHandler = (ctx: BotContext) => Promise; + +// Database types +export interface DatabaseConnection { + connect(): Promise; + close(): Promise; + query(text: string, params?: any[]): Promise; +} + +// Customer types +export interface Customer { + id: string; + unthreadCustomerId: string; + telegramChatId: number; + email?: string; + name?: string; + createdAt: Date; + updatedAt: Date; +} + +// Ticket types +export interface Ticket { + conversationId: string; + friendlyId: string; + customerId: string; + telegramChatId: number; + summary: string; + status: 'open' | 'closed' | 'pending'; + createdAt: Date; + updatedAt: Date; +} + +// Support form types +export enum SupportField { + SUMMARY = 'summary', + EMAIL = 'email', + COMPLETE = 'complete' +} + +export interface SupportFormState { + field: SupportField; + summary?: string; + email?: string; + messageId?: number; + initiatedBy?: number; // Track who initiated the support request + initiatedInChat?: number; // Track which chat the support was initiated in + currentField?: SupportField; // For backward compatibility +} + +// Storage interface +export interface Storage { + get(key: string): Promise; + set(key: string, value: string, ttl?: number): Promise; + delete(key: string): Promise; + exists(key: string): Promise; +} + +// Webhook types +export interface WebhookEvent { + type: string; + source: string; + data: any; + timestamp: string; +} + +export interface MessageCreatedEvent extends WebhookEvent { + type: 'message_created'; + data: { + id: string; + conversation_id: string; + content: string; + author: { + id: string; + name: string; + type: 'agent' | 'customer'; + }; + created_at: string; + }; +} + +export interface ConversationUpdatedEvent extends WebhookEvent { + type: 'conversation_updated'; + data: { + id: string; + status: 'open' | 'closed' | 'pending'; + updated_at: string; + }; +} + +// Configuration types +export interface BotConfig { + telegramToken: string; + unthreadApiKey: string; + unthreadApiUrl: string; + platformRedisUrl?: string; + webhookRedisUrl?: string; + databaseUrl: string; +} + +// API Response types +export interface UnthreadApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export interface CreateCustomerResponse { + id: string; + email: string; + name?: string; + created_at: string; +} + +export interface CreateConversationResponse { + id: string; + friendly_id: string; + customer_id: string; + title: string; + status: string; + created_at: string; +} + +// Error types +export interface TelegramError extends Error { + response?: { + error_code: number; + description: string; + parameters?: { + retry_after?: number; + }; + }; + on?: { + method: string; + payload: any; + }; +} + +// Logging types +export interface LogContext { + chatId?: number; + userId?: number; + conversationId?: string; + error?: string; + stack?: string; + [key: string]: any; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ae9445 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": false, + "outDir": "./dist", + "rootDir": "./src", + "removeComments": true, + "strict": true, + "noImplicitAny": false, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "typeRoots": [ + "node_modules/@types", + "src/types" + ] + }, + "include": [ + "src/**/*", + "src/types/*.d.ts" + ], + "exclude": [ + "node_modules", + "dist", + "src/sdk/**/*", + "**/*.test.ts", + "**/*.spec.ts" + ] +} diff --git a/yarn.lock b/yarn.lock index db085b9..7dbc550 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,26 @@ resolved "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz" integrity sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw== -"@wgtechlabs/log-engine@^1.2.1": - version "1.2.1" - resolved "https://registry.npmjs.org/@wgtechlabs/log-engine/-/log-engine-1.2.1.tgz" - integrity sha512-BHu8SMZztrIsRzX3KNYARMUpMAgzRqf27i/5wbSe7eQjnfm5WhHModgQfMSXte6V9m1ZHmfadjDGfNJtM2tTJg== +"@types/node@*", "@types/node@24.0.3": + version "24.0.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.3.tgz#f935910f3eece3a3a2f8be86b96ba833dc286cab" + integrity sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg== + dependencies: + undici-types "~7.8.0" + +"@types/pg@8.15.4": + version "8.15.4" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.15.4.tgz#419f791c6fac8e0bed66dd8f514b60f8ba8db46d" + integrity sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^2.2.0" + +"@wgtechlabs/log-engine@^1.3.0": + version "1.3.0" + resolved "https://registry.npmjs.org/@wgtechlabs/log-engine/-/log-engine-1.3.0.tgz" + integrity sha512-pg4LZzN7xgEzqJbU31/xWCcY/sXOWMd7xo642kvtsLC6OMvgW899Q5AX+yYLsMoN06B4960/MAUdUGksl9ZGzA== abort-controller@^3.0.0: version "3.0.0" @@ -46,6 +62,18 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + anymatch@~3.1.2: version "3.1.3" resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" @@ -97,6 +125,14 @@ buffer-fill@^1.0.0: resolved "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz" integrity sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ== +chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chokidar@^3.5.2: version "3.6.0" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" @@ -112,16 +148,50 @@ chokidar@^3.5.2: optionalDependencies: fsevents "~2.3.2" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + cluster-key-slot@1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz" integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concurrently@9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.2.tgz#22d9109296961eaee773e12bfb1ce9a66bc9836c" + integrity sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ== + dependencies: + chalk "^4.1.2" + lodash "^4.17.21" + rxjs "^7.8.1" + shell-quote "^1.8.1" + supports-color "^8.1.1" + tree-kill "^1.2.2" + yargs "^17.7.2" + data-uri-to-buffer@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz" @@ -139,6 +209,16 @@ dotenv@^16.4.7: resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz" integrity sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg== +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + event-target-shim@^5.0.0: version "5.0.1" resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" @@ -171,6 +251,11 @@ fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" @@ -183,6 +268,11 @@ has-flag@^3.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz" integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + ignore-by-default@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz" @@ -200,6 +290,11 @@ is-extglob@^2.1.1: resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" @@ -212,6 +307,11 @@ is-number@^7.0.0: resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" @@ -296,12 +396,17 @@ pg-pool@^3.10.0: resolved "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz" integrity sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA== +pg-protocol@*: + version "1.10.1" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.10.1.tgz#fabc892e8d00b9c6f2c4a8f48358f5a53b49d830" + integrity sha512-9YS3ZonDj0Lxny//aF0ITPdfrEPgKWCJvONsSXAaIUhgpzlzl5JgaZNlbTFxvYNfm2terGEnHeOSUlF6qRGBzw== + pg-protocol@^1.10.0: version "1.10.0" resolved "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz" integrity sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q== -pg-types@2.2.0: +pg-types@2.2.0, pg-types@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz" integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== @@ -382,6 +487,18 @@ redis@^5.1.1: "@redis/search" "5.5.5" "@redis/time-series" "5.5.5" +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +rxjs@^7.8.1: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== + dependencies: + tslib "^2.1.0" + safe-compare@^1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz" @@ -399,6 +516,11 @@ semver@^7.5.3: resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +shell-quote@^1.8.1: + version "1.8.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" + integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== + simple-update-notifier@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz" @@ -411,6 +533,22 @@ split2@^4.1.0: resolved "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz" integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + supports-color@^5.5.0: version "5.5.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz" @@ -418,6 +556,20 @@ supports-color@^5.5.0: dependencies: has-flag "^3.0.0" +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + telegraf@^4.0.0: version "4.16.3" resolved "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz" @@ -449,11 +601,31 @@ tr46@~0.0.3: resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + +tslib@^2.1.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +typescript@5.8.3: + version "5.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + undefsafe@^2.0.5: version "2.0.5" resolved "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz" integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== +undici-types@~7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294" + integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw== + web-streams-polyfill@^3.0.3: version "3.3.3" resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz" @@ -472,7 +644,39 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + xtend@^4.0.0: version "4.0.2" resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1"