From 88d0d5faff908db9591d881000563a2367423239 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 10:08:36 +0000 Subject: [PATCH 01/31] feat(plugin-chatwoot): add Chatwoot plugin package for automatic CRM integration Introduces a new plugin category (packages/plugins/) and the first plugin: @builderbot/plugin-chatwoot. The plugin automatically creates inboxes, saves contacts, and syncs conversations with Chatwoot using a simple DX: createChatwootPlugin({ token, url, accountId }) + chatwoot.attach(bot). https://claude.ai/code/session_01KYzSLXHCUPi1G8rR6GDcKm --- lerna.json | 3 +- .../chatwoot/__tests__/chatwootPlugin.test.ts | 55 +++++ packages/plugins/chatwoot/package.json | 46 +++++ packages/plugins/chatwoot/rollup.config.js | 21 ++ packages/plugins/chatwoot/src/chatwootApi.ts | 190 ++++++++++++++++++ .../plugins/chatwoot/src/chatwootPlugin.ts | 118 +++++++++++ packages/plugins/chatwoot/src/index.ts | 3 + packages/plugins/chatwoot/src/types.ts | 61 ++++++ packages/plugins/chatwoot/tsconfig.json | 17 ++ pnpm-workspace.yaml | 1 + 10 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts create mode 100644 packages/plugins/chatwoot/package.json create mode 100644 packages/plugins/chatwoot/rollup.config.js create mode 100644 packages/plugins/chatwoot/src/chatwootApi.ts create mode 100644 packages/plugins/chatwoot/src/chatwootPlugin.ts create mode 100644 packages/plugins/chatwoot/src/index.ts create mode 100644 packages/plugins/chatwoot/src/types.ts create mode 100644 packages/plugins/chatwoot/tsconfig.json diff --git a/lerna.json b/lerna.json index ecaa7028f..bc2753d4f 100644 --- a/lerna.json +++ b/lerna.json @@ -25,7 +25,8 @@ "packages/provider-gohighlevel", "packages/provider-email", "packages/contexts-dialogflow", - "packages/contexts-dialogflow-cx" + "packages/contexts-dialogflow-cx", + "packages/plugins/chatwoot" ], "command": { "version": { diff --git a/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts b/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts new file mode 100644 index 000000000..3c82954a6 --- /dev/null +++ b/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts @@ -0,0 +1,55 @@ +import { test } from 'uvu' +import * as assert from 'uvu/assert' + +import { ChatwootApi } from '../src/chatwootApi' +import { ChatwootPlugin, createChatwootPlugin } from '../src/chatwootPlugin' + +const MOCK_CONFIG = { + token: 'test-token-123', + url: 'https://chatwoot.example.com', + accountId: 1, +} + +test('createChatwootPlugin returns a ChatwootPlugin instance', () => { + const plugin = createChatwootPlugin(MOCK_CONFIG) + assert.instance(plugin, ChatwootPlugin) +}) + +test('ChatwootPlugin exposes getApi()', () => { + const plugin = createChatwootPlugin(MOCK_CONFIG) + const api = plugin.getApi() + assert.instance(api, ChatwootApi) +}) + +test('ChatwootPlugin getInbox() returns null before attach', () => { + const plugin = createChatwootPlugin(MOCK_CONFIG) + assert.is(plugin.getInbox(), null) +}) + +test('createChatwootPlugin uses default inbox name', () => { + const plugin = createChatwootPlugin(MOCK_CONFIG) + assert.instance(plugin, ChatwootPlugin) +}) + +test('createChatwootPlugin accepts custom inbox name', () => { + const plugin = createChatwootPlugin({ + ...MOCK_CONFIG, + inboxName: 'Custom Inbox', + }) + assert.instance(plugin, ChatwootPlugin) +}) + +test('ChatwootApi constructs correct base URL', () => { + const api = new ChatwootApi(MOCK_CONFIG) + assert.instance(api, ChatwootApi) +}) + +test('ChatwootApi trims trailing slash from URL', () => { + const api = new ChatwootApi({ + ...MOCK_CONFIG, + url: 'https://chatwoot.example.com/', + }) + assert.instance(api, ChatwootApi) +}) + +test.run() diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json new file mode 100644 index 000000000..a2c3427bb --- /dev/null +++ b/packages/plugins/chatwoot/package.json @@ -0,0 +1,46 @@ +{ + "name": "@builderbot/plugin-chatwoot", + "version": "1.3.15-alpha.21", + "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", + "keywords": ["chatwoot", "builderbot", "plugin", "crm", "customer-support"], + "author": "Leifer Mendez ", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "directories": { + "src": "src", + "test": "__tests__" + }, + "files": [ + "./dist/" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "npx uvu -r tsm ./__tests__ .test.ts", + "test:coverage": "npx c8 npm run test", + "test:debug": "npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "dependencies": { + "@builderbot/bot": "^1.3.15-alpha.21" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-terser": "^0.4.4", + "@types/node": "^24.10.2", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "tslib": "^2.6.2", + "tsm": "^2.3.0" + }, + "gitHead": "8ca5bde13fa8276a8bdeac9877df107aba0bc20b" +} diff --git a/packages/plugins/chatwoot/rollup.config.js b/packages/plugins/chatwoot/rollup.config.js new file mode 100644 index 000000000..4a9dd95b7 --- /dev/null +++ b/packages/plugins/chatwoot/rollup.config.js @@ -0,0 +1,21 @@ +import typescript from 'rollup-plugin-typescript2' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import commonjs from '@rollup/plugin-commonjs' +export default { + input: ['src/index.ts'], + output: [ + { + dir: 'dist', + entryFileNames: '[name].cjs', + format: 'cjs', + exports: 'named', + }, + ], + plugins: [ + commonjs(), + nodeResolve({ + resolveOnly: (module) => !/@builderbot\/bot/i.test(module), + }), + typescript(), + ], +} diff --git a/packages/plugins/chatwoot/src/chatwootApi.ts b/packages/plugins/chatwoot/src/chatwootApi.ts new file mode 100644 index 000000000..e54356afc --- /dev/null +++ b/packages/plugins/chatwoot/src/chatwootApi.ts @@ -0,0 +1,190 @@ +import type { + ChatwootContact, + ChatwootConversation, + ChatwootInbox, + ChatwootMessage, + ChatwootPluginConfig, + ChatwootSearchContactsPayload, +} from './types' + +class ChatwootApi { + private baseUrl: string + private headers: Record + private accountId: number + + constructor(config: ChatwootPluginConfig) { + this.baseUrl = `${config.url.replace(/\/$/, '')}/api/v1/accounts/${config.accountId}` + this.accountId = config.accountId + this.headers = { + 'Content-Type': 'application/json', + api_access_token: config.token, + } + } + + /** + * Crea un inbox tipo API channel en Chatwoot. + * Si ya existe uno con el mismo nombre, lo retorna. + */ + async findOrCreateInbox(name: string): Promise { + const existing = await this.listInboxes() + const found = existing.find((inbox) => inbox.name === name) + if (found) return found + + const response = await fetch(`${this.baseUrl}/inboxes`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + name, + channel: { + type: 'api', + webhook_url: '', + }, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`[Chatwoot] Error creating inbox: ${error}`) + } + + return (await response.json()) as ChatwootInbox + } + + /** + * Lista todos los inboxes de la cuenta. + */ + async listInboxes(): Promise { + const response = await fetch(`${this.baseUrl}/inboxes`, { + method: 'GET', + headers: this.headers, + }) + + if (!response.ok) return [] + + const data = (await response.json()) as { payload: ChatwootInbox[] } + return data?.payload ?? [] + } + + /** + * Busca un contacto por teléfono. Si no existe, lo crea. + */ + async findOrCreateContact(phone: string, name?: string): Promise { + const found = await this.searchContacts(phone) + if (found) return found + + return this.createContact(phone, name) + } + + /** + * Busca contactos por query (teléfono, nombre, email). + */ + async searchContacts(query: string): Promise { + const response = await fetch(`${this.baseUrl}/contacts/search?q=${encodeURIComponent(query)}`, { + method: 'GET', + headers: this.headers, + }) + + if (!response.ok) return null + + const data = (await response.json()) as ChatwootSearchContactsPayload + return data?.payload?.[0] ?? null + } + + /** + * Crea un nuevo contacto en Chatwoot. + */ + async createContact(phone: string, name?: string): Promise { + const response = await fetch(`${this.baseUrl}/contacts`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + name: name ?? phone, + phone_number: phone.startsWith('+') ? phone : `+${phone}`, + identifier: phone, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`[Chatwoot] Error creating contact: ${error}`) + } + + const data = (await response.json()) as { payload: { contact: ChatwootContact } } + return data?.payload?.contact ?? (data as unknown as ChatwootContact) + } + + /** + * Busca una conversación abierta para un contacto en un inbox. + * Si no existe, crea una nueva. + */ + async findOrCreateConversation(contactId: number, inboxId: number): Promise { + const existing = await this.getContactConversations(contactId) + const open = existing.find((conv) => conv.inbox_id === inboxId && conv.status !== 'resolved') + if (open) return open + + return this.createConversation(contactId, inboxId) + } + + /** + * Obtiene las conversaciones de un contacto. + */ + async getContactConversations(contactId: number): Promise { + const response = await fetch(`${this.baseUrl}/contacts/${contactId}/conversations`, { + method: 'GET', + headers: this.headers, + }) + + if (!response.ok) return [] + + const data = (await response.json()) as { payload: ChatwootConversation[] } + return data?.payload ?? [] + } + + /** + * Crea una nueva conversación en Chatwoot. + */ + async createConversation(contactId: number, inboxId: number): Promise { + const response = await fetch(`${this.baseUrl}/conversations`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + contact_id: contactId, + inbox_id: inboxId, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`[Chatwoot] Error creating conversation: ${error}`) + } + + return (await response.json()) as ChatwootConversation + } + + /** + * Envía un mensaje a una conversación de Chatwoot. + */ + async sendMessage( + conversationId: number, + content: string, + messageType: 'incoming' | 'outgoing' = 'incoming' + ): Promise { + const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/messages`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + content, + message_type: messageType, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`[Chatwoot] Error sending message: ${error}`) + } + + return (await response.json()) as ChatwootMessage + } +} + +export { ChatwootApi } diff --git a/packages/plugins/chatwoot/src/chatwootPlugin.ts b/packages/plugins/chatwoot/src/chatwootPlugin.ts new file mode 100644 index 000000000..444cdf7f2 --- /dev/null +++ b/packages/plugins/chatwoot/src/chatwootPlugin.ts @@ -0,0 +1,118 @@ +import type { CoreClass } from '@builderbot/bot' + +import { ChatwootApi } from './chatwootApi' +import type { ChatwootInbox, ChatwootPluginConfig } from './types' + +const DEFAULT_INBOX_NAME = 'BuilderBot Inbox' + +class ChatwootPlugin { + private api: ChatwootApi + private config: ChatwootPluginConfig + private inbox: ChatwootInbox | null = null + private conversationCache = new Map() + private contactCache = new Map() + + constructor(config: ChatwootPluginConfig) { + this.config = config + this.api = new ChatwootApi(config) + } + + /** + * Conecta el plugin al bot. Una sola línea y listo. + * + * ```ts + * const bot = await createBot({ flow, provider, database }) + * await chatwoot.attach(bot) + * ``` + */ + async attach(bot: CoreClass): Promise { + const inboxName = this.config.inboxName ?? DEFAULT_INBOX_NAME + this.inbox = await this.api.findOrCreateInbox(inboxName) + console.log(`[Chatwoot] Inbox "${this.inbox.name}" ready (id: ${this.inbox.id})`) + + bot.on('send_message', async (payload) => { + try { + const { from, answer } = payload + if (!from || !answer) return + + const content = Array.isArray(answer) ? answer.join('\n') : String(answer) + if (!content || content.startsWith('__')) return + + const conversationId = await this.resolveConversation(from) + await this.api.sendMessage(conversationId, content, 'outgoing') + } catch (err) { + console.error('[Chatwoot] Error syncing outgoing message:', err) + } + }) + + bot.provider.on('message', async (payload: { from: string; body: string; name?: string }) => { + try { + const { from, body, name } = payload + if (!from || !body) return + + const conversationId = await this.resolveConversation(from, name) + await this.api.sendMessage(conversationId, body, 'incoming') + } catch (err) { + console.error('[Chatwoot] Error syncing incoming message:', err) + } + }) + + console.log(`[Chatwoot] Plugin attached successfully`) + } + + /** + * Resuelve (o crea) el contacto y la conversación en Chatwoot para un número dado. + */ + private async resolveConversation(phone: string, name?: string): Promise { + const cached = this.conversationCache.get(phone) + if (cached) return cached + + let contactId = this.contactCache.get(phone) + if (!contactId) { + const contact = await this.api.findOrCreateContact(phone, name) + if (!contact?.id) throw new Error(`[Chatwoot] Could not resolve contact for ${phone}`) + contactId = contact.id + this.contactCache.set(phone, contactId) + } + + if (!this.inbox) throw new Error('[Chatwoot] Plugin not attached yet. Call attach() first.') + const conversation = await this.api.findOrCreateConversation(contactId, this.inbox.id) + this.conversationCache.set(phone, conversation.id) + + return conversation.id + } + + /** + * Acceso directo a la API de Chatwoot para operaciones avanzadas. + */ + getApi(): ChatwootApi { + return this.api + } + + /** + * Retorna el inbox creado por el plugin. + */ + getInbox(): ChatwootInbox | null { + return this.inbox + } +} + +/** + * Crea una instancia del plugin de Chatwoot. + * + * ```ts + * const chatwoot = createChatwootPlugin({ + * token: 'tu-token', + * url: 'https://app.chatwoot.com', + * accountId: 1, + * }) + * + * const bot = await createBot({ flow, provider, database }) + * await chatwoot.attach(bot) + * ``` + */ +const createChatwootPlugin = (config: ChatwootPluginConfig): ChatwootPlugin => { + return new ChatwootPlugin(config) +} + +export { ChatwootPlugin, createChatwootPlugin } diff --git a/packages/plugins/chatwoot/src/index.ts b/packages/plugins/chatwoot/src/index.ts new file mode 100644 index 000000000..c46b80ff6 --- /dev/null +++ b/packages/plugins/chatwoot/src/index.ts @@ -0,0 +1,3 @@ +export { ChatwootPlugin, createChatwootPlugin } from './chatwootPlugin' +export { ChatwootApi } from './chatwootApi' +export * from './types' diff --git a/packages/plugins/chatwoot/src/types.ts b/packages/plugins/chatwoot/src/types.ts new file mode 100644 index 000000000..893b237d0 --- /dev/null +++ b/packages/plugins/chatwoot/src/types.ts @@ -0,0 +1,61 @@ +/** + * Configuración principal del plugin de Chatwoot. + * Solo necesitas `token`, `url` y `accountId` para empezar. + */ +export interface ChatwootPluginConfig { + /** API access token de Chatwoot (User o Agent token) */ + token: string + /** URL base de tu instancia de Chatwoot (ej: https://app.chatwoot.com) */ + url: string + /** ID de la cuenta en Chatwoot */ + accountId: number + /** Nombre del inbox que se creará automáticamente (default: 'BuilderBot Inbox') */ + inboxName?: string +} + +export interface ChatwootContact { + id?: number + name?: string + phone_number?: string + email?: string + identifier?: string +} + +export interface ChatwootConversation { + id: number + inbox_id: number + contact_id: number + status?: string + account_id?: number +} + +export interface ChatwootInbox { + id: number + name: string + channel_type?: string + webhook_url?: string +} + +export interface ChatwootMessage { + id?: number + content: string + message_type: 'incoming' | 'outgoing' + content_type?: string + private?: boolean +} + +export interface ChatwootApiResponse { + success: boolean + data?: T + error?: string +} + +export interface ChatwootSearchContactsPayload { + payload: ChatwootContact[] +} + +export interface ChatwootConversationsPayload { + data: { + payload: ChatwootConversation[] + } +} diff --git a/packages/plugins/chatwoot/tsconfig.json b/packages/plugins/chatwoot/tsconfig.json new file mode 100644 index 000000000..bed0d7489 --- /dev/null +++ b/packages/plugins/chatwoot/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "es2021", + "types": ["node"] + }, + "include": ["src/**/*.js", "src/**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9a36b4056..e44ffadc8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - 'packages/*' + - 'packages/plugins/*' From 0f9e395665a11992c52e90fe91387b3b076b9abd Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 2 Apr 2026 10:37:06 +0200 Subject: [PATCH 02/31] feat(plugin-chatwoot): enhance Chatwoot plugin with webhook support and media handli --- .gitignore | 2 + package.json | 1 + packages/plugins/.gitkeep | 0 packages/plugins/chatwoot/README.md | 298 +++++++++++++++ .../chatwoot/__tests__/chatwootPlugin.test.ts | 361 +++++++++++++++++- packages/plugins/chatwoot/package.json | 13 +- packages/plugins/chatwoot/rollup.config.js | 5 + packages/plugins/chatwoot/src/chatwootApi.ts | 140 ++++++- .../plugins/chatwoot/src/chatwootPlugin.ts | 140 ++++++- packages/plugins/chatwoot/src/types.ts | 72 +++- packages/plugins/chatwoot/tsconfig.json | 1 + pnpm-lock.yaml | 28 ++ 12 files changed, 1012 insertions(+), 49 deletions(-) create mode 100644 packages/plugins/.gitkeep create mode 100644 packages/plugins/chatwoot/README.md diff --git a/.gitignore b/.gitignore index d1feed8a8..7b9a9f574 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,9 @@ /packages/portal/server /packages/*/starters /packages/*/node_modules +/packages/*/*/node_modules /packages/*/dist +/packages/*/*/dist /packages/*/*/tokens/* /packages/*/docs/dist /packages/provider/src/venom/tokens diff --git a/package.json b/package.json index b8129504b..14dd1b06b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "repository": "https://github.com/leifermendez/bot-whatsapp", "license": "ISC", "workspaces": [ + "packages/plugins/*", "packages/bot", "packages/cli", "packages/create-builderbot", diff --git a/packages/plugins/.gitkeep b/packages/plugins/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugins/chatwoot/README.md b/packages/plugins/chatwoot/README.md new file mode 100644 index 000000000..d7c92f411 --- /dev/null +++ b/packages/plugins/chatwoot/README.md @@ -0,0 +1,298 @@ +

+ +

@builderbot/plugin-chatwoot

+ +

+ +

+ Syncs every WhatsApp conversation to Chatwoot automatically.
+ Agents can reply directly from Chatwoot and their messages are forwarded to WhatsApp in real time. +

+ +--- + +## What it does + +| Feature | Details | +|---|---| +| **Inbox auto-creation** | Creates an API-channel inbox on first run and reuses it on subsequent starts | +| **Contact sync** | Finds or creates the Chatwoot contact for every phone number | +| **Conversation sync** | Finds or creates the open conversation and caches it in memory | +| **Media attachments** | Sends images/files received from WhatsApp as attachments in Chatwoot | +| **Bidirectional messages** | Bot outgoing messages → Chatwoot (outgoing) · User messages → Chatwoot (incoming) | +| **Agent replies** | Chatwoot agent messages → WhatsApp via `handleWebhook` | +| **Blacklist integration** | When an agent takes a conversation, the user is added to the bot blacklist so the bot stops responding | +| **Webhook auto-registration** | Optionally registers the webhook URL in Chatwoot on startup | +| **Group message filter** | `@g.us` group messages are silently ignored | +| **Startup validation** | Credentials are verified before anything runs; the plugin self-disables on failure | +| **Serialized API calls** | An internal queue ensures no race conditions against the Chatwoot API | + +--- + +## Installation + +```bash +pnpm add @builderbot/plugin-chatwoot +``` + +--- + +## Quick start + +```ts +import { createBot, createProvider, createFlow, MemoryDB } from '@builderbot/bot' +import { BaileysProvider } from '@builderbot/provider-baileys' +import { createChatwootPlugin } from '@builderbot/plugin-chatwoot' + +const chatwoot = createChatwootPlugin({ + token: 'YOUR_CHATWOOT_USER_TOKEN', + url: 'https://app.chatwoot.com', + accountId: 1, +}) + +const bot = await createBot({ + flow: createFlow([...]), + provider: createProvider(BaileysProvider), + database: new MemoryDB(), +}) + +// One call wires everything up +await chatwoot.attach(bot) +``` + +That's it. Every message exchanged through your bot is now mirrored in Chatwoot. + +--- + +## Configuration + +```ts +const chatwoot = createChatwootPlugin({ + /** User or Agent API access token from Chatwoot → Profile → Access Token */ + token: 'xxxxxxxxxxxxxxxxxxxxxxxx', + + /** Base URL of your Chatwoot instance */ + url: 'https://app.chatwoot.com', + + /** Numeric account ID visible in the Chatwoot URL */ + accountId: 1, + + /** Optional: custom inbox name (default: 'BuilderBot Inbox') */ + inboxName: 'WhatsApp Bot', + + /** + * Optional: public URL where Chatwoot will POST webhook events. + * When provided, the plugin registers (or reuses) the webhook on startup. + * Required for agent replies to reach WhatsApp. + */ + webhookUrl: 'https://your-bot.example.com/v1/chatwoot', +}) +``` + +### How to get your token + +1. Open Chatwoot → click your avatar (bottom-left) → **Profile Settings** +2. Scroll down to **Access Token** and copy it + +### How to find your accountId + +It is the number in the URL after `/app/accounts/`: +``` +https://app.chatwoot.com/app/accounts/42/conversations + ↑ + accountId = 42 +``` + +--- + +## Receiving agent replies (webhook) + +When a Chatwoot agent sends a message, Chatwoot fires a webhook. You need to expose an HTTP endpoint and call `handleWebhook` inside it. + +### With BuilderBot's built-in HTTP server + +```ts +import { createBot, createFlow, addKeyword, handleCtx } from '@builderbot/bot' +import { BaileysProvider } from '@builderbot/provider-baileys' +import { createChatwootPlugin } from '@builderbot/plugin-chatwoot' + +const chatwoot = createChatwootPlugin({ + token: 'YOUR_TOKEN', + url: 'https://app.chatwoot.com', + accountId: 1, + webhookUrl: 'https://your-bot.example.com/v1/chatwoot', +}) + +const provider = createProvider(BaileysProvider, { name: 'bot' }) + +const bot = await createBot({ + flow: createFlow([...]), + provider, + database: new MemoryDB(), +}) + +await chatwoot.attach(bot) + +// Expose the webhook endpoint +provider.server.post( + '/v1/chatwoot', + handleCtx(async (bot, req, res) => { + await chatwoot.handleWebhook(bot, req.body) + res.end(JSON.stringify({ status: 'ok' })) + }) +) +``` + +> **Note:** The URL you pass as `webhookUrl` must be reachable by your Chatwoot server. +> For local development use [ngrok](https://ngrok.com/) or a similar tunnel. + +### What `handleWebhook` handles + +| Chatwoot event | Plugin action | +|---|---| +| `conversation_updated` — agent **assigned** | Adds the user's phone to the bot blacklist (bot stops responding) | +| `conversation_updated` — agent **unassigned** | Removes the phone from the blacklist (bot resumes) | +| `message_created` — outgoing on API channel | Forwards the agent's message (text + optional media) to WhatsApp | +| `message_created` — private note | Ignored — private notes are not forwarded | +| Event for a different inbox | Ignored — only events for the plugin's inbox are processed | + +--- + +## How agent takeover works + +``` +Agent assigned to conversation + │ + ▼ + phone added to blacklist ──► bot stops responding to that user + │ + Agent types in Chatwoot + │ + ▼ + handleWebhook receives message_created + │ + ▼ + Message forwarded to WhatsApp + +Agent unassigns from conversation + │ + ▼ + phone removed from blacklist ──► bot resumes normally +``` + +--- + +## Advanced usage + +### Accessing the Chatwoot API directly + +```ts +const api = chatwoot.getApi() + +// Create a contact manually +const contact = await api.findOrCreateContact('+5215511223344', 'John Doe') + +// Send a message to an existing conversation +await api.sendMessage(conversationId, 'Hello from the API!', 'outgoing') + +// Send a message with a media attachment +await api.sendMessage(conversationId, 'See attached', 'outgoing', 'https://example.com/image.png') +// Or from a local file: +await api.sendMessage(conversationId, 'See attached', 'outgoing', '/tmp/photo.jpg') +``` + +### Inspecting the active inbox + +```ts +const inbox = chatwoot.getInbox() +console.log(inbox?.id, inbox?.name) +``` + +### Checking plugin health + +```ts +if (!chatwoot.status) { + console.warn('Chatwoot plugin is disabled — check your credentials') +} +``` + +--- + +## Environment variables (recommended) + +Store sensitive values in a `.env` file instead of hardcoding them: + +```env +CHATWOOT_TOKEN=your-access-token +CHATWOOT_URL=https://app.chatwoot.com +CHATWOOT_ACCOUNT_ID=1 +CHATWOOT_WEBHOOK_URL=https://your-bot.example.com/v1/chatwoot +``` + +```ts +const chatwoot = createChatwootPlugin({ + token: process.env.CHATWOOT_TOKEN!, + url: process.env.CHATWOOT_URL!, + accountId: Number(process.env.CHATWOOT_ACCOUNT_ID), + webhookUrl: process.env.CHATWOOT_WEBHOOK_URL, +}) +``` + +--- + +## Supported media types + +The plugin automatically detects the MIME type from the file extension when uploading attachments. + +| Extension | MIME type | +|---|---| +| `.jpg` / `.jpeg` | `image/jpeg` | +| `.png` | `image/png` | +| `.gif` | `image/gif` | +| `.webp` | `image/webp` | +| `.mp4` | `video/mp4` | +| `.pdf` | `application/pdf` | +| `.mp3` / `.ogg` | `audio/mpeg` / `audio/ogg` | +| other | `application/octet-stream` | + +--- + +## API reference + +### `createChatwootPlugin(config)` + +Creates the plugin instance. Returns a `ChatwootPlugin`. + +### `chatwoot.attach(bot)` + +Wires the plugin into the bot. Must be called once after `createBot`. + +- Validates Chatwoot credentials (`checkAccount`) +- Finds or creates the API-channel inbox +- Registers the webhook in Chatwoot if `webhookUrl` is configured +- Listens to `send_message` and `provider.message` events + +### `chatwoot.handleWebhook(bot, body)` + +Processes a raw webhook body sent by Chatwoot. Call this from your HTTP route handler. + +### `chatwoot.getApi()` + +Returns the underlying `ChatwootApi` instance for direct API calls. + +### `chatwoot.getInbox()` + +Returns the `ChatwootInbox` object (`{ id, name }`) or `null` before `attach()`. + +### `chatwoot.status` + +`true` while the plugin is operational. Set to `false` automatically if credentials fail. + +--- + +## Links + +- [BuilderBot documentation](https://builderbot.app/) +- [Chatwoot documentation](https://www.chatwoot.com/docs/) +- [💻 Discord](https://link.codigoencasa.com/DISCORD) +- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) diff --git a/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts b/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts index 3c82954a6..3a9654357 100644 --- a/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts +++ b/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts @@ -4,52 +4,375 @@ import * as assert from 'uvu/assert' import { ChatwootApi } from '../src/chatwootApi' import { ChatwootPlugin, createChatwootPlugin } from '../src/chatwootPlugin' +// ─── config ─────────────────────────────────────────────────────────────────── + const MOCK_CONFIG = { token: 'test-token-123', url: 'https://chatwoot.example.com', accountId: 1, } +const MOCK_INBOX = { id: 42, name: 'BuilderBot Inbox' } + +// ─── fetch mock ─────────────────────────────────────────────────────────────── + +type MockFn = (url: string, opts?: RequestInit) => Promise + +/** + * Smart fetch mock: matches requests by method + URL substring. + * More specific entries (longer path strings) win over shorter ones. + */ +const makeSmartFetch = (overrides: Record = {}): { mock: MockFn; calls: string[] } => { + const calls: string[] = [] + + const defaults: Record = { + 'GET /api/v1/accounts/1/': { id: 1 }, + 'GET /inboxes': { payload: [MOCK_INBOX] }, + 'POST /inboxes': MOCK_INBOX, + 'GET /webhooks': { payload: { webhooks: [] } }, + 'POST /webhooks': { id: 1, url: '' }, + 'GET /contacts/search': { payload: [] }, + 'GET /conversations': { payload: [] }, + 'POST /contacts': { payload: { contact: { id: 10 } } }, + 'POST /conversations': { id: 99, inbox_id: 42, contact_id: 10 }, + 'POST /messages': { id: 1, content: 'ok', message_type: 'outgoing' }, + ...overrides, + } + + const mock: MockFn = async (url, opts) => { + const method = (opts?.method ?? 'GET').toUpperCase() + calls.push(`${method} ${String(url)}`) + + const key = Object.keys(defaults) + .sort((a, b) => b.length - a.length) + .find((k) => { + const space = k.indexOf(' ') + const kMethod = k.slice(0, space) + const kPath = k.slice(space + 1) + return kMethod === method && String(url).includes(kPath) + }) + + const body = key !== undefined ? defaults[key] : {} + return { + ok: true, + json: async () => body, + text: async () => JSON.stringify(body), + headers: new Headers({ 'content-type': 'application/json' }), + arrayBuffer: async () => new ArrayBuffer(0), + } as unknown as Response + } + + return { mock, calls } +} + +/** Temporarily replace global.fetch, restore after fn resolves. */ +const withFetch = async (mockFn: MockFn, fn: () => Promise): Promise => { + const original = (global as any).fetch + ;(global as any).fetch = mockFn + try { + return await fn() + } finally { + ;(global as any).fetch = original + } +} + +/** Wait for the internal SimpleQueue to drain. */ +const drainQueue = () => new Promise((r) => setTimeout(r, 60)) + +// ─── mock bot ───────────────────────────────────────────────────────────────── + +const makeMockBot = () => { + const botHandlers: Record unknown>> = {} + const provHandlers: Record unknown>> = {} + return { + on(ev: string, h: (...a: unknown[]) => unknown) { + botHandlers[ev] = [...(botHandlers[ev] ?? []), h] + }, + emit: (ev: string, payload: unknown) => Promise.all((botHandlers[ev] ?? []).map((h) => h(payload))), + provider: { + on(ev: string, h: (...a: unknown[]) => unknown) { + provHandlers[ev] = [...(provHandlers[ev] ?? []), h] + }, + emit: (ev: string, payload: unknown) => Promise.all((provHandlers[ev] ?? []).map((h) => h(payload))), + }, + blacklist: { + items: new Set(), + add(p: string) { + this.items.add(p) + }, + remove(p: string) { + this.items.delete(p) + }, + checkIf(p: string) { + return this.items.has(p) + }, + }, + sent: [] as Array<{ number: string; content: string; options?: unknown }>, + async sendMessage(number: string, content: string, options?: unknown) { + this.sent.push({ number, content, options }) + }, + } +} + +// ─── existing construction tests ───────────────────────────────────────────── + test('createChatwootPlugin returns a ChatwootPlugin instance', () => { - const plugin = createChatwootPlugin(MOCK_CONFIG) - assert.instance(plugin, ChatwootPlugin) + assert.instance(createChatwootPlugin(MOCK_CONFIG), ChatwootPlugin) }) test('ChatwootPlugin exposes getApi()', () => { - const plugin = createChatwootPlugin(MOCK_CONFIG) - const api = plugin.getApi() - assert.instance(api, ChatwootApi) + assert.instance(createChatwootPlugin(MOCK_CONFIG).getApi(), ChatwootApi) }) test('ChatwootPlugin getInbox() returns null before attach', () => { + assert.is(createChatwootPlugin(MOCK_CONFIG).getInbox(), null) +}) + +test('createChatwootPlugin uses default inbox name', () => { + assert.instance(createChatwootPlugin(MOCK_CONFIG), ChatwootPlugin) +}) + +test('createChatwootPlugin accepts custom inbox name', () => { + assert.instance(createChatwootPlugin({ ...MOCK_CONFIG, inboxName: 'Custom Inbox' }), ChatwootPlugin) +}) + +test('ChatwootApi constructs with correct base URL', () => { + assert.instance(new ChatwootApi(MOCK_CONFIG), ChatwootApi) +}) + +test('ChatwootApi trims trailing slash from URL', () => { + assert.instance(new ChatwootApi({ ...MOCK_CONFIG, url: 'https://chatwoot.example.com/' }), ChatwootApi) +}) + +// ─── status flag ───────────────────────────────────────────────────────────── + +test('plugin status is true by default', () => { + assert.is(createChatwootPlugin(MOCK_CONFIG).status, true) +}) + +test('attach() sets status=false and skips inbox when credentials are invalid', async () => { + const { mock } = makeSmartFetch({ 'GET /api/v1/accounts/1/': { error: 'unauthorized' } }) const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + assert.is(plugin.status, false) assert.is(plugin.getInbox(), null) }) -test('createChatwootPlugin uses default inbox name', () => { +test('attach() sets inbox and keeps status=true when credentials are valid', async () => { + const { mock } = makeSmartFetch() const plugin = createChatwootPlugin(MOCK_CONFIG) - assert.instance(plugin, ChatwootPlugin) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + assert.is(plugin.status, true) + assert.is(plugin.getInbox()?.id, MOCK_INBOX.id) }) -test('createChatwootPlugin accepts custom inbox name', () => { - const plugin = createChatwootPlugin({ - ...MOCK_CONFIG, - inboxName: 'Custom Inbox', +// ─── webhook auto-creation ──────────────────────────────────────────────────── + +test('attach() registers webhook when webhookUrl is configured', async () => { + const { mock, calls } = makeSmartFetch() + const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/chatwoot' }) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + assert.ok( + calls.some((c) => c.startsWith('POST') && c.includes('/webhooks')), + 'POST /webhooks was called to register the webhook' + ) +}) + +test('attach() skips webhook registration when webhookUrl is not configured', async () => { + const { mock, calls } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + assert.not.ok( + calls.some((c) => c.startsWith('POST') && c.includes('/webhooks')), + 'POST /webhooks should not be called without webhookUrl' + ) +}) + +// ─── group filter ───────────────────────────────────────────────────────────── + +test('send_message handler skips @g.us group messages', async () => { + const { mock, calls } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + const countAfterAttach = calls.length + + await withFetch(mock, async () => { + await bot.emit('send_message', { from: '1234567890@g.us', answer: 'Hello group' }) + await drainQueue() + }) + assert.is(calls.length, countAfterAttach, 'no fetch calls for group send_message') +}) + +test('provider message handler skips @g.us group messages', async () => { + const { mock, calls } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + const countAfterAttach = calls.length + + await withFetch(mock, async () => { + await bot.provider.emit('message', { from: '9876543210@g.us', body: 'Group message' }) + await drainQueue() + }) + assert.is(calls.length, countAfterAttach, 'no fetch calls for group provider message') +}) + +// ─── handleWebhook ──────────────────────────────────────────────────────────── + +test('handleWebhook returns early when inbox ID does not match', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + + await plugin.handleWebhook(bot as any, { + event: 'message_created', + message_type: 'outgoing', + private: false, + content: 'Should be ignored', + conversation: { inbox_id: 9999, channel: 'Channel::Api', meta: { sender: { phone_number: '+1234' } } }, + }) + assert.is(bot.sent.length, 0, 'sendMessage not called for mismatched inbox') +}) + +test('handleWebhook adds phone to blacklist when agent is assigned', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + + await plugin.handleWebhook(bot as any, { + event: 'conversation_updated', + changed_attributes: [{ assignee_id: { current_value: 7 } }], + meta: { sender: { phone_number: '+5215511223344' } }, + conversation: { inbox_id: MOCK_INBOX.id }, + }) + assert.ok(bot.blacklist.items.has('5215511223344'), 'phone added to blacklist') +}) + +test('handleWebhook removes phone from blacklist when agent is unassigned', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + + bot.blacklist.items.add('5215511223344') + + await plugin.handleWebhook(bot as any, { + event: 'conversation_updated', + changed_attributes: [{ assignee_id: { current_value: null } }], + meta: { sender: { phone_number: '+5215511223344' } }, + conversation: { inbox_id: MOCK_INBOX.id }, }) - assert.instance(plugin, ChatwootPlugin) + assert.not.ok(bot.blacklist.items.has('5215511223344'), 'phone removed from blacklist') }) -test('ChatwootApi constructs correct base URL', () => { +test('handleWebhook forwards outgoing API channel message to WhatsApp', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + + await plugin.handleWebhook(bot as any, { + event: 'message_created', + message_type: 'outgoing', + private: false, + content: 'Hello from agent', + conversation: { + inbox_id: MOCK_INBOX.id, + channel: 'Channel::Api', + meta: { sender: { phone_number: '+5215511223344' } }, + }, + }) + assert.is(bot.sent.length, 1, 'sendMessage called once') + assert.is(bot.sent[0].number, '5215511223344') + assert.is(bot.sent[0].content, 'Hello from agent') +}) + +test('handleWebhook does not forward private messages to WhatsApp', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + + await plugin.handleWebhook(bot as any, { + event: 'message_created', + message_type: 'outgoing', + private: true, + content: 'Internal agent note', + conversation: { + inbox_id: MOCK_INBOX.id, + channel: 'Channel::Api', + meta: { sender: { phone_number: '+5215511223344' } }, + }, + }) + assert.is(bot.sent.length, 0, 'private message must not be forwarded') +}) + +test('handleWebhook is a no-op before attach()', async () => { + const bot = makeMockBot() + const plugin = createChatwootPlugin(MOCK_CONFIG) + await plugin.handleWebhook(bot as any, { + event: 'message_created', + message_type: 'outgoing', + private: false, + content: 'Should be ignored', + conversation: { inbox_id: 42, channel: 'Channel::Api' }, + }) + assert.is(bot.sent.length, 0, 'no action before attach') +}) + +// ─── media support ──────────────────────────────────────────────────────────── + +test('ChatwootApi.sendMessage sends JSON when no media is provided', async () => { + const { mock, calls } = makeSmartFetch() const api = new ChatwootApi(MOCK_CONFIG) - assert.instance(api, ChatwootApi) + await withFetch(mock, () => api.sendMessage(1, 'hello', 'outgoing')) + assert.ok( + calls.some((c) => c.startsWith('POST') && c.includes('/messages')), + 'POST /messages called for text message' + ) }) -test('ChatwootApi trims trailing slash from URL', () => { - const api = new ChatwootApi({ - ...MOCK_CONFIG, - url: 'https://chatwoot.example.com/', +test('ChatwootApi.sendMessage sends FormData when mediaSource is provided', async () => { + const bodies: unknown[] = [] + const captureFetch: MockFn = async (_url, opts) => { + bodies.push(opts?.body) + return { + ok: true, + json: async () => ({ id: 1, content: 'ok', message_type: 'outgoing' }), + text: async () => '{}', + headers: new Headers({ 'content-type': 'application/json' }), + // arrayBuffer is needed for the media download step + arrayBuffer: async () => new ArrayBuffer(8), + } as unknown as Response + } + const api = new ChatwootApi(MOCK_CONFIG) + await withFetch(captureFetch, () => api.sendMessage(1, 'with media', 'outgoing', 'https://example.com/image.png')) + const formDataBody = bodies.find((b) => b instanceof FormData) + assert.instance(formDataBody, FormData, 'a POST body should be FormData when media is provided') +}) + +// ─── SimpleQueue serialization ──────────────────────────────────────────────── + +test('enqueued messages are processed without dropping', async () => { + const { mock, calls } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + const countAfterAttach = calls.length + + await withFetch(mock, async () => { + await bot.emit('send_message', { from: '5215511223344', answer: 'msg1' }) + await bot.emit('send_message', { from: '5215511223344', answer: 'msg2' }) + await drainQueue() }) - assert.instance(api, ChatwootApi) + assert.ok(calls.length > countAfterAttach, 'fetch calls were made for enqueued messages') }) test.run() diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index a2c3427bb..dd92b4eb0 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -7,7 +7,13 @@ "license": "ISC", "main": "dist/index.cjs", "types": "dist/index.d.ts", - "type": "module", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, "directories": { "src": "src", "test": "__tests__" @@ -22,7 +28,7 @@ "scripts": { "build": "rimraf dist && rollup --config", "test": "npx uvu -r tsm ./__tests__ .test.ts", - "test:coverage": "npx c8 npm run test", + "test:coverage": "npx c8 pnpm test", "test:debug": "npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts" }, "bugs": { @@ -35,11 +41,10 @@ "devDependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", "@types/node": "^24.10.2", "rimraf": "^6.1.2", "rollup-plugin-typescript2": "^0.36.0", - "tslib": "^2.6.2", + "tslib": "^2.8.1", "tsm": "^2.3.0" }, "gitHead": "8ca5bde13fa8276a8bdeac9877df107aba0bc20b" diff --git a/packages/plugins/chatwoot/rollup.config.js b/packages/plugins/chatwoot/rollup.config.js index 4a9dd95b7..782032ce3 100644 --- a/packages/plugins/chatwoot/rollup.config.js +++ b/packages/plugins/chatwoot/rollup.config.js @@ -10,6 +10,11 @@ export default { format: 'cjs', exports: 'named', }, + { + dir: 'dist', + entryFileNames: '[name].mjs', + format: 'esm', + }, ], plugins: [ commonjs(), diff --git a/packages/plugins/chatwoot/src/chatwootApi.ts b/packages/plugins/chatwoot/src/chatwootApi.ts index e54356afc..5ca4869d1 100644 --- a/packages/plugins/chatwoot/src/chatwootApi.ts +++ b/packages/plugins/chatwoot/src/chatwootApi.ts @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises' + import type { ChatwootContact, ChatwootConversation, @@ -7,20 +9,57 @@ import type { ChatwootSearchContactsPayload, } from './types' +/** Minimal MIME lookup by file extension — avoids external dependencies. */ +const getContentType = (filename: string): string => { + const ext = filename.split('.').pop()?.toLowerCase() ?? '' + const map: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + mp4: 'video/mp4', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + opus: 'audio/ogg', + wav: 'audio/wav', + pdf: 'application/pdf', + svg: 'image/svg+xml', + } + return map[ext] ?? 'application/octet-stream' +} + class ChatwootApi { private baseUrl: string + private token: string private headers: Record - private accountId: number constructor(config: ChatwootPluginConfig) { this.baseUrl = `${config.url.replace(/\/$/, '')}/api/v1/accounts/${config.accountId}` - this.accountId = config.accountId + this.token = config.token this.headers = { 'Content-Type': 'application/json', api_access_token: config.token, } } + /** + * Verifica que las credenciales sean válidas contra la API de Chatwoot. + * Retorna `true` si la cuenta es accesible. + */ + async checkAccount(): Promise { + try { + const response = await fetch(`${this.baseUrl}/`, { + method: 'GET', + headers: this.headers, + }) + const data = (await response.json()) as { error?: string } + return !data?.error + } catch { + return false + } + } + /** * Crea un inbox tipo API channel en Chatwoot. * Si ya existe uno con el mismo nombre, lo retorna. @@ -163,12 +202,18 @@ class ChatwootApi { /** * Envía un mensaje a una conversación de Chatwoot. + * Si se provee `mediaSource` (URL o ruta local), el mensaje se envía con adjunto via FormData. */ async sendMessage( conversationId: number, content: string, - messageType: 'incoming' | 'outgoing' = 'incoming' + messageType: 'incoming' | 'outgoing' = 'incoming', + mediaSource?: string | null ): Promise { + if (mediaSource) { + return this.sendMessageWithMedia(conversationId, content, messageType, mediaSource) + } + const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/messages`, { method: 'POST', headers: this.headers, @@ -185,6 +230,95 @@ class ChatwootApi { return (await response.json()) as ChatwootMessage } + + /** + * Envía un mensaje con adjunto multimedia a una conversación. + * `mediaSource` puede ser una URL https:// o una ruta local en disco. + */ + private async sendMessageWithMedia( + conversationId: number, + content: string, + messageType: 'incoming' | 'outgoing', + mediaSource: string + ): Promise { + const form = new FormData() + form.set('content', content) + form.set('message_type', messageType) + + try { + const isUrl = mediaSource.startsWith('http://') || mediaSource.startsWith('https://') + + if (isUrl) { + const mediaResponse = await fetch(mediaSource) + if (mediaResponse.ok) { + const buffer = await mediaResponse.arrayBuffer() + const contentType = mediaResponse.headers.get('content-type') ?? 'application/octet-stream' + const fileName = mediaSource.split('/').pop()?.split('?')[0] ?? 'file' + form.set('attachments[]', new Blob([buffer], { type: contentType }), fileName) + } + } else { + const fileName = mediaSource.split('/').pop() ?? 'file' + const fileBuffer = await readFile(mediaSource) + const mimeType = getContentType(fileName) + form.set('attachments[]', new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), fileName) + } + } catch (mediaErr) { + console.error('[Chatwoot] Could not attach media, sending text-only:', mediaErr) + } + + const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/messages`, { + method: 'POST', + headers: { api_access_token: this.token }, + body: form, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`[Chatwoot] Error sending message with media: ${error}`) + } + + return (await response.json()) as ChatwootMessage + } + + /** + * Busca un webhook existente cuya URL contenga `matchUrl`. + */ + async findWebhook(matchUrl: string): Promise<{ id: number; url: string } | null> { + try { + const response = await fetch(`${this.baseUrl}/webhooks`, { + method: 'GET', + headers: this.headers, + }) + + if (!response.ok) return null + + const data = (await response.json()) as { payload?: { webhooks?: Array<{ id: number; url: string }> } } + const webhooks = data?.payload?.webhooks ?? [] + return webhooks.find((w) => w.url === matchUrl) ?? null + } catch { + return null + } + } + + /** + * Crea un webhook en Chatwoot. + */ + async createWebhook(url: string, subscriptions: string[]): Promise { + try { + const response = await fetch(`${this.baseUrl}/webhooks`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ webhook: { url, subscriptions } }), + }) + + if (!response.ok) { + const error = await response.text() + console.error(`[Chatwoot] Error creating webhook: ${error}`) + } + } catch (err) { + console.error('[Chatwoot] Error creating webhook:', err) + } + } } export { ChatwootApi } diff --git a/packages/plugins/chatwoot/src/chatwootPlugin.ts b/packages/plugins/chatwoot/src/chatwootPlugin.ts index 444cdf7f2..6f49f6e7a 100644 --- a/packages/plugins/chatwoot/src/chatwootPlugin.ts +++ b/packages/plugins/chatwoot/src/chatwootPlugin.ts @@ -1,9 +1,40 @@ import type { CoreClass } from '@builderbot/bot' import { ChatwootApi } from './chatwootApi' -import type { ChatwootInbox, ChatwootPluginConfig } from './types' +import type { + BotIncomingMessagePayload, + BotOutgoingPayload, + ChatwootBotRef, + ChatwootInbox, + ChatwootPluginConfig, + ChatwootWebhookBody, +} from './types' const DEFAULT_INBOX_NAME = 'BuilderBot Inbox' +const WEBHOOK_SUBSCRIPTIONS = ['conversation_updated', 'message_created'] + +/** + * Minimal single-concurrency async queue. + * Ensures Chatwoot API calls are serialized to avoid race conditions. + */ +class SimpleQueue { + private running = false + private tasks: Array<() => Promise> = [] + + enqueue(task: () => Promise): void { + this.tasks.push(task) + if (!this.running) this.drain() + } + + private async drain(): Promise { + this.running = true + while (this.tasks.length > 0) { + const task = this.tasks.shift()! + await task().catch((err) => console.error('[Chatwoot] Queue task error:', err)) + } + this.running = false + } +} class ChatwootPlugin { private api: ChatwootApi @@ -11,6 +42,10 @@ class ChatwootPlugin { private inbox: ChatwootInbox | null = null private conversationCache = new Map() private contactCache = new Map() + private messageQueue = new SimpleQueue() + + /** False if Chatwoot credentials are invalid or unreachable. All operations are skipped when false. */ + public status = true constructor(config: ChatwootPluginConfig) { this.config = config @@ -26,12 +61,32 @@ class ChatwootPlugin { * ``` */ async attach(bot: CoreClass): Promise { + const accountOk = await this.api.checkAccount() + if (!accountOk) { + console.error('[Chatwoot] Invalid credentials or unreachable endpoint. Plugin disabled.') + this.status = false + return + } + const inboxName = this.config.inboxName ?? DEFAULT_INBOX_NAME this.inbox = await this.api.findOrCreateInbox(inboxName) console.log(`[Chatwoot] Inbox "${this.inbox.name}" ready (id: ${this.inbox.id})`) + if (this.config.webhookUrl) { + const existing = await this.api.findWebhook(this.config.webhookUrl) + if (!existing) { + await this.api.createWebhook(this.config.webhookUrl, WEBHOOK_SUBSCRIPTIONS) + console.log(`[Chatwoot] Webhook registered: ${this.config.webhookUrl}`) + } else { + console.log(`[Chatwoot] Webhook already exists (id: ${existing.id})`) + } + } + bot.on('send_message', async (payload) => { - try { + if (!this.status) return + if (payload.from?.includes('@g.us')) return + + this.messageQueue.enqueue(async () => { const { from, answer } = payload if (!from || !answer) return @@ -39,25 +94,81 @@ class ChatwootPlugin { if (!content || content.startsWith('__')) return const conversationId = await this.resolveConversation(from) - await this.api.sendMessage(conversationId, content, 'outgoing') - } catch (err) { - console.error('[Chatwoot] Error syncing outgoing message:', err) - } + const mediaUrl = (payload as unknown as BotOutgoingPayload).options?.media ?? null + await this.api.sendMessage(conversationId, content, 'outgoing', mediaUrl) + }) }) - bot.provider.on('message', async (payload: { from: string; body: string; name?: string }) => { - try { + bot.provider.on('message', async (payload: BotIncomingMessagePayload) => { + if (!this.status) return + if (payload.from?.includes('@g.us')) return + + this.messageQueue.enqueue(async () => { const { from, body, name } = payload if (!from || !body) return const conversationId = await this.resolveConversation(from, name) - await this.api.sendMessage(conversationId, body, 'incoming') - } catch (err) { - console.error('[Chatwoot] Error syncing incoming message:', err) - } + const mediaUrl = payload.options?.media ?? null + await this.api.sendMessage(conversationId, body, 'incoming', mediaUrl) + }) }) - console.log(`[Chatwoot] Plugin attached successfully`) + console.log('[Chatwoot] Plugin attached successfully') + } + + /** + * Procesa un webhook entrante desde Chatwoot. + * + * Wire this to your HTTP route handler: + * ```ts + * server.post('/v1/chatwoot', handleCtx(async (bot, req, res) => { + * await chatwoot.handleWebhook(bot, req.body) + * res.end(JSON.stringify({ status: 'ok' })) + * })) + * ``` + * + * Handles: + * - `conversation_updated` + assignee change → add/remove phone from blacklist + * - `message_created` outgoing on API channel → forward message to WhatsApp + */ + async handleWebhook(bot: CoreClass & ChatwootBotRef, body: ChatwootWebhookBody): Promise { + if (!this.inbox) return + + const inboxIdFromBody = + body?.conversation?.inbox_id ?? body?.inbox?.id ?? body?.conversation?.contact_inbox?.inbox_id + + if (inboxIdFromBody !== undefined && inboxIdFromBody !== this.inbox.id) return + + const changedKeys = body?.changed_attributes?.flatMap((attr) => Object.keys(attr)) ?? [] + + if (body?.event === 'conversation_updated' && changedKeys.includes('assignee_id')) { + const phone = body?.meta?.sender?.phone_number?.replace('+', '') + const idAssigned = (body?.changed_attributes?.[0] as any)?.assignee_id?.current_value ?? null + + if (phone) { + if (idAssigned) { + bot.blacklist?.add(phone) + } else if (bot.blacklist?.checkIf(phone)) { + bot.blacklist?.remove(phone) + } + } + return + } + + if ( + body?.private === false && + body?.event === 'message_created' && + body?.message_type === 'outgoing' && + body?.conversation?.channel?.includes('Channel::Api') + ) { + const phone = body?.conversation?.meta?.sender?.phone_number?.replace('+', '') + const content = body?.content ?? '' + const file = body?.attachments?.length ? body.attachments[0] : null + + if (phone) { + await bot.sendMessage(phone, content, { media: file?.data_url ?? null }) + } + } } /** @@ -71,7 +182,7 @@ class ChatwootPlugin { if (!contactId) { const contact = await this.api.findOrCreateContact(phone, name) if (!contact?.id) throw new Error(`[Chatwoot] Could not resolve contact for ${phone}`) - contactId = contact.id + contactId = contact.id! this.contactCache.set(phone, contactId) } @@ -105,6 +216,7 @@ class ChatwootPlugin { * token: 'tu-token', * url: 'https://app.chatwoot.com', * accountId: 1, + * webhookUrl: 'https://mi-bot.example.com/v1/chatwoot', * }) * * const bot = await createBot({ flow, provider, database }) diff --git a/packages/plugins/chatwoot/src/types.ts b/packages/plugins/chatwoot/src/types.ts index 893b237d0..e766f9528 100644 --- a/packages/plugins/chatwoot/src/types.ts +++ b/packages/plugins/chatwoot/src/types.ts @@ -11,6 +11,12 @@ export interface ChatwootPluginConfig { accountId: number /** Nombre del inbox que se creará automáticamente (default: 'BuilderBot Inbox') */ inboxName?: string + /** + * URL pública donde Chatwoot enviará webhooks hacia el bot. + * Si se provee, el plugin registrará (o reutilizará) el webhook automáticamente. + * Ej: 'https://mi-bot.example.com/v1/chatwoot' + */ + webhookUrl?: string } export interface ChatwootContact { @@ -19,6 +25,7 @@ export interface ChatwootContact { phone_number?: string email?: string identifier?: string + contact_inboxes?: Array<{ inbox: { id: number } }> } export interface ChatwootConversation { @@ -44,18 +51,65 @@ export interface ChatwootMessage { private?: boolean } -export interface ChatwootApiResponse { - success: boolean - data?: T - error?: string -} - export interface ChatwootSearchContactsPayload { payload: ChatwootContact[] } -export interface ChatwootConversationsPayload { - data: { - payload: ChatwootConversation[] +export interface BotIncomingMessagePayload { + from: string + body: string + name?: string + options?: { media?: string } +} + +/** + * Duck-typed interface for the bot fields used by handleWebhook. + * Combine with CoreClass: `bot: CoreClass & ChatwootBotRef`. + */ +export interface ChatwootBotRef { + blacklist?: { + add(phone: string): void + remove(phone: string): void + checkIf(phone: string): boolean + } + sendMessage(number: string, message: string, options?: { media?: string | null }): Promise +} + +/** + * Minimal shape of the send_message event payload for accessing options.media. + * The full payload is inferred from HostEventTypes; this covers only what the plugin needs. + */ +export interface BotOutgoingPayload { + from?: string + answer?: string | string[] + options?: { media?: string | null } +} + +/** Shape of the webhook body that Chatwoot POSTs to the bot */ +export interface ChatwootWebhookBody { + event?: string + message_type?: string + private?: boolean + content?: string + attachments?: Array<{ data_url?: string; [key: string]: unknown }> + changed_attributes?: Array> + meta?: { + sender?: { phone_number?: string; name?: string } + assignee?: { id?: number } | null + } + conversation?: { + id?: number + inbox_id?: number + channel?: string + status?: string + meta?: { + sender?: { phone_number?: string; name?: string } + assignee?: { id?: number } | null + } + contact_inbox?: { inbox_id?: number } + messages?: Array<{ inbox_id?: number }> } + inbox?: { id?: number } + sender?: { phone_number?: string; type?: string } + account?: { id?: number } } diff --git a/packages/plugins/chatwoot/tsconfig.json b/packages/plugins/chatwoot/tsconfig.json index bed0d7489..a05589529 100644 --- a/packages/plugins/chatwoot/tsconfig.json +++ b/packages/plugins/chatwoot/tsconfig.json @@ -7,6 +7,7 @@ "rootDir": "./src", "declaration": true, "declarationMap": true, + "module": "ES2020", "moduleResolution": "node", "importHelpers": true, "target": "es2021", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a88f78ac8..eade710c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -615,6 +615,34 @@ importers: specifier: ^0.5.6 version: 0.5.6 + packages/plugins/chatwoot: + dependencies: + '@builderbot/bot': + specifier: ^1.3.15-alpha.21 + version: 1.3.15 + devDependencies: + '@rollup/plugin-commonjs': + specifier: ^29.0.0 + version: 29.0.0(rollup@4.53.3) + '@rollup/plugin-node-resolve': + specifier: ^16.0.3 + version: 16.0.3(rollup@4.53.3) + '@types/node': + specifier: ^24.10.2 + version: 24.10.2 + rimraf: + specifier: ^6.1.2 + version: 6.1.2 + rollup-plugin-typescript2: + specifier: ^0.36.0 + version: 0.36.0(rollup@4.53.3)(typescript@5.9.3) + tslib: + specifier: ^2.8.1 + version: 2.8.1 + tsm: + specifier: ^2.3.0 + version: 2.3.0 + packages/provider-baileys: dependencies: '@adiwajshing/keyed-db': From a62de39d908135c8869bfd430bef1f0b339aa7b8 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 2 Apr 2026 12:24:13 +0200 Subject: [PATCH 03/31] feat(plugin-chatwoot): improve media handling and auto-register webhook routes --- .gitignore | 1 + packages/plugins/chatwoot/README.md | 80 +++- .../chatwoot/__tests__/chatwootPlugin.test.ts | 425 +++++++++++++++++- .../plugins/chatwoot/src/chatwootPlugin.ts | 202 ++++++++- packages/plugins/chatwoot/src/types.ts | 14 + 5 files changed, 687 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 7b9a9f574..e78500aee 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ log/* *.tgz lib tmp/ +!tmp/chatwoot-media/.gitkeep .yarn/* !.yarn/releases !.yarn/plugins/@yarnpkg/plugin-postinstall.cjs diff --git a/packages/plugins/chatwoot/README.md b/packages/plugins/chatwoot/README.md index d7c92f411..3081cafa4 100644 --- a/packages/plugins/chatwoot/README.md +++ b/packages/plugins/chatwoot/README.md @@ -18,11 +18,13 @@ | **Inbox auto-creation** | Creates an API-channel inbox on first run and reuses it on subsequent starts | | **Contact sync** | Finds or creates the Chatwoot contact for every phone number | | **Conversation sync** | Finds or creates the open conversation and caches it in memory | -| **Media attachments** | Sends images/files received from WhatsApp as attachments in Chatwoot | +| **Media attachments** | Images, audio, video and documents are sent as attachments in both directions | +| **Media-only messages** | WhatsApp media-only messages (no caption) are synced with a readable label (`[image]`, `[audio]`, `[file]`, …) | +| **Multiple attachments** | When an agent sends several files from Chatwoot, each one is forwarded to WhatsApp | | **Bidirectional messages** | Bot outgoing messages → Chatwoot (outgoing) · User messages → Chatwoot (incoming) | -| **Agent replies** | Chatwoot agent messages → WhatsApp via `handleWebhook` | +| **Agent replies** | Chatwoot agent messages → WhatsApp via webhook | | **Blacklist integration** | When an agent takes a conversation, the user is added to the bot blacklist so the bot stops responding | -| **Webhook auto-registration** | Optionally registers the webhook URL in Chatwoot on startup | +| **Webhook auto-registration** | Registers the webhook URL in Chatwoot and the HTTP route on the provider server automatically | | **Group message filter** | `@g.us` group messages are silently ignored | | **Startup validation** | Credentials are verified before anything runs; the plugin self-disables on failure | | **Serialized API calls** | An internal queue ensures no race conditions against the Chatwoot API | @@ -48,6 +50,8 @@ const chatwoot = createChatwootPlugin({ token: 'YOUR_CHATWOOT_USER_TOKEN', url: 'https://app.chatwoot.com', accountId: 1, + // Optional but recommended: enables agent → WhatsApp replies + webhookUrl: 'https://your-bot.example.com/v1/chatwoot', }) const bot = await createBot({ @@ -56,11 +60,11 @@ const bot = await createBot({ database: new MemoryDB(), }) -// One call wires everything up +// One call wires everything up — including the webhook HTTP route await chatwoot.attach(bot) ``` -That's it. Every message exchanged through your bot is now mirrored in Chatwoot. +That's it. Every message exchanged through your bot is now mirrored in Chatwoot, and agent replies are forwarded back to WhatsApp automatically. --- @@ -107,12 +111,10 @@ https://app.chatwoot.com/app/accounts/42/conversations ## Receiving agent replies (webhook) -When a Chatwoot agent sends a message, Chatwoot fires a webhook. You need to expose an HTTP endpoint and call `handleWebhook` inside it. - -### With BuilderBot's built-in HTTP server +When a Chatwoot agent sends a message, Chatwoot fires a webhook to your bot. As long as you set `webhookUrl` in the config, `attach()` handles everything automatically — it registers the webhook in Chatwoot **and** wires the HTTP route on the provider's server. ```ts -import { createBot, createFlow, addKeyword, handleCtx } from '@builderbot/bot' +import { createBot, createFlow, addKeyword } from '@builderbot/bot' import { BaileysProvider } from '@builderbot/provider-baileys' import { createChatwootPlugin } from '@builderbot/plugin-chatwoot' @@ -123,29 +125,30 @@ const chatwoot = createChatwootPlugin({ webhookUrl: 'https://your-bot.example.com/v1/chatwoot', }) -const provider = createProvider(BaileysProvider, { name: 'bot' }) - const bot = await createBot({ flow: createFlow([...]), - provider, + provider: createProvider(BaileysProvider, { name: 'bot' }), database: new MemoryDB(), }) +// Registers the Chatwoot account webhook AND the /v1/chatwoot HTTP route automatically await chatwoot.attach(bot) - -// Expose the webhook endpoint -provider.server.post( - '/v1/chatwoot', - handleCtx(async (bot, req, res) => { - await chatwoot.handleWebhook(bot, req.body) - res.end(JSON.stringify({ status: 'ok' })) - }) -) ``` > **Note:** The URL you pass as `webhookUrl` must be reachable by your Chatwoot server. > For local development use [ngrok](https://ngrok.com/) or a similar tunnel. +### Advanced: manual route registration + +If you use a custom HTTP server outside of BuilderBot's provider, you can still call `handleWebhook` directly: + +```ts +myServer.post('/v1/chatwoot', async (req, res) => { + await chatwoot.handleWebhook(bot, req.body) + res.end(JSON.stringify({ status: 'ok' })) +}) +``` + ### What `handleWebhook` handles | Chatwoot event | Plugin action | @@ -242,7 +245,30 @@ const chatwoot = createChatwootPlugin({ ## Supported media types -The plugin automatically detects the MIME type from the file extension when uploading attachments. +The plugin handles media in both directions. + +### WhatsApp → Chatwoot + +When an incoming WhatsApp message carries media, the file is attached to the Chatwoot message. The plugin resolves the media source through two strategies, tried in order: + +1. **`options.media` URL** — used when the provider already exposes a public media URL in `payload.options.media`. +2. **`provider.saveFile` fallback** — used when the provider carries the raw message context (e.g. Baileys) but does not populate `options.media`. The plugin calls `bot.provider.saveFile(payload)` to download the file to a temporary path, uploads it to Chatwoot, then cleans up the temp file automatically. If the download fails the message is still forwarded with the readable label as caption. + +If the message body is a provider event string, it is also converted to a human-readable caption that appears alongside the attachment: + +| WhatsApp event | Label shown in Chatwoot | +|---|---| +| `_event_media_` | `[image]` | +| `_event_voice_note_` | `[audio]` | +| `_event_document_` | `[file]` | +| `_event_video_` | `[video]` | +| `_event_location_` | `[location]` | +| `_event_sticker_` | `[sticker]` | +| `_event_order_` | `[order]` | + +### Chatwoot → WhatsApp + +When an agent uploads files in Chatwoot, each attachment is forwarded as a separate WhatsApp message. The plugin detects the MIME type from the file extension when uploading local files: | Extension | MIME type | |---|---| @@ -252,9 +278,14 @@ The plugin automatically detects the MIME type from the file extension when uplo | `.webp` | `image/webp` | | `.mp4` | `video/mp4` | | `.pdf` | `application/pdf` | -| `.mp3` / `.ogg` | `audio/mpeg` / `audio/ogg` | +| `.mp3` | `audio/mpeg` | +| `.ogg` / `.opus` | `audio/ogg` | +| `.wav` | `audio/wav` | +| `.svg` | `image/svg+xml` | | other | `application/octet-stream` | +For remote URLs, the MIME type is taken from the HTTP `Content-Type` response header automatically. + --- ## API reference @@ -269,7 +300,8 @@ Wires the plugin into the bot. Must be called once after `createBot`. - Validates Chatwoot credentials (`checkAccount`) - Finds or creates the API-channel inbox -- Registers the webhook in Chatwoot if `webhookUrl` is configured +- Registers the account webhook in Chatwoot if `webhookUrl` is configured +- Auto-registers the HTTP route on `provider.server` if `webhookUrl` is configured - Listens to `send_message` and `provider.message` events ### `chatwoot.handleWebhook(bot, body)` diff --git a/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts b/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts index 3a9654357..37cb4bc09 100644 --- a/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts +++ b/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts @@ -81,9 +81,11 @@ const drainQueue = () => new Promise((r) => setTimeout(r, 60)) // ─── mock bot ───────────────────────────────────────────────────────────────── -const makeMockBot = () => { +const makeMockBot = (saveFileImpl?: (ctx: unknown, opts?: unknown) => Promise) => { const botHandlers: Record unknown>> = {} const provHandlers: Record unknown>> = {} + const serverRoutes: Record Promise> = {} + const serverGetRoutes: Record void> = {} return { on(ev: string, h: (...a: unknown[]) => unknown) { botHandlers[ev] = [...(botHandlers[ev] ?? []), h] @@ -94,6 +96,17 @@ const makeMockBot = () => { provHandlers[ev] = [...(provHandlers[ev] ?? []), h] }, emit: (ev: string, payload: unknown) => Promise.all((provHandlers[ev] ?? []).map((h) => h(payload))), + server: { + routes: serverRoutes, + getRoutes: serverGetRoutes, + post(path: string, handler: (req: any, res: any) => Promise) { + serverRoutes[path] = handler + }, + get(path: string, handler: (req: any, res: any) => void) { + serverGetRoutes[path] = handler + }, + }, + ...(saveFileImpl ? { saveFile: saveFileImpl } : {}), }, blacklist: { items: new Set(), @@ -360,6 +373,211 @@ test('ChatwootApi.sendMessage sends FormData when mediaSource is provided', asyn // ─── SimpleQueue serialization ──────────────────────────────────────────────── +// ─── media / _event_* normalization ────────────────────────────────────────── + +/** Extracts { content, message_type } from either a JSON string body or FormData. */ +const extractMessageBody = (body: unknown): { content: string; message_type: string } | null => { + if (body instanceof FormData) { + return { content: String(body.get('content') ?? ''), message_type: String(body.get('message_type') ?? '') } + } + if (typeof body === 'string') { + try { + return JSON.parse(body) + } catch { + return null + } + } + return null +} + +test('provider message: _event_media_ with options.media sends empty content (attachment speaks for itself)', async () => { + const bodies: ReturnType[] = [] + const { mock: baseMock } = makeSmartFetch() + const captureFetch: MockFn = async (url, opts) => { + if (String(url).includes('/messages') && opts?.method === 'POST') { + bodies.push(extractMessageBody(opts?.body)) + } + return baseMock(url, opts) + } + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(baseMock, () => plugin.attach(bot as any)) + + await withFetch(captureFetch, async () => { + await bot.provider.emit('message', { + from: '5215511223344', + body: '_event_media_', + options: { media: 'https://example.com/photo.jpg' }, + }) + await drainQueue() + }) + + const msg = bodies.find((b) => b?.message_type === 'incoming') + assert.ok(msg, 'a message was sent to Chatwoot') + assert.is(msg?.content, '', 'media with no caption → empty content, no redundant [image] label') +}) + +test('provider message: _event_voice_note_ with options.media sends empty content', async () => { + const bodies: ReturnType[] = [] + const { mock: baseMock } = makeSmartFetch() + const captureFetch: MockFn = async (url, opts) => { + if (String(url).includes('/messages') && opts?.method === 'POST') { + bodies.push(extractMessageBody(opts?.body)) + } + return baseMock(url, opts) + } + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(baseMock, () => plugin.attach(bot as any)) + + await withFetch(captureFetch, async () => { + await bot.provider.emit('message', { + from: '5215511223344', + body: '_event_voice_note_', + options: { media: 'https://example.com/audio.ogg' }, + }) + await drainQueue() + }) + + const msg = bodies.find((b) => b?.message_type === 'incoming') + assert.ok(msg, 'a message was sent to Chatwoot') + assert.is(msg?.content, '', 'audio with media URL → empty content, no redundant [audio] label') +}) + +test('provider message: media-only message with empty body is still synced to Chatwoot', async () => { + const { mock, calls } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + const countAfterAttach = calls.length + + await withFetch(mock, async () => { + await bot.provider.emit('message', { + from: '5215511223344', + body: '', + options: { media: 'https://example.com/photo.jpg' }, + }) + await drainQueue() + }) + + assert.ok(calls.length > countAfterAttach, 'fetch calls were made for media-only incoming message') +}) + +test('send_message: media-only bot message (empty content) is synced to Chatwoot', async () => { + const { mock, calls } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + const countAfterAttach = calls.length + + await withFetch(mock, async () => { + await bot.emit('send_message', { + from: '5215511223344', + answer: '', + options: { media: 'https://example.com/image.png' }, + }) + await drainQueue() + }) + + assert.ok(calls.length > countAfterAttach, 'fetch calls were made for media-only outgoing message') +}) + +test('handleWebhook forwards all attachments when agent sends multiple files', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + + await plugin.handleWebhook(bot as any, { + event: 'message_created', + message_type: 'outgoing', + private: false, + content: 'See attached files', + attachments: [ + { data_url: 'https://example.com/file1.pdf' }, + { data_url: 'https://example.com/file2.png' }, + { data_url: 'https://example.com/file3.mp4' }, + ], + conversation: { + inbox_id: MOCK_INBOX.id, + channel: 'Channel::Api', + meta: { sender: { phone_number: '+5215511223344' } }, + }, + }) + + assert.is(bot.sent.length, 3, 'one sendMessage call per attachment') + assert.is((bot.sent[0].options as any)?.media, 'https://example.com/file1.pdf', 'first file with content') + assert.is(bot.sent[0].content, 'See attached files', 'text goes with first file') + assert.is((bot.sent[1].options as any)?.media, 'https://example.com/file2.png', 'second file separate') + assert.is((bot.sent[2].options as any)?.media, 'https://example.com/file3.mp4', 'third file separate') +}) + +// ─── auto HTTP route registration ──────────────────────────────────────────── + +test('attach() auto-registers POST route on provider.server when webhookUrl is set', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + assert.ok( + bot.provider.server.routes['/v1/chatwoot'] !== undefined, + 'POST route /v1/chatwoot should be registered on provider.server' + ) +}) + +test('attach() does not register any route when webhookUrl is not set', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + assert.is(Object.keys(bot.provider.server.routes).length, 0, 'no routes should be registered without webhookUrl') +}) + +test('auto-registered route forwards agent message to WhatsApp', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + + const handler = bot.provider.server.routes['/v1/chatwoot'] + assert.ok(handler, 'route handler must exist') + + const mockRes = { + headers: {} as Record, + body: '', + writeHead(_code: number, headers: Record) { + this.headers = { ...this.headers, ...headers } + }, + end(data: string) { + this.body = data + }, + } + + await handler( + { + body: { + event: 'message_created', + message_type: 'outgoing', + private: false, + content: 'Hello from agent via route', + conversation: { + inbox_id: MOCK_INBOX.id, + channel: 'Channel::Api', + meta: { sender: { phone_number: '+5215511223344' } }, + }, + }, + }, + mockRes + ) + + assert.is(bot.sent.length, 1, 'sendMessage should be called once') + assert.is(bot.sent[0].number, '5215511223344', 'message sent to correct phone') + assert.is(bot.sent[0].content, 'Hello from agent via route', 'message content matches') + assert.is(mockRes.body, JSON.stringify({ status: 'ok' }), 'response body is { status: ok }') +}) + +// ─── SimpleQueue serialization ──────────────────────────────────────────────── + test('enqueued messages are processed without dropping', async () => { const { mock, calls } = makeSmartFetch() const plugin = createChatwootPlugin(MOCK_CONFIG) @@ -375,4 +593,209 @@ test('enqueued messages are processed without dropping', async () => { assert.ok(calls.length > countAfterAttach, 'fetch calls were made for enqueued messages') }) +// ─── saveFile fallback + public URL (WA → Chatwoot, no options.media) ──────── + +test('attach() registers GET /media/:filename route when webhookUrl and server.get are present', async () => { + const { mock } = makeSmartFetch() + const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) + const bot = makeMockBot() + await withFetch(mock, () => plugin.attach(bot as any)) + assert.ok( + bot.provider.server.getRoutes['/media/:filename'] !== undefined, + 'GET /media/:filename route should be registered' + ) +}) + +test('provider message: _event_media_ without options.media uses saveFile + public URL', async () => { + const SAVED_PATH = '/tmp/chatwoot-media/file-12345.jpg' + const bodies: ReturnType[] = [] + const downloadedUrls: string[] = [] + + const saveFileMock = async (_ctx: unknown, _opts?: unknown): Promise => SAVED_PATH + + const { mock: baseMock } = makeSmartFetch() + const captureFetch: MockFn = async (url, opts) => { + if (String(url).includes('/messages') && opts?.method === 'POST') { + bodies.push(extractMessageBody(opts?.body)) + } + // Capture media download attempts + if (String(url).includes('/media/')) { + downloadedUrls.push(String(url)) + } + return baseMock(url, opts) + } + + const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) + const bot = makeMockBot(saveFileMock) + await withFetch(baseMock, () => plugin.attach(bot as any)) + + await withFetch(captureFetch, async () => { + await bot.provider.emit('message', { + from: '5215511223344', + body: '_event_media_', + message: { imageMessage: { mimetype: 'image/jpeg' } }, + }) + await drainQueue() + }) + + const incoming = bodies.find((b) => b?.message_type === 'incoming') + assert.ok(incoming, 'a message was sent to Chatwoot') + assert.is(incoming?.content, '', 'image with no caption + media URL → empty content') + // The media was fetched from the public URL + assert.ok( + downloadedUrls.some((u) => u.includes('https://bot.example.com/media/file-12345.jpg')), + 'plugin fetched media from the public /media URL' + ) +}) + +test('provider message: saveFile failure falls back to label-only message', async () => { + const bodies: ReturnType[] = [] + + const failingSaveFile = async (_ctx: unknown, _opts?: unknown): Promise => { + throw new Error('download failed') + } + + const { mock: baseMock } = makeSmartFetch() + const captureFetch: MockFn = async (url, opts) => { + if (String(url).includes('/messages') && opts?.method === 'POST') { + bodies.push(extractMessageBody(opts?.body)) + } + return baseMock(url, opts) + } + + const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) + const bot = makeMockBot(failingSaveFile) + await withFetch(baseMock, () => plugin.attach(bot as any)) + + await withFetch(captureFetch, async () => { + await bot.provider.emit('message', { + from: '5215511223344', + body: '_event_media_', + message: { imageMessage: { mimetype: 'image/jpeg' } }, + }) + await drainQueue() + }) + + const incoming = bodies.find((b) => b?.message_type === 'incoming') + assert.ok(incoming, 'message still sent to Chatwoot even when saveFile throws') + assert.is(incoming?.content, '[image]', 'body still normalized to [image]') +}) + +test('provider message: image with caption sends real caption text instead of [image]', async () => { + const bodies: ReturnType[] = [] + + const { mock: baseMock } = makeSmartFetch() + const captureFetch: MockFn = async (url, opts) => { + if (String(url).includes('/messages') && opts?.method === 'POST') { + bodies.push(extractMessageBody(opts?.body)) + } + return baseMock(url, opts) + } + + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(baseMock, () => plugin.attach(bot as any)) + + await withFetch(captureFetch, async () => { + await bot.provider.emit('message', { + from: '5215511223344', + body: '_event_media_', + // Baileys raw WAMessage with a caption + message: { imageMessage: { mimetype: 'image/jpeg', caption: 'Check this out!' } }, + }) + await drainQueue() + }) + + const incoming = bodies.find((b) => b?.message_type === 'incoming') + assert.ok(incoming, 'a message was sent to Chatwoot') + assert.is(incoming?.content, 'Check this out!', 'real caption replaces [image] label') +}) + +test('provider message: image without caption sends empty content (no redundant [image] label)', async () => { + const bodies: ReturnType[] = [] + + const SAVED_PATH = '/tmp/chatwoot-media/file-99.jpg' + const saveFileMock = async (_ctx: unknown, _opts?: unknown): Promise => SAVED_PATH + + const { mock: baseMock } = makeSmartFetch() + const captureFetch: MockFn = async (url, opts) => { + if (String(url).includes('/messages') && opts?.method === 'POST') { + bodies.push(extractMessageBody(opts?.body)) + } + return baseMock(url, opts) + } + + const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) + const bot = makeMockBot(saveFileMock) + await withFetch(baseMock, () => plugin.attach(bot as any)) + + await withFetch(captureFetch, async () => { + await bot.provider.emit('message', { + from: '5215511223344', + body: '_event_media_', + message: { imageMessage: { mimetype: 'image/jpeg', caption: '' } }, + }) + await drainQueue() + }) + + const incoming = bodies.find((b) => b?.message_type === 'incoming') + assert.ok(incoming, 'a message was sent to Chatwoot') + assert.is(incoming?.content, '', 'no caption + media URL → empty content, no redundant label') +}) + +test('provider message: video with caption sends real caption text', async () => { + const bodies: ReturnType[] = [] + + const { mock: baseMock } = makeSmartFetch() + const captureFetch: MockFn = async (url, opts) => { + if (String(url).includes('/messages') && opts?.method === 'POST') { + bodies.push(extractMessageBody(opts?.body)) + } + return baseMock(url, opts) + } + + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(baseMock, () => plugin.attach(bot as any)) + + await withFetch(captureFetch, async () => { + await bot.provider.emit('message', { + from: '5215511223344', + body: '_event_media_', + message: { videoMessage: { mimetype: 'video/mp4', caption: 'Watch this video' } }, + }) + await drainQueue() + }) + + const incoming = bodies.find((b) => b?.message_type === 'incoming') + assert.ok(incoming, 'a message was sent to Chatwoot') + assert.is(incoming?.content, 'Watch this video', 'video caption is used as content') +}) + +test('provider message: no saveFile on provider sends [image] label as last-resort fallback', async () => { + const bodies: ReturnType[] = [] + + const { mock: baseMock } = makeSmartFetch() + const captureFetch: MockFn = async (url, opts) => { + if (String(url).includes('/messages') && opts?.method === 'POST') { + bodies.push(extractMessageBody(opts?.body)) + } + return baseMock(url, opts) + } + + // No saveFile, no options.media → no mediaUrl → falls back to normalizeBody label + const plugin = createChatwootPlugin(MOCK_CONFIG) + const bot = makeMockBot() + await withFetch(baseMock, () => plugin.attach(bot as any)) + + await withFetch(captureFetch, async () => { + await bot.provider.emit('message', { from: '5215511223344', body: '_event_media_' }) + await drainQueue() + }) + + const incoming = bodies.find((b) => b?.message_type === 'incoming') + assert.ok(incoming, 'message sent to Chatwoot without saveFile') + assert.is(incoming?.content, '[image]', 'no media URL → [image] label used as fallback') +}) + test.run() diff --git a/packages/plugins/chatwoot/src/chatwootPlugin.ts b/packages/plugins/chatwoot/src/chatwootPlugin.ts index 6f49f6e7a..deaedf8ed 100644 --- a/packages/plugins/chatwoot/src/chatwootPlugin.ts +++ b/packages/plugins/chatwoot/src/chatwootPlugin.ts @@ -1,4 +1,7 @@ import type { CoreClass } from '@builderbot/bot' +import { createReadStream } from 'node:fs' +import { mkdir, unlink } from 'node:fs/promises' +import { join } from 'node:path' import { ChatwootApi } from './chatwootApi' import type { @@ -13,6 +16,74 @@ import type { const DEFAULT_INBOX_NAME = 'BuilderBot Inbox' const WEBHOOK_SUBSCRIPTIONS = ['conversation_updated', 'message_created'] +/** Maps file extensions to MIME types for correct Content-Type headers when serving media. */ +const MIME_MAP: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + mp4: 'video/mp4', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + opus: 'audio/ogg', + wav: 'audio/wav', + pdf: 'application/pdf', + svg: 'image/svg+xml', +} + +function mimeFromFilename(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() ?? '' + return MIME_MAP[ext] ?? 'application/octet-stream' +} + +/** Maps WhatsApp `_event_*` body strings to human-readable labels shown in Chatwoot. */ +const EVENT_LABELS: Record = { + _event_media_: '[image]', + _event_voice_note_: '[audio]', + _event_document_: '[file]', + _event_location_: '[location]', + _event_video_: '[video]', + _event_sticker_: '[sticker]', + _event_order_: '[order]', +} + +/** + * Converts a WhatsApp provider event string (e.g. `_event_media_`) into a readable label. + * Returns the original string unchanged for normal text messages. + */ +function normalizeBody(raw: string): string { + for (const [key, label] of Object.entries(EVENT_LABELS)) { + if (raw.includes(key)) return label + } + return raw +} + +/** Returns true if the body string corresponds to a WhatsApp media event. */ +function isMediaEvent(raw: string): boolean { + return Object.keys(EVENT_LABELS).some((key) => raw.includes(key)) +} + +/** + * Extracts the real caption from the raw provider message context (e.g. Baileys WAMessage). + * Baileys always overwrites `body` with `_event_media_` for media messages, but the original + * caption typed by the user is preserved in `message.imageMessage.caption` (and equivalent + * fields for video, document, sticker, etc.). + * Returns the caption string if present and non-empty, otherwise null. + */ +function extractCaption(payload: BotIncomingMessagePayload): string | null { + const msg = (payload as any).message + if (!msg) return null + const caption = + msg.imageMessage?.caption || + msg.videoMessage?.caption || + msg.documentMessage?.caption || + msg.documentWithCaptionMessage?.message?.documentMessage?.caption || + msg.extendedTextMessage?.text || + null + return typeof caption === 'string' && caption.trim() ? caption.trim() : null +} + /** * Minimal single-concurrency async queue. * Ensures Chatwoot API calls are serialized to avoid race conditions. @@ -36,6 +107,9 @@ class SimpleQueue { } } +const MEDIA_ROUTE = '/media' +const MEDIA_DIR_NAME = join('tmp', 'chatwoot-media') + class ChatwootPlugin { private api: ChatwootApi private config: ChatwootPluginConfig @@ -43,6 +117,8 @@ class ChatwootPlugin { private conversationCache = new Map() private contactCache = new Map() private messageQueue = new SimpleQueue() + private mediaDir: string | null = null + private mediaBaseUrl: string | null = null /** False if Chatwoot credentials are invalid or unreachable. All operations are skipped when false. */ public status = true @@ -72,6 +148,8 @@ class ChatwootPlugin { this.inbox = await this.api.findOrCreateInbox(inboxName) console.log(`[Chatwoot] Inbox "${this.inbox.name}" ready (id: ${this.inbox.id})`) + const server = (bot as any).provider?.server + if (this.config.webhookUrl) { const existing = await this.api.findWebhook(this.config.webhookUrl) if (!existing) { @@ -80,6 +158,59 @@ class ChatwootPlugin { } else { console.log(`[Chatwoot] Webhook already exists (id: ${existing.id})`) } + + const urlPath = new URL(this.config.webhookUrl).pathname + if (server?.post) { + server.post(urlPath, (req: any, res: any) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ status: 'ok' })) + + const botRef = bot as CoreClass & ChatwootBotRef + if (typeof (botRef as any).sendMessage !== 'function') { + ;(botRef as any).sendMessage = (phone: string, msg: string, opts?: any) => + (bot as any).provider?.sendMessage(phone, msg, opts) + } + if (!(botRef as any).blacklist) { + ;(botRef as any).blacklist = (bot as any).dynamicBlacklist + } + + this.handleWebhook(botRef, req.body ?? {}).catch((err) => + console.error('[Chatwoot] Webhook processing error:', err) + ) + }) + console.log(`[Chatwoot] Webhook route auto-registered at ${urlPath}`) + } else { + console.warn('[Chatwoot] provider.server not available — register the webhook route manually') + } + + // Register a public GET route to serve media files downloaded from WhatsApp. + // Files are stored in tmp/chatwoot-media/ and exposed at /media/:filename + // so Chatwoot can download them by URL rather than receiving a binary upload. + if (server?.get) { + this.mediaDir = join(process.cwd(), MEDIA_DIR_NAME) + this.mediaBaseUrl = new URL(this.config.webhookUrl).origin + await mkdir(this.mediaDir, { recursive: true }) + + server.get(`${MEDIA_ROUTE}/:filename`, (req: any, res: any) => { + const raw = req.params?.filename ?? '' + const safeName = raw.replace(/[^a-zA-Z0-9._-]/g, '') + if (!safeName) { + res.writeHead(400) + res.end('Bad request') + return + } + const filePath = join(this.mediaDir!, safeName) + const contentType = mimeFromFilename(safeName) + const stream = createReadStream(filePath) + stream.on('error', () => { + res.writeHead(404) + res.end('Not found') + }) + res.writeHead(200, { 'Content-Type': contentType }) + stream.pipe(res) + }) + console.log(`[Chatwoot] Media route registered at ${MEDIA_ROUTE}/:filename`) + } } bot.on('send_message', async (payload) => { @@ -88,13 +219,16 @@ class ChatwootPlugin { this.messageQueue.enqueue(async () => { const { from, answer } = payload - if (!from || !answer) return + if (!from) return - const content = Array.isArray(answer) ? answer.join('\n') : String(answer) - if (!content || content.startsWith('__')) return + const rawContent = Array.isArray(answer) ? answer.join('\n') : String(answer ?? '') + if (rawContent.startsWith('__')) return - const conversationId = await this.resolveConversation(from) const mediaUrl = (payload as unknown as BotOutgoingPayload).options?.media ?? null + const content = normalizeBody(rawContent) + if (!content && !mediaUrl) return + + const conversationId = await this.resolveConversation(from) await this.api.sendMessage(conversationId, content, 'outgoing', mediaUrl) }) }) @@ -105,11 +239,52 @@ class ChatwootPlugin { this.messageQueue.enqueue(async () => { const { from, body, name } = payload - if (!from || !body) return + if (!from) return + + let mediaUrl: string | null = payload.options?.media ?? null + let tempFilePath: string | null = null + + // Fallback for providers (e.g. Baileys) that carry the raw WAMessage context + // but do not populate options.media. Download the file via provider.saveFile, + // save it to the public media directory and expose it as an HTTP asset so + // Chatwoot can fetch and store it by URL. + if (!mediaUrl && body && isMediaEvent(body)) { + const saveFile = (bot as any).provider?.saveFile + if (typeof saveFile === 'function') { + try { + if (this.mediaDir && this.mediaBaseUrl) { + tempFilePath = await saveFile.call((bot as any).provider, payload, { + path: this.mediaDir, + }) + const filename = tempFilePath!.split('/').pop() + mediaUrl = `${this.mediaBaseUrl}${MEDIA_ROUTE}/${filename}` + } else { + tempFilePath = await saveFile.call((bot as any).provider, payload) + mediaUrl = tempFilePath + } + } catch (err) { + console.error('[Chatwoot] Could not download media via saveFile:', err) + } + } + } + if (!body && !mediaUrl) return + + // Prefer the real caption from the raw message context over the normalised label. + // When a user sends an image with text Baileys sets body to _event_media_ and + // stores the actual caption inside message.imageMessage.caption (and similar). + // If there is a media attachment but no caption, send empty content so Chatwoot + // shows just the image/file preview without a redundant [image] label. + // Only fall back to the event label when there is no media file at all + // (e.g. location, sticker, order — events that have no downloadable attachment). + const caption = extractCaption(payload) + const content = caption ?? (mediaUrl ? '' : normalizeBody(body ?? '')) const conversationId = await this.resolveConversation(from, name) - const mediaUrl = payload.options?.media ?? null - await this.api.sendMessage(conversationId, body, 'incoming', mediaUrl) + await this.api.sendMessage(conversationId, content, 'incoming', mediaUrl) + + if (tempFilePath) { + unlink(tempFilePath).catch(() => undefined) + } }) }) @@ -163,10 +338,17 @@ class ChatwootPlugin { ) { const phone = body?.conversation?.meta?.sender?.phone_number?.replace('+', '') const content = body?.content ?? '' - const file = body?.attachments?.length ? body.attachments[0] : null + const attachments = body?.attachments ?? [] - if (phone) { - await bot.sendMessage(phone, content, { media: file?.data_url ?? null }) + if (phone && (content || attachments.length)) { + const firstMedia = attachments[0]?.data_url ?? null + await bot.sendMessage(phone, content, { media: firstMedia }) + + for (const attachment of attachments.slice(1)) { + if (attachment.data_url) { + await bot.sendMessage(phone, '', { media: attachment.data_url }) + } + } } } } diff --git a/packages/plugins/chatwoot/src/types.ts b/packages/plugins/chatwoot/src/types.ts index e766f9528..51f554c31 100644 --- a/packages/plugins/chatwoot/src/types.ts +++ b/packages/plugins/chatwoot/src/types.ts @@ -60,6 +60,20 @@ export interface BotIncomingMessagePayload { body: string name?: string options?: { media?: string } + /** + * Raw provider-specific message context. Baileys spreads the full WAMessage + * here, which is needed by `provider.saveFile` to download media when + * `options.media` is not populated. + */ + [key: string]: unknown +} + +/** + * Duck-typed interface for providers that can download incoming media to disk. + * Baileys exposes this as `provider.saveFile(ctx)` returning a local file path. + */ +export interface BotProviderWithSaveFile { + saveFile(ctx: BotIncomingMessagePayload, options?: { path?: string }): Promise } /** From 0e51f341c67b873fe1eb0cffa6ff0355ef7ef048 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 2 Apr 2026 19:31:00 +0200 Subject: [PATCH 04/31] v1.4.2-alpha.1 --- lerna.json | 4 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 4 +- packages/contexts-dialogflow/package.json | 4 +- packages/create-builderbot/package.json | 4 +- packages/database-json/package.json | 4 +- packages/database-mongo/package.json | 4 +- packages/database-mysql/package.json | 4 +- packages/database-postgres/package.json | 4 +- .../eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 12 +- packages/provider-baileys/package.json | 158 ++++++++--------- packages/provider-email/package.json | 4 +- packages/provider-evolution-api/package.json | 4 +- .../provider-facebook-messenger/package.json | 110 ++++++------ packages/provider-gohighlevel/package.json | 4 +- packages/provider-gupshup/package.json | 4 +- packages/provider-instagram/package.json | 114 ++++++------ packages/provider-meta/package.json | 4 +- packages/provider-sherpa/package.json | 164 +++++++++--------- packages/provider-telegram/package.json | 102 +++++------ packages/provider-twilio/package.json | 4 +- packages/provider-venom/package.json | 4 +- packages/provider-web-whatsapp/package.json | 4 +- packages/provider-wppconnect/package.json | 4 +- pnpm-lock.yaml | 119 +++++-------- 28 files changed, 415 insertions(+), 440 deletions(-) diff --git a/lerna.json b/lerna.json index 3d1c086ae..a21909fc1 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.1", + "version": "1.4.2-alpha.1", "packages": [ "packages/bot", "packages/cli", @@ -35,7 +35,7 @@ "**/*" ], "noVerifyAccess": true, - "syncWorkspaceLockfile": false + "workspaceProtocol": "noop" } }, "npmClient": "pnpm", diff --git a/packages/bot/package.json b/packages/bot/package.json index 9071faedb..88b6b04d8 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index 375c6d322..13f9e8f10 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index f0f18d759..caaa90b68 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@google-cloud/dialogflow-cx": "^5.5.0" }, "devDependencies": { diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index f2f64e53a..159839006 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@google-cloud/dialogflow": "^7.4.0" }, "devDependencies": { diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index 897c452e2..5271947db 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "", "license": "ISC", "main": "dist/index.cjs", @@ -16,7 +16,7 @@ ], "bin": "./bin/create.cjs", "dependencies": { - "@builderbot/cli": "^1.4.1" + "@builderbot/cli": "workspace:^" }, "repository": { "type": "git", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index 7e43edb52..9d9a4da1c 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "^1.4.1" + "@builderbot/bot": "workspace:^" }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index 3f739acb9..7a81a3aa1 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "mongodb": "^7.0.0" }, "devDependencies": { diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index e9ec4d9c3..2ce0d147b 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "mysql2": "^3.15.3" }, "devDependencies": { diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index f72c326fb..6c9036540 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "pg": "^8.11.5" }, "devDependencies": { diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index 97909593c..d64706030 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index d0a6fa6f9..c8f479805 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index dd92b4eb0..27057fa85 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,8 +1,14 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.3.15-alpha.21", + "version": "1.4.2-alpha.1", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", - "keywords": ["chatwoot", "builderbot", "plugin", "crm", "customer-support"], + "keywords": [ + "chatwoot", + "builderbot", + "plugin", + "crm", + "customer-support" + ], "author": "Leifer Mendez ", "license": "ISC", "main": "dist/index.cjs", @@ -36,7 +42,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "^1.3.15-alpha.21" + "@builderbot/bot": "workspace:^" }, "devDependencies": { "@rollup/plugin-commonjs": "^29.0.0", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index a27697330..be08c4f2d 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,81 +1,81 @@ { - "name": "@builderbot/provider-baileys", - "version": "1.4.1", - "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --forceExit", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "^1.4.1", - "@hapi/boom": "^10.0.1", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/cors": "^2.8.17", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "body-parser": "^2.2.1", - "cors": "^2.8.5", - "jest": "^30.2.0", - "mime-types": "^3.0.2", - "pino": "^10.1.0", - "polka": "^0.5.2", - "qr-image": "^3.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "ts-jest": "^29.4.6", - "ts-node": "^10.9.2", - "wa-sticker-formatter": "^4.4.4", - "wtfnode": "^0.10.1" - }, - "dependencies": { - "@adiwajshing/keyed-db": "^0.2.4", - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "@types/polka": "^0.5.7", - "baileys": "7.0.0-rc.9", - "cheerio": "^1.1.2", - "fluent-ffmpeg": "^2.1.2", - "fs-extra": "^11.3.2", - "jimp": "^1.6.0", - "node-cache": "^5.1.2", - "sharp": "0.33.3" - }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "name": "@builderbot/provider-baileys", + "version": "1.4.2-alpha.1", + "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", + "keywords": [], + "author": "Leifer Mendez ", + "license": "ISC", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --forceExit", + "test:coverage": "jest --coverage", + "test:watch": "jest --watchAll --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@builderbot/bot": "workspace:^", + "@hapi/boom": "^10.0.1", + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/cors": "^2.8.17", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/qr-image": "^3.2.9", + "@types/sinon": "^17.0.3", + "body-parser": "^2.2.1", + "cors": "^2.8.5", + "jest": "^30.2.0", + "mime-types": "^3.0.2", + "pino": "^10.1.0", + "polka": "^0.5.2", + "qr-image": "^3.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "sinon": "^17.0.1", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "wa-sticker-formatter": "^4.4.4", + "wtfnode": "^0.10.1" + }, + "dependencies": { + "@adiwajshing/keyed-db": "^0.2.4", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@types/polka": "^0.5.7", + "baileys": "7.0.0-rc.9", + "cheerio": "^1.1.2", + "fluent-ffmpeg": "^2.1.2", + "fs-extra": "^11.3.2", + "jimp": "^1.6.0", + "node-cache": "^5.1.2", + "sharp": "0.33.3" + }, + "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" } diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index 6609adfb1..6c22fbef4 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index fa6e36f7d..a01588a84 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", @@ -40,7 +40,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index fb56d05e9..0a8e2d87f 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,57 +1,57 @@ { - "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.1", - "description": "Provider for Facebook Messenger", - "keywords": [ - "facebook", - "messenger", - "chatbot", - "builderbot" - ], - "author": "", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "^1.4.1", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "jest": "^30.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.4.6", - "typescript": "^5.9.3" - }, - "dependencies": { - "axios": "^1.13.2", - "mime-types": "^3.0.2", - "polka": "^0.5.2" - }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "name": "@builderbot/provider-facebook-messenger", + "version": "1.4.2-alpha.1", + "description": "Provider for Facebook Messenger", + "keywords": [ + "facebook", + "messenger", + "chatbot", + "builderbot" + ], + "author": "", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@builderbot/bot": "workspace:^", + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/polka": "^0.5.7", + "jest": "^30.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "ts-jest": "^29.4.6", + "typescript": "^5.9.3" + }, + "dependencies": { + "axios": "^1.13.2", + "mime-types": "^3.0.2", + "polka": "^0.5.2" + }, + "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" } diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index 9acc998ee..6764983d3 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", @@ -38,7 +38,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index f0279e78c..989082ac4 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", @@ -25,7 +25,7 @@ "@builderbot/bot": "workspace:*" }, "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.0.0", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-json": "^6.0.0", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index 7602131e4..126a1faa2 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,59 +1,59 @@ { - "name": "@builderbot/provider-instagram", - "version": "1.4.1", - "description": "Provider for Instagram Messaging", - "keywords": [ - "instagram", - "messenger", - "chatbot", - "builderbot" - ], - "author": "", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "^1.4.1", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/form-data": "^2.5.2", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "jest": "^30.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.4.6", - "typescript": "^5.9.3" - }, - "dependencies": { - "axios": "^1.13.2", - "form-data": "^4.0.5", - "mime-types": "^3.0.2", - "polka": "^0.5.2" - }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "name": "@builderbot/provider-instagram", + "version": "1.4.2-alpha.1", + "description": "Provider for Instagram Messaging", + "keywords": [ + "instagram", + "messenger", + "chatbot", + "builderbot" + ], + "author": "", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@builderbot/bot": "workspace:^", + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/form-data": "^2.5.2", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/polka": "^0.5.7", + "jest": "^30.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "ts-jest": "^29.4.6", + "typescript": "^5.9.3" + }, + "dependencies": { + "axios": "^1.13.2", + "form-data": "^4.0.5", + "mime-types": "^3.0.2", + "polka": "^0.5.2" + }, + "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" } diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index 92eeaff38..bb1e1d979 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", @@ -38,7 +38,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index 2ecacdd1a..35dbf9d5c 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,84 +1,84 @@ { - "name": "@builderbot/provider-sherpa", - "version": "1.4.1", - "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --forceExit", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "^1.4.1", - "@hapi/boom": "^10.0.1", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/cors": "^2.8.17", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "body-parser": "^2.2.1", - "cors": "^2.8.5", - "jest": "^30.2.0", - "mime-types": "^3.0.2", - "pino": "^10.1.0", - "polka": "^0.5.2", - "qr-image": "^3.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "ts-jest": "^29.4.6", - "ts-node": "^10.9.2", - "wa-sticker-formatter": "^4.4.4", - "wtfnode": "^0.10.1" - }, - "dependencies": { - "@adiwajshing/keyed-db": "^0.2.4", - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "@types/polka": "^0.5.7", - "fluent-ffmpeg": "^2.1.2", - "fs-extra": "^11.3.2", - "jimp": "^1.6.0", - "node-cache": "^5.1.2", - "qrcode-terminal": "^0.12.0", - "rollup": "^4.53.3", - "sharp": "0.33.3", - "tslib": "^2.8.1", - "typescript": "^5.9.3", - "whaileys": "6.3.8" - }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "name": "@builderbot/provider-sherpa", + "version": "1.4.2-alpha.1", + "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", + "keywords": [], + "author": "Leifer Mendez ", + "license": "ISC", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --forceExit", + "test:coverage": "jest --coverage", + "test:watch": "jest --watchAll --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@builderbot/bot": "workspace:^", + "@hapi/boom": "^10.0.1", + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/cors": "^2.8.17", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/qr-image": "^3.2.9", + "@types/sinon": "^17.0.3", + "body-parser": "^2.2.1", + "cors": "^2.8.5", + "jest": "^30.2.0", + "mime-types": "^3.0.2", + "pino": "^10.1.0", + "polka": "^0.5.2", + "qr-image": "^3.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "sinon": "^17.0.1", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "wa-sticker-formatter": "^4.4.4", + "wtfnode": "^0.10.1" + }, + "dependencies": { + "@adiwajshing/keyed-db": "^0.2.4", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@types/polka": "^0.5.7", + "fluent-ffmpeg": "^2.1.2", + "fs-extra": "^11.3.2", + "jimp": "^1.6.0", + "node-cache": "^5.1.2", + "qrcode-terminal": "^0.12.0", + "rollup": "^4.53.3", + "sharp": "0.33.3", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "whaileys": "6.3.8" + }, + "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" } diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index 5e288770b..683a80662 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,53 +1,53 @@ { - "name": "@builderbot/provider-telegram", - "version": "1.4.1", - "description": "Provider for Telegram", - "keywords": [], - "author": "", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --forceExit", - "test:coverage": "jest --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "^1.4.1", - "@hapi/boom": "^10.0.1", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/cors": "^2.8.17", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "jest": "^30.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.4.6" - }, - "dependencies": { - "telegram": "^2.23.10" - }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "name": "@builderbot/provider-telegram", + "version": "1.4.2-alpha.1", + "description": "Provider for Telegram", + "keywords": [], + "author": "", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --forceExit", + "test:coverage": "jest --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@builderbot/bot": "workspace:^", + "@hapi/boom": "^10.0.1", + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/cors": "^2.8.17", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/qr-image": "^3.2.9", + "@types/sinon": "^17.0.3", + "jest": "^30.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "ts-jest": "^29.4.6" + }, + "dependencies": { + "telegram": "^2.23.10" + }, + "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" } diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index 7a5e5670e..00afd0d2d 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index 71d0fc0be..a88e6b3a4 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", @@ -31,7 +31,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index 2e3c17b23..0d72c2f8f 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index b5b66b062..a1652a8ee 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.1", + "version": "1.4.2-alpha.1", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", @@ -26,7 +26,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.1", + "@builderbot/bot": "workspace:^", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7c79fa0a..b79850edb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,8 +253,8 @@ importers: packages/contexts-dialogflow: dependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@google-cloud/dialogflow': specifier: ^7.4.0 version: 7.4.0 @@ -293,8 +293,8 @@ importers: packages/contexts-dialogflow-cx: dependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@google-cloud/dialogflow-cx': specifier: ^5.5.0 version: 5.5.0 @@ -333,8 +333,8 @@ importers: packages/create-builderbot: dependencies: '@builderbot/cli': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../cli devDependencies: '@rollup/plugin-commonjs': specifier: ^29.0.0 @@ -358,8 +358,8 @@ importers: packages/database-json: dependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot devDependencies: '@rollup/plugin-commonjs': specifier: ^29.0.0 @@ -392,8 +392,8 @@ importers: packages/database-mongo: dependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot mongodb: specifier: ^7.0.0 version: 7.0.0(@aws-sdk/credential-providers@3.940.0)(gcp-metadata@8.1.2)(socks@2.8.7) @@ -438,8 +438,8 @@ importers: packages/database-mysql: dependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot mysql2: specifier: ^3.15.3 version: 3.15.3 @@ -481,8 +481,8 @@ importers: packages/database-postgres: dependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot pg: specifier: ^8.11.5 version: 8.16.3 @@ -618,8 +618,8 @@ importers: packages/plugins/chatwoot: dependencies: '@builderbot/bot': - specifier: ^1.3.15-alpha.21 - version: 1.3.15 + specifier: workspace:^ + version: link:../../bot devDependencies: '@rollup/plugin-commonjs': specifier: ^29.0.0 @@ -677,8 +677,8 @@ importers: version: 0.33.3 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@hapi/boom': specifier: ^10.0.1 version: 10.0.1 @@ -774,8 +774,8 @@ importers: version: 0.5.2 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -838,8 +838,8 @@ importers: version: 2.2.1 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -923,8 +923,8 @@ importers: version: 0.5.2 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -990,8 +990,8 @@ importers: version: 2.2.1 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -1048,8 +1048,8 @@ importers: version: 0.5.2 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.0.0 version: 30.2.0 @@ -1106,8 +1106,8 @@ importers: version: 0.5.2 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -1176,8 +1176,8 @@ importers: version: 2.2.1 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -1285,8 +1285,8 @@ importers: version: 6.3.8(@adiwajshing/keyed-db@0.2.4)(bufferutil@4.0.9)(jimp@1.6.0)(qrcode-terminal@0.12.0)(sharp@0.33.3)(utf-8-validate@5.0.10) devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@hapi/boom': specifier: ^10.0.1 version: 10.0.1 @@ -1370,8 +1370,8 @@ importers: version: 2.26.22 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@hapi/boom': specifier: ^10.0.1 version: 10.0.1 @@ -1437,8 +1437,8 @@ importers: version: 5.10.7 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -1510,8 +1510,8 @@ importers: version: 5.3.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -1589,8 +1589,8 @@ importers: version: 1.34.2(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@jest/globals': specifier: ^30.2.0 version: 30.2.0 @@ -1665,8 +1665,8 @@ importers: version: 0.33.5 devDependencies: '@builderbot/bot': - specifier: ^1.3.15 - version: 1.3.15 + specifier: workspace:^ + version: link:../bot '@rollup/plugin-commonjs': specifier: ^29.0.0 version: 29.0.0(rollup@4.53.3) @@ -2255,19 +2255,6 @@ packages: integrity: sha512-QhfcjDgm7p7PdH6jPCDuqwIruLxTcLShiPHMEpBB4p2CykxT5cPnQ0KuHK40ymmcLhSA1R56PlZlA7tez6mhyA==, } - '@builderbot/bot@1.3.15': - resolution: - { - integrity: sha512-g5ofmOTp0ixcQfOBi0wb90ePhGu0lpwZUtryY5/6D/5bYOcHyX6078IYwTm1T2Vn2gTj3c8sIF9r2gGasUSG3g==, - } - - '@builderbot/cli@1.3.15': - resolution: - { - integrity: sha512-SrPV407uqOA0DKunvSDtgEwCs9GONYXxCuxG1sb6+q196rtAj/oAu3FtBEEqL4DHweY2i3o2zV6iyHt+2l1USw==, - } - hasBin: true - '@cacheable/memory@2.0.6': resolution: { @@ -17764,24 +17751,6 @@ snapshots: - debug - supports-color - '@builderbot/bot@1.3.15': - dependencies: - '@ffmpeg-installer/ffmpeg': 1.1.0 - body-parser: 1.20.4 - cors: 2.8.5 - fluent-ffmpeg: 2.1.3 - follow-redirects: 1.15.11(debug@4.4.3) - mime-types: 2.1.35 - picocolors: 1.1.1 - polka: 0.5.2 - optionalDependencies: - sharp: 0.33.3 - transitivePeerDependencies: - - debug - - supports-color - - '@builderbot/cli@1.3.15': {} - '@cacheable/memory@2.0.6': dependencies: '@cacheable/utils': 2.3.2 From c38c3f2ec3af5aa1f1b01ff1c6776288ad7623a6 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 2 Apr 2026 21:19:08 +0200 Subject: [PATCH 05/31] fix: enhance error handling in --- packages/provider-meta/src/utils/downloadFile.ts | 1 + packages/provider-meta/src/utils/processIncomingMsg.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/provider-meta/src/utils/downloadFile.ts b/packages/provider-meta/src/utils/downloadFile.ts index ce524ee9c..14d7317ad 100644 --- a/packages/provider-meta/src/utils/downloadFile.ts +++ b/packages/provider-meta/src/utils/downloadFile.ts @@ -33,6 +33,7 @@ async function downloadFile(url: string, Token: string): Promise<{ buffer: Buffe } } catch (error) { console.error(error.message) + throw error } } diff --git a/packages/provider-meta/src/utils/processIncomingMsg.ts b/packages/provider-meta/src/utils/processIncomingMsg.ts index 4fd5e151f..346101644 100644 --- a/packages/provider-meta/src/utils/processIncomingMsg.ts +++ b/packages/provider-meta/src/utils/processIncomingMsg.ts @@ -133,9 +133,12 @@ export const processIncomingMessage = async ({ break } case 'sticker': { + const stickerUrl = await getMediaUrl(version, message.sticker?.id, numberId, jwtToken) responseObj = { type: message.type, from: message.from, + url: stickerUrl ?? fileData?.url, + fileData, to, id: message.sticker.id, body: utils.generateRefProvider('_event_media_'), From f11fc19dba603564f37c9c5a84a4c6ba3d37a1c6 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 2 Apr 2026 21:21:33 +0200 Subject: [PATCH 06/31] v1.4.2-alpha.2 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 2 +- packages/contexts-dialogflow/package.json | 2 +- packages/create-builderbot/package.json | 2 +- packages/database-json/package.json | 2 +- packages/database-mongo/package.json | 2 +- packages/database-mysql/package.json | 2 +- packages/database-postgres/package.json | 2 +- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 2 +- packages/provider-baileys/package.json | 2 +- packages/provider-email/package.json | 2 +- packages/provider-evolution-api/package.json | 2 +- packages/provider-facebook-messenger/package.json | 2 +- packages/provider-gohighlevel/package.json | 2 +- packages/provider-gupshup/package.json | 2 +- packages/provider-instagram/package.json | 2 +- packages/provider-meta/package.json | 2 +- packages/provider-sherpa/package.json | 2 +- packages/provider-telegram/package.json | 2 +- packages/provider-twilio/package.json | 2 +- packages/provider-venom/package.json | 2 +- packages/provider-web-whatsapp/package.json | 2 +- packages/provider-wppconnect/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lerna.json b/lerna.json index a21909fc1..91425bf22 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index 88b6b04d8..f33d9a08d 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index 13f9e8f10..9709a56a6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index caaa90b68..a65e44250 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index 159839006..dc215c6a6 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index 5271947db..dfa7b48d7 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "", "license": "ISC", "main": "dist/index.cjs", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index 9d9a4da1c..e86706c1d 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index 7a81a3aa1..a875c1f32 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index 2ce0d147b..71b2058ac 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 6c9036540..440df4aba 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index d64706030..e3010b373 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index c8f479805..2346a18d0 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index 27057fa85..2b4bbae58 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index be08c4f2d..63dba5124 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index 6c22fbef4..0712e21b4 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index a01588a84..99aff29a0 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index 0a8e2d87f..2ab4d9f6e 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index 6764983d3..6c3d31163 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index 989082ac4..4bb064d0d 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index 126a1faa2..046785be6 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index bb1e1d979..a9cf402aa 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index 35dbf9d5c..57f09171f 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index 683a80662..89a6f8e9e 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Provider for Telegram", "keywords": [], "author": "", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index 00afd0d2d..ebc12004d 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index a88e6b3a4..05038f53f 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index 0d72c2f8f..d167285f5 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index a1652a8ee..d78fbd335 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.1", + "version": "1.4.2-alpha.2", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", From 88f937516dbc211cededaa4d7f299c5e10d1d4ba Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Wed, 8 Apr 2026 10:57:34 +0200 Subject: [PATCH 07/31] feat(provider-meta): auto-convert audio to OGG/Opus and send as voice note (PTT) - sendAudio() now auto-converts MP3/WAV/etc to OGG/Opus using utils.convertAudio() - All audio messages now sent with voice: true for PTT (green bubble) instead of audio file (orange) - sendMedia() no longer double-converts audio (removed MP3 intermediate step) - Updated tests for new behavior Fixes audio arriving as 'orange file' instead of voice note --- .../provider-meta/__tests__/provider.test.ts | 50 ++++++++++++------- packages/provider-meta/src/meta/provider.ts | 26 +++++----- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/packages/provider-meta/__tests__/provider.test.ts b/packages/provider-meta/__tests__/provider.test.ts index 6f40a37cd..acd893ade 100644 --- a/packages/provider-meta/__tests__/provider.test.ts +++ b/packages/provider-meta/__tests__/provider.test.ts @@ -24,6 +24,7 @@ jest.mock('@builderbot/bot') describe('#MetaProvider', () => { let metaProvider: MetaProvider beforeEach(() => { + jest.clearAllMocks() metaProvider = new MetaProvider({ name: 'bot', jwtToken: 'your_jwt_token', @@ -271,53 +272,66 @@ describe('#MetaProvider', () => { }) describe('#sendAudio', () => { - test('should send audio message to the provided recipient', async () => { + test('should send audio message to the provided recipient (auto-converts to OGG)', async () => { // Arrange const fakeRecipient = '1234567890' const fakePathVideo: any = 'path/to/audio.mp3' + const convertedPath: any = 'path/to/audio.opus' metaProvider.sendMessageMeta = jest.fn() as never + ;(utils.convertAudio as jest.MockedFunction).mockResolvedValue(convertedPath) + jest.spyOn(mime, 'lookup').mockReturnValue('audio/mpeg') // Act await metaProvider.sendAudio(fakeRecipient, fakePathVideo) // Assert + expect(utils.convertAudio).toHaveBeenCalledWith(fakePathVideo, 'opus') expect(metaProvider.sendMessageMeta).toHaveBeenCalledWith({ messaging_product: 'whatsapp', to: fakeRecipient, type: 'audio', audio: { id: undefined, + voice: true, }, }) }) - test('should throw an error if pathVideo is null', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakePathVideo = null - - // Act & Assert - await expect(metaProvider.sendAudio(fakeRecipient, fakePathVideo)).rejects.toThrow( - 'MEDIA_INPUT_NULL_: null' - ) - }) - - test('should log a message for unsupported media types', async () => { + test('should send OGG audio directly without conversion', async () => { // Arrange const fakeRecipient = '1234567890' const fakePathVideo: any = 'path/to/audio.ogg' - const consoleSpy = jest.spyOn(console, 'log') + + metaProvider.sendMessageMeta = jest.fn() as never + ;(utils.convertAudio as jest.MockedFunction).mockResolvedValue(fakePathVideo) + jest.spyOn(mime, 'lookup').mockReturnValue('audio/ogg') // Act await metaProvider.sendAudio(fakeRecipient, fakePathVideo) // Assert - expect(consoleSpy).toHaveBeenCalledWith( - `Format (audio/ogg) not supported, you should use\nhttps://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types` - ) + expect(utils.convertAudio).not.toHaveBeenCalled() + expect(metaProvider.sendMessageMeta).toHaveBeenCalledWith({ + messaging_product: 'whatsapp', + to: fakeRecipient, + type: 'audio', + audio: { + id: undefined, + voice: true, + }, + }) + }) + + test('should throw an error if pathVideo is null', async () => { + // Arrange + const fakeRecipient = '1234567890' + const fakePathVideo = null - consoleSpy.mockRestore() + // Act & Assert + await expect(metaProvider.sendAudio(fakeRecipient, fakePathVideo)).rejects.toThrow( + 'MEDIA_INPUT_NULL_: null' + ) }) }) diff --git a/packages/provider-meta/src/meta/provider.ts b/packages/provider-meta/src/meta/provider.ts index 76b6c9ce8..586054201 100644 --- a/packages/provider-meta/src/meta/provider.ts +++ b/packages/provider-meta/src/meta/provider.ts @@ -380,8 +380,8 @@ class MetaProvider extends ProviderClass implements MetaInterface if (mimeType.includes('image')) return this.sendImage(to, mediaInput, text, context) if (mimeType.includes('video')) return this.sendVideo(to, fileDownloaded, text, context) if (mimeType.includes('audio')) { - const fileOpus = await utils.convertAudio(mediaInput, 'mp3') - return this.sendAudio(to, fileOpus, context) + // sendAudio handles conversion to OGG/Opus automatically + return this.sendAudio(to, mediaInput, context) } return this.sendFile(to, mediaInput, text, context) @@ -822,7 +822,7 @@ class MetaProvider extends ProviderClass implements MetaInterface /** * Send an audio message by uploading a local file * @param to - Recipient phone number - * @param pathVideo - Local path to the audio file (supports mp3, m4a, aac, amr, ogg with opus codec) + * @param pathVideo - Local path to the audio file (supports mp3, m4a, aac, amr, ogg - auto-converts to ogg/opus) * @param context - Optional message ID to reply to * @returns Promise with the API response * @throws Error if pathVideo is null @@ -833,21 +833,20 @@ class MetaProvider extends ProviderClass implements MetaInterface to = parseMetaNumber(to) if (!pathVideo) throw new Error(`MEDIA_INPUT_NULL_: ${pathVideo}`) - const formData = new FormData() + let audioPath = pathVideo const mimeType = mime.lookup(pathVideo) - if (['audio/ogg'].includes(mimeType)) { - console.log( - [ - `Format (${mimeType}) not supported, you should use`, - `https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types`, - ].join('\n') - ) + // Auto-convert to OGG/Opus if not already in that format (required for voice notes) + if (!mimeType?.includes('ogg') && !mimeType?.includes('opus')) { + audioPath = await utils.convertAudio(pathVideo, 'opus') } - formData.append('file', createReadStream(pathVideo), { - contentType: mimeType, + + const formData = new FormData() + formData.append('file', createReadStream(audioPath), { + contentType: 'audio/ogg', }) formData.append('messaging_product', 'whatsapp') + const { data: { id: mediaId }, } = await axios.post( @@ -867,6 +866,7 @@ class MetaProvider extends ProviderClass implements MetaInterface type: 'audio', audio: { id: mediaId, + voice: true, // Sends as voice note (PTT) instead of audio file }, } if (context) body.context = { message_id: context } From be4c4287fe2fa5847968c98c3d9d32088edeaca3 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Wed, 8 Apr 2026 11:51:16 +0200 Subject: [PATCH 08/31] v1.4.2-alpha.3 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 2 +- packages/contexts-dialogflow/package.json | 2 +- packages/create-builderbot/package.json | 2 +- packages/database-json/package.json | 2 +- packages/database-mongo/package.json | 2 +- packages/database-mysql/package.json | 2 +- packages/database-postgres/package.json | 2 +- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 2 +- packages/provider-baileys/package.json | 2 +- packages/provider-email/package.json | 2 +- packages/provider-evolution-api/package.json | 2 +- packages/provider-facebook-messenger/package.json | 2 +- packages/provider-gohighlevel/package.json | 2 +- packages/provider-gupshup/package.json | 2 +- packages/provider-instagram/package.json | 2 +- packages/provider-meta/package.json | 2 +- packages/provider-sherpa/package.json | 2 +- packages/provider-telegram/package.json | 2 +- packages/provider-twilio/package.json | 2 +- packages/provider-venom/package.json | 2 +- packages/provider-web-whatsapp/package.json | 2 +- packages/provider-wppconnect/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lerna.json b/lerna.json index 91425bf22..c76eb71c4 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index f33d9a08d..0fa759947 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index 9709a56a6..5f9a62b46 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index a65e44250..f25509263 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index dc215c6a6..511f51171 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index dfa7b48d7..092e816bb 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "", "license": "ISC", "main": "dist/index.cjs", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index e86706c1d..9f1f11d42 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index a875c1f32..bb7348c33 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index 71b2058ac..038c223d4 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 440df4aba..04ca8fb69 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index e3010b373..bdc5bf4ae 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index 2346a18d0..6120dea58 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index 2b4bbae58..4ea5da263 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index 63dba5124..d32da57df 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index 0712e21b4..d823f16e1 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index 99aff29a0..611e8f554 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index 2ab4d9f6e..5b406678e 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index 6c3d31163..bf0013f64 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index 4bb064d0d..edbce6961 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index 046785be6..31d7f4668 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index a9cf402aa..d54024790 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index 57f09171f..799f4f735 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index 89a6f8e9e..b2b5269b3 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Provider for Telegram", "keywords": [], "author": "", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index ebc12004d..760388178 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index 05038f53f..d79795589 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index d167285f5..4b8d9f31d 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index d78fbd335..4f599c3fe 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.2", + "version": "1.4.2-alpha.3", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", From 19b4550a5abfc6ea102ba9504308e400e266fd44 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Wed, 8 Apr 2026 11:56:55 +0200 Subject: [PATCH 09/31] chore: publish dev --- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 4 ++-- packages/contexts-dialogflow/package.json | 4 ++-- packages/create-builderbot/package.json | 4 ++-- packages/database-json/package.json | 4 ++-- packages/database-mongo/package.json | 4 ++-- packages/database-mysql/package.json | 4 ++-- packages/database-postgres/package.json | 4 ++-- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 4 ++-- packages/provider-baileys/package.json | 4 ++-- packages/provider-email/package.json | 4 ++-- packages/provider-evolution-api/package.json | 4 ++-- packages/provider-facebook-messenger/package.json | 4 ++-- packages/provider-gohighlevel/package.json | 4 ++-- packages/provider-gupshup/package.json | 4 ++-- packages/provider-instagram/package.json | 4 ++-- packages/provider-meta/package.json | 4 ++-- packages/provider-sherpa/package.json | 4 ++-- packages/provider-telegram/package.json | 4 ++-- packages/provider-twilio/package.json | 4 ++-- packages/provider-venom/package.json | 4 ++-- packages/provider-web-whatsapp/package.json | 4 ++-- packages/provider-wppconnect/package.json | 4 ++-- 26 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/bot/package.json b/packages/bot/package.json index 0fa759947..e852afadd 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -62,5 +62,5 @@ "optionalDependencies": { "sharp": "0.33.3" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 5f9a62b46..81073e2ee 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,5 +41,5 @@ "type": "git", "url": "https://github.com/codigoencasa/bot-whatsapp/tree/main/packages/cli" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index f25509263..67b09766a 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@google-cloud/dialogflow-cx": "^5.5.0" }, "devDependencies": { @@ -44,5 +44,5 @@ "rollup-plugin-typescript2": "^0.36.0", "sinon": "^17.0.1" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index 511f51171..43409e4a1 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@google-cloud/dialogflow": "^7.4.0" }, "devDependencies": { @@ -44,5 +44,5 @@ "rollup-plugin-typescript2": "^0.36.0", "sinon": "^17.0.1" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index 092e816bb..b937bd7d4 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -16,7 +16,7 @@ ], "bin": "./bin/create.cjs", "dependencies": { - "@builderbot/cli": "workspace:^" + "@builderbot/cli": "^1.4.2-alpha.3" }, "repository": { "type": "git", @@ -30,5 +30,5 @@ "rimraf": "^6.1.2", "rollup-plugin-typescript2": "^0.36.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/database-json/package.json b/packages/database-json/package.json index 9f1f11d42..e252b61b7 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "workspace:^" + "@builderbot/bot": "^1.4.2-alpha.3" }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { @@ -43,5 +43,5 @@ "tslib": "^2.6.2", "tsm": "^2.3.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index bb7348c33..13090fd54 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "mongodb": "^7.0.0" }, "devDependencies": { @@ -47,5 +47,5 @@ "tslib": "^2.6.2", "tsm": "^2.3.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index 038c223d4..adbbe5aec 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "mysql2": "^3.15.3" }, "devDependencies": { @@ -46,5 +46,5 @@ "tslib": "^2.6.2", "tsm": "^2.3.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 04ca8fb69..0527cc504 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "pg": "^8.11.5" }, "devDependencies": { @@ -46,5 +46,5 @@ "tslib": "^2.6.2", "tsm": "^2.3.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index bdc5bf4ae..6bc8ae671 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -42,5 +42,5 @@ "tslib": "^2.6.2", "tsm": "^2.3.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/manager/package.json b/packages/manager/package.json index 6120dea58..808d3e6e2 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -68,5 +68,5 @@ "optional": true } }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index 4ea5da263..fa9da55cb 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -42,7 +42,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "workspace:^" + "@builderbot/bot": "^1.4.2-alpha.3" }, "devDependencies": { "@rollup/plugin-commonjs": "^29.0.0", @@ -53,5 +53,5 @@ "tslib": "^2.8.1", "tsm": "^2.3.0" }, - "gitHead": "8ca5bde13fa8276a8bdeac9877df107aba0bc20b" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index d32da57df..13e3445aa 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -38,7 +38,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@hapi/boom": "^10.0.1", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", @@ -77,5 +77,5 @@ "node-cache": "^5.1.2", "sharp": "0.33.3" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index d823f16e1..dc58edaa2 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -48,5 +48,5 @@ "nodemailer": "^6.10.1", "polka": "^0.5.2" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index 611e8f554..b43cd760a 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -40,7 +40,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -65,5 +65,5 @@ "tslib": "^2.6.2", "tsm": "^2.3.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index 5b406678e..63982674f 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -33,7 +33,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -53,5 +53,5 @@ "mime-types": "^3.0.2", "polka": "^0.5.2" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index bf0013f64..b65ba37e1 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -38,7 +38,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -53,5 +53,5 @@ "ts-jest": "^29.4.6", "tslib": "^2.6.2" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index edbce6961..6704c5ac0 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -25,7 +25,7 @@ "@builderbot/bot": "workspace:*" }, "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.0.0", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-json": "^6.0.0", @@ -40,5 +40,5 @@ "ts-jest": "^29.0.0", "typescript": "^5.0.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index 31d7f4668..6a027d61f 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -33,7 +33,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -55,5 +55,5 @@ "mime-types": "^3.0.2", "polka": "^0.5.2" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index d54024790..0311258ad 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -38,7 +38,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -61,5 +61,5 @@ "tslib": "^2.6.2", "tsm": "^2.3.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index 799f4f735..e1d5f911a 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -38,7 +38,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@hapi/boom": "^10.0.1", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", @@ -80,5 +80,5 @@ "typescript": "^5.9.3", "whaileys": "6.3.8" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index b2b5269b3..ae75cf67c 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@hapi/boom": "^10.0.1", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", @@ -49,5 +49,5 @@ "dependencies": { "telegram": "^2.23.10" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index 760388178..79e7fa396 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -56,5 +56,5 @@ "polka": "^0.5.2", "twilio": "~5.10.7" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index d79795589..50795fa8f 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -31,7 +31,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -59,5 +59,5 @@ "sharp": "0.33.3", "venom-bot": "~5.3.0" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index 4b8d9f31d..f4706a5a3 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -56,5 +56,5 @@ "sharp": "0.33.3", "whatsapp-web.js": "~1.34.2" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index 4f599c3fe..2b69c114d 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -26,7 +26,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "workspace:^", + "@builderbot/bot": "^1.4.2-alpha.3", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", @@ -55,5 +55,5 @@ "polka": "^0.5.2", "sharp": "0.33.5" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } From 3a57f5fddba8f0001c70e78f5ef28b91fcab0079 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Wed, 8 Apr 2026 12:17:30 +0200 Subject: [PATCH 10/31] v1.4.2-alpha.5 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 4 ++-- packages/contexts-dialogflow/package.json | 4 ++-- packages/create-builderbot/package.json | 4 ++-- packages/database-json/package.json | 4 ++-- packages/database-mongo/package.json | 4 ++-- packages/database-mysql/package.json | 4 ++-- packages/database-postgres/package.json | 4 ++-- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 4 ++-- packages/provider-baileys/package.json | 4 ++-- packages/provider-email/package.json | 4 ++-- packages/provider-evolution-api/package.json | 4 ++-- packages/provider-facebook-messenger/package.json | 4 ++-- packages/provider-gohighlevel/package.json | 4 ++-- packages/provider-gupshup/package.json | 4 ++-- packages/provider-instagram/package.json | 4 ++-- packages/provider-meta/package.json | 4 ++-- packages/provider-sherpa/package.json | 4 ++-- packages/provider-telegram/package.json | 4 ++-- packages/provider-twilio/package.json | 4 ++-- packages/provider-venom/package.json | 4 ++-- packages/provider-web-whatsapp/package.json | 4 ++-- packages/provider-wppconnect/package.json | 4 ++-- 27 files changed, 49 insertions(+), 49 deletions(-) diff --git a/lerna.json b/lerna.json index c76eb71c4..9194165b7 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index e852afadd..e7f908fa7 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index 81073e2ee..24392c558 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index 67b09766a..b00beac66 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@google-cloud/dialogflow-cx": "^5.5.0" }, "devDependencies": { diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index 43409e4a1..e9f26cd37 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@google-cloud/dialogflow": "^7.4.0" }, "devDependencies": { diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index b937bd7d4..b785556a0 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "", "license": "ISC", "main": "dist/index.cjs", @@ -16,7 +16,7 @@ ], "bin": "./bin/create.cjs", "dependencies": { - "@builderbot/cli": "^1.4.2-alpha.3" + "@builderbot/cli": "workspace:^" }, "repository": { "type": "git", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index e252b61b7..c79478505 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "^1.4.2-alpha.3" + "@builderbot/bot": "workspace:^" }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index 13090fd54..5bfbe5b2b 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "mongodb": "^7.0.0" }, "devDependencies": { diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index adbbe5aec..0578493d5 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "mysql2": "^3.15.3" }, "devDependencies": { diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 0527cc504..960aca624 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "dependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "pg": "^8.11.5" }, "devDependencies": { diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index 6bc8ae671..d15e63777 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index 808d3e6e2..32acced86 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index fa9da55cb..4f7c32820 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", @@ -42,7 +42,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "dependencies": { - "@builderbot/bot": "^1.4.2-alpha.3" + "@builderbot/bot": "workspace:^" }, "devDependencies": { "@rollup/plugin-commonjs": "^29.0.0", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index 13e3445aa..813ef8cea 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", @@ -38,7 +38,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@hapi/boom": "^10.0.1", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index dc58edaa2..1da8ac4e1 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index b43cd760a..fe790f8b8 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", @@ -40,7 +40,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index 63982674f..fcbbd4cc9 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", @@ -33,7 +33,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index b65ba37e1..e489eee50 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", @@ -38,7 +38,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index 6704c5ac0..d0dc98ffd 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", @@ -25,7 +25,7 @@ "@builderbot/bot": "workspace:*" }, "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.0.0", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-json": "^6.0.0", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index 6a027d61f..140e47483 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", @@ -33,7 +33,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index 0311258ad..1aad55b69 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", @@ -38,7 +38,7 @@ "queue-promise": "^2.2.1" }, "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index e1d5f911a..5826ac4b3 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", @@ -38,7 +38,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@hapi/boom": "^10.0.1", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index ae75cf67c..caa7bf5aa 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Provider for Telegram", "keywords": [], "author": "", @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@hapi/boom": "^10.0.1", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index 79e7fa396..cc912e4e3 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", @@ -29,7 +29,7 @@ "url": "https://github.com/codigoencasa/bot-whatsapp/issues" }, "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index 50795fa8f..d73727896 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", @@ -31,7 +31,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index f4706a5a3..c5ba1775a 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", @@ -30,7 +30,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@jest/globals": "^30.2.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index 2b69c114d..515af34b5 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.3", + "version": "1.4.2-alpha.5", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", @@ -26,7 +26,7 @@ }, "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", "devDependencies": { - "@builderbot/bot": "^1.4.2-alpha.3", + "@builderbot/bot": "workspace:^", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", From ace0bb8b1f0c11905b89e17a281fcef7e995af34 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Wed, 8 Apr 2026 12:17:45 +0200 Subject: [PATCH 11/31] chore: publish dev --- package.json | 3 +- scripts/check-workspace-deps.js | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 scripts/check-workspace-deps.js diff --git a/package.json b/package.json index 14dd1b06b..58203be00 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "publish:canary": "npx lerna publish from-package --canary", "prepare": "npx husky install", "publish:dev": "npx lerna publish from-package --dist-tag dev --yes", - "next:version": "npx lerna version --force-publish", + "check:workspace-deps": "node ./scripts/check-workspace-deps.js", + "next:version": "pnpm run check:workspace-deps && npx lerna version --force-publish", "preinstall": "npx only-allow pnpm", "release": "standard-version -- --prerelease --global", "generate:release-summary": "node ./scripts/generate-release-summary.js --version=$(node -p \"require('./lerna.json').version\")", diff --git a/scripts/check-workspace-deps.js b/scripts/check-workspace-deps.js new file mode 100644 index 000000000..2f1ecf82c --- /dev/null +++ b/scripts/check-workspace-deps.js @@ -0,0 +1,61 @@ +const fs = require('fs') +const path = require('path') + +const rootDir = process.cwd() +const dependencySections = ['dependencies', 'devDependencies', 'optionalDependencies'] + +const rootPackageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')) +const workspacePatterns = rootPackageJson.workspaces || [] + +const packageJsonPaths = workspacePatterns + .map((pattern) => { + if (pattern.endsWith('/*')) { + const baseDir = path.join(rootDir, pattern.slice(0, -2)) + if (!fs.existsSync(baseDir)) { + return [] + } + + return fs + .readdirSync(baseDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(baseDir, entry.name, 'package.json')) + .filter((pkgPath) => fs.existsSync(pkgPath)) + } + + const pkgPath = path.join(rootDir, pattern, 'package.json') + return fs.existsSync(pkgPath) ? [pkgPath] : [] + }) + .flat() + +const violations = [] + +for (const packageJsonPath of packageJsonPaths) { + const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + const relativePath = path.relative(rootDir, packageJsonPath) + + for (const section of dependencySections) { + const deps = content[section] || {} + + for (const [name, version] of Object.entries(deps)) { + if (!name.startsWith('@builderbot/')) { + continue + } + + if (typeof version === 'string' && version.startsWith('workspace:')) { + continue + } + + violations.push(`${relativePath} -> ${section}.${name} = "${version}"`) + } + } +} + +if (violations.length > 0) { + console.error('Found internal dependencies without workspace protocol:') + for (const violation of violations) { + console.error(`- ${violation}`) + } + process.exit(1) +} + +console.log('All internal @builderbot/* dependencies use workspace protocol.') From c141dedbefba1d3297062966d9f02f89e83c4092 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Wed, 8 Apr 2026 12:18:30 +0200 Subject: [PATCH 12/31] v1.4.2-alpha.6 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 2 +- packages/contexts-dialogflow/package.json | 2 +- packages/create-builderbot/package.json | 2 +- packages/database-json/package.json | 2 +- packages/database-mongo/package.json | 2 +- packages/database-mysql/package.json | 2 +- packages/database-postgres/package.json | 2 +- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 2 +- packages/provider-baileys/package.json | 2 +- packages/provider-email/package.json | 2 +- packages/provider-evolution-api/package.json | 2 +- packages/provider-facebook-messenger/package.json | 2 +- packages/provider-gohighlevel/package.json | 2 +- packages/provider-gupshup/package.json | 2 +- packages/provider-instagram/package.json | 2 +- packages/provider-meta/package.json | 2 +- packages/provider-sherpa/package.json | 2 +- packages/provider-telegram/package.json | 2 +- packages/provider-twilio/package.json | 2 +- packages/provider-venom/package.json | 2 +- packages/provider-web-whatsapp/package.json | 2 +- packages/provider-wppconnect/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lerna.json b/lerna.json index 9194165b7..69581c26b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index e7f908fa7..c9b26bede 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index 24392c558..762cf32b5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index b00beac66..07c60ce12 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index e9f26cd37..3fb0cb657 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index b785556a0..784cdd078 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "", "license": "ISC", "main": "dist/index.cjs", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index c79478505..951b70bf0 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index 5bfbe5b2b..005d2c099 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index 0578493d5..0a6fee0ec 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 960aca624..2f0aecc20 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index d15e63777..76713e60f 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index 32acced86..9178a66a1 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index 4f7c32820..e2711aa0e 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index 813ef8cea..0f3c6ba77 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index 1da8ac4e1..b658f1eb0 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index fe790f8b8..5b2b1d48c 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index fcbbd4cc9..729e0a64f 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index e489eee50..987602a96 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index d0dc98ffd..47f14765f 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index 140e47483..791f7c6f7 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index 1aad55b69..9e566d533 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index 5826ac4b3..bbeb18436 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index caa7bf5aa..848b49ae1 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Provider for Telegram", "keywords": [], "author": "", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index cc912e4e3..0f9eb3cdc 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index d73727896..c5cd89972 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index c5ba1775a..d20b5f0b8 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index 515af34b5..f303ec77c 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.5", + "version": "1.4.2-alpha.6", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", From 47d4b57e08cd414400116690b8f285b5331d5c5f Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 16 Apr 2026 11:49:13 +0200 Subject: [PATCH 13/31] feat(provider-baileys): integrate LID cache for phone number resolution --- .../__tests__/baileysReliability.test.ts | 9 +- .../__tests__/lidCache.critical.test.ts | 469 +++++++ .../__tests__/lidCache.edges.test.ts | 634 +++++++++ packages/provider-baileys/src/bailey.ts | 80 +- packages/provider-baileys/src/index.ts | 1 + packages/provider-baileys/src/lidCache.ts | 1238 +++++++++++++++++ packages/provider-baileys/src/type.ts | 17 + 7 files changed, 2436 insertions(+), 12 deletions(-) create mode 100644 packages/provider-baileys/__tests__/lidCache.critical.test.ts create mode 100644 packages/provider-baileys/__tests__/lidCache.edges.test.ts create mode 100644 packages/provider-baileys/src/lidCache.ts diff --git a/packages/provider-baileys/__tests__/baileysReliability.test.ts b/packages/provider-baileys/__tests__/baileysReliability.test.ts index 31cae9f12..73f925fd2 100644 --- a/packages/provider-baileys/__tests__/baileysReliability.test.ts +++ b/packages/provider-baileys/__tests__/baileysReliability.test.ts @@ -155,9 +155,10 @@ describe('#BaileysProvider - Reliability', () => { await provider['delayedReconnect']() - expect(emitSpy).toHaveBeenCalledWith('auth_failure', expect.arrayContaining([ - expect.stringContaining('Maximum reconnection attempts reached'), - ])) + expect(emitSpy).toHaveBeenCalledWith( + 'auth_failure', + expect.arrayContaining([expect.stringContaining('Maximum reconnection attempts reached')]) + ) }) test('should not increment attempts when max is reached', async () => { @@ -383,7 +384,7 @@ describe('#BaileysProvider - Reliability', () => { }, } as any - const result = await provider.getPNForLID('lid:abc') + const result = await provider.getPNForLID('123456789@lid') expect(result).toBe('1234567890@s.whatsapp.net') }) }) diff --git a/packages/provider-baileys/__tests__/lidCache.critical.test.ts b/packages/provider-baileys/__tests__/lidCache.critical.test.ts new file mode 100644 index 000000000..795c54cd8 --- /dev/null +++ b/packages/provider-baileys/__tests__/lidCache.critical.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, test, beforeEach, afterEach, jest } from '@jest/globals' +import { rm, access, stat, mkdir, writeFile } from 'fs/promises' +import { join } from 'path' + +import { HybridLidCache } from '../src/lidCache' + +describe('lidCache CRITICAL fixes', () => { + const testSession = 'test-critical-' + Date.now() + + // Mock logger para capturar errores + const mockLogger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + } + + beforeEach(async () => { + jest.clearAllMocks() + }) + + afterEach(async () => { + // Limpiar cualquier directorio de test que haya quedado + try { + const testDirs = [join(process.cwd(), `${testSession}_sessions`), join(process.cwd(), 'test-*_sessions')] + for (const dir of testDirs) { + try { + await rm(dir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + } + } catch { + // ignore cleanup errors + } + }) + + // ============================================================================ + // CRITICAL FIX 1: File Permissions (0o600) + // ============================================================================ + describe('File permissions security', () => { + test('should create file with 0o600 permissions (owner read/write only)', async () => { + const session = 'perm-test-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('123@lid', '456789@s.whatsapp.net') + await cache.flushToDisk() + await cache.close() + + const cacheFile = join(process.cwd(), `${session}_sessions`, 'lid-cache.json') + const stats = await stat(cacheFile) + + // Verificar permisos: 0o600 = 384 en decimal + const mode = stats.mode & 0o777 + expect(mode).toBe(0o600) + + // Limpiar + await rm(join(process.cwd(), `${session}_sessions`), { recursive: true, force: true }) + }) + + test('should sanitize session name to prevent path traversal', async () => { + const maliciousSession = '../../../etc/cron.d/test' + const safeCache = new HybridLidCache(maliciousSession, 3600, process.cwd(), mockLogger as any) + await safeCache.ready() + + // El path debe estar dentro del directorio actual + expect(safeCache['filePath']).toContain(process.cwd()) + // El path NO debe contener la ruta maliciosa + expect(safeCache['filePath']).not.toContain('/etc/cron.d') + // El path debe tener el sufijo _sessions + expect(safeCache['filePath']).toContain('_sessions') + + await safeCache.set('test@lid', '123@s.whatsapp.net') + await safeCache.flushToDisk() + await new Promise((r) => setTimeout(r, 100)) + await safeCache.close() + + // Verificar que el archivo fue creado + const cacheFile = safeCache['filePath'] + await access(cacheFile) + + // Limpiar + const sessionDir = join(process.cwd(), safeCache['filePath'].replace(process.cwd(), '').split('/')[1]) + try { + await rm(sessionDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + }) + }) + + // ============================================================================ + // CRITICAL FIX 2: Async race / ready() pattern + // ============================================================================ + describe('Async race condition handling', () => { + test('should wait for load before returning data via ready()', async () => { + const session = 'ready-test-' + Date.now() + const cacheDir = join(process.cwd(), `${session}_sessions`) + + // Primera instancia: guardar datos + const cache1 = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache1.ready() + await cache1.set('race-test@lid', '999888777@s.whatsapp.net') + await cache1.flushToDisk() + await cache1.close() + + // Pequeña pausa para asegurar escritura + await new Promise((r) => setTimeout(r, 50)) + + // Segunda instancia: verificar que ready() funciona + const cache2 = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache2.ready() + const result = await cache2.get('race-test@lid') + await cache2.close() + + expect(result).toBe('999888777@s.whatsapp.net') + + // Limpiar + await rm(cacheDir, { recursive: true, force: true }) + }) + + test('should reject invalid constructor arguments', () => { + // Session name vacío + expect(() => { + new HybridLidCache('', 3600, undefined, mockLogger as any) + }).toThrow('sessionName is required') + + // TTL muy bajo + expect(() => { + new HybridLidCache('test', 30, undefined, mockLogger as any) + }).toThrow('ttlSeconds must be at least 60') + + // Session name no-string + expect(() => { + new HybridLidCache(123 as any, 3600, undefined, mockLogger as any) + }).toThrow('sessionName is required') + }) + }) + + // ============================================================================ + // CRITICAL FIX 3: Concurrent flush deduplication + // ============================================================================ + describe('Concurrent flush deduplication', () => { + test('should not allow concurrent flush operations', async () => { + const session = 'concurrent-test-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + // Llenar caché + for (let i = 0; i < 100; i++) { + await cache.set(`concurrent${i}@lid`, `phone${i}@s.whatsapp.net`) + } + + // Intentar múltiple flushes concurrentes + const flushes = [cache.flushToDisk(), cache.flushToDisk(), cache.flushToDisk(), cache.flushToDisk()] + + // No deberían conflictar, solo el primero ejecuta + await expect(Promise.all(flushes)).resolves.not.toThrow() + + await cache.close() + + // Verificar que el archivo existe y tiene datos + await new Promise((r) => setTimeout(r, 50)) + const cacheFile = join(process.cwd(), `${session}_sessions`, 'lid-cache.json') + const stats = await stat(cacheFile) + expect(stats.size).toBeGreaterThan(0) + + // Limpiar + await rm(join(process.cwd(), `${session}_sessions`), { recursive: true, force: true }) + }) + + test('should handle rapid set/flush/close sequence', async () => { + const session = 'rapid-test-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + // Secuencia rápida que podría causar race conditions + await cache.set('rapid1@lid', '111@s.whatsapp.net') + await cache.flushToDisk() + await cache.set('rapid2@lid', '222@s.whatsapp.net') + await cache.flushToDisk() + await cache.close() + + // Verificar que el caché se cerró limpiamente + expect(await cache.get('rapid1@lid')).toBeNull() // Cerrado, no responde + }) + }) + + // ============================================================================ + // CRITICAL FIX 4: Error logging and max failures + // ============================================================================ + describe('Error handling and logging', () => { + test('should log errors when flush fails', async () => { + const session = 'error-test-' + Date.now() + const cacheDir = join(process.cwd(), `${session}_sessions`) + + // Crear directorio como archivo para forzar error + await mkdir(cacheDir, { recursive: true }) + await writeFile(join(cacheDir, 'lid-cache.json'), '') // Archivo vacío + + const badCache = new HybridLidCache(session, 3600, process.cwd(), mockLogger as any) + await badCache.ready() + + // Limpiar mocks + mockLogger.error.mockClear() + mockLogger.warn.mockClear() + + // Intentar guardar datos + await badCache.set('fail-test@lid', '123@s.whatsapp.net') + + try { + await badCache.flushToDisk() + } catch { + // Esperado + } + + await badCache.close() + }) + + test('should disable persistence after MAX_FLUSH_FAILURES', async () => { + const session = 'disable-test-' + Date.now() + const cacheDir = join(process.cwd(), `${session}_sessions`) + + // Limpiar y crear estructura que cause fallos + try { + await rm(cacheDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + await mkdir(cacheDir, { recursive: true }) + + const badCache = new HybridLidCache(session, 3600, process.cwd(), mockLogger as any) + await badCache.ready() + + // Hacer que el flush falle múltiples veces + let attempts = 0 + const maxAttempts = 15 + + for (let i = 0; i < maxAttempts; i++) { + await badCache.set(`failure-test${i}@lid`, `phone${i}@s.whatsapp.net`) + try { + await badCache.flushToDisk() + } catch { + attempts++ + } + } + + // Si hubo fallos, debería haber logueado errores + if (attempts > 0) { + expect(mockLogger.error.mock.calls.length + mockLogger.warn.mock.calls.length).toBeGreaterThan(0) + } + + await badCache.close() + }) + + test('should handle invalid inputs gracefully', async () => { + const session = 'invalid-test-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + // Intentar set con valores inválidos + await cache.set('invalid-lid', '123@s.whatsapp.net') // No tiene @lid + await cache.set('123@lid', 'not-a-valid-pn') // No tiene @s.whatsapp.net ni solo dígitos + + // Deberían ser rechazados + expect(await cache.get('invalid-lid')).toBeNull() + expect(await cache.get('123@lid')).toBeNull() + + await cache.close() + }) + }) + + // ============================================================================ + // Additional robustness tests + // ============================================================================ + describe('Additional robustness', () => { + test('should handle rapid close without explicit flush', async () => { + const session = 'close-test-' + Date.now() + const cacheDir = join(process.cwd(), `${session}_sessions`) + const cacheFile = join(cacheDir, 'lid-cache.json') + + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + // Guardar datos pero no hacer flush explícito + await cache.set('no-flush@lid', '555@s.whatsapp.net') + + // Cerrar inmediatamente (debería hacer flush en close) + await cache.close() + + // Pequeña pausa para asegurar escritura + await new Promise((r) => setTimeout(r, 100)) + + try { + // Verificar que el archivo tiene datos + const stats = await stat(cacheFile) + expect(stats.size).toBeGreaterThan(0) + } catch (err: any) { + // Si el archivo no existe, el flush en close falló + throw new Error(`Expected file ${cacheFile} to exist but got: ${err.message}`) + } + + // Limpiar + await rm(cacheDir, { recursive: true, force: true }) + }) + + test('should provide stats for monitoring', async () => { + const session = 'stats-test-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + // Stats inicial + const initialStats = cache.getStats() + expect(initialStats.keys).toBe(0) + + // Agregar datos + await cache.set('stats1@lid', '111@s.whatsapp.net') + await cache.set('stats2@lid', '222@s.whatsapp.net') + + const finalStats = cache.getStats() + expect(finalStats.keys).toBe(2) + + await cache.close() + }) + + test('should handle isClosed flag correctly', async () => { + const session = 'closed-test-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('closed-test@lid', '123@s.whatsapp.net') + await cache.close() + + // Después de cerrar, operaciones deberían retornar null/void + expect(await cache.get('closed-test@lid')).toBeNull() + + await cache.set('after-close@lid', '456@s.whatsapp.net') + expect(await cache.get('after-close@lid')).toBeNull() + + // No debería lanzar error + await cache.clear() + await cache.compact() + }) + }) + + // ============================================================================ + // CRITICAL FIX 6: Phone Number Validation Too Strict + // ============================================================================ + describe('Phone number normalization (CRITICAL FIX)', () => { + test('should handle plain digits', async () => { + const session = 'pn-plain-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('plain@lid', '34691015468') + const result = await cache.get('plain@lid') + + expect(result).toBe('34691015468@s.whatsapp.net') + await cache.close() + }) + + test('should handle international format with plus prefix', async () => { + const session = 'pn-plus-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('plus@lid', '+34691015468') + const result = await cache.get('plus@lid') + + expect(result).toBe('34691015468@s.whatsapp.net') + await cache.close() + }) + + test('should handle phone numbers with spaces', async () => { + const session = 'pn-spaces-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('spaced@lid', '34 691 015 468') + const result = await cache.get('spaced@lid') + + expect(result).toBe('34691015468@s.whatsapp.net') + await cache.close() + }) + + test('should handle phone numbers with dashes and parens', async () => { + const session = 'pn-formatted-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('formatted@lid', '+1 (555) 123-4567') + const result = await cache.get('formatted@lid') + + expect(result).toBe('15551234567@s.whatsapp.net') + await cache.close() + }) + + test('should handle legacy @c.us format', async () => { + const session = 'pn-legacy-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('legacy@lid', '34691015468@c.us') + const result = await cache.get('legacy@lid') + + expect(result).toBe('34691015468@s.whatsapp.net') + await cache.close() + }) + + test('should preserve already formatted @s.whatsapp.net', async () => { + const session = 'pn-already-formatted-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('formatted@lid', '34691015468@s.whatsapp.net') + const result = await cache.get('formatted@lid') + + expect(result).toBe('34691015468@s.whatsapp.net') + await cache.close() + }) + + test('should handle dots as separators', async () => { + const session = 'pn-dots-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + await cache.set('dots@lid', '34.691.015.468') + const result = await cache.get('dots@lid') + + expect(result).toBe('34691015468@s.whatsapp.net') + await cache.close() + }) + + test('should handle mixed format + country code', async () => { + const session = 'pn-mixed-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + // UK number with various formats + await cache.set('uk1@lid', '+44 20 7123 4567') + await cache.set('uk2@lid', '+44-20-7123-4567') + + expect(await cache.get('uk1@lid')).toBe('442071234567@s.whatsapp.net') + expect(await cache.get('uk2@lid')).toBe('442071234567@s.whatsapp.net') + + await cache.close() + }) + + test('should store consistently regardless of input format', async () => { + const session = 'pn-consistent-' + Date.now() + const cache = new HybridLidCache(session, 3600, undefined, mockLogger as any) + await cache.ready() + + // Same number, different formats - should all normalize to same key + await cache.set('same1@lid', '+1234567890') + await cache.set('same2@lid', '1234567890') + await cache.set('same3@lid', '1 234 567 890') + + // All should return the same normalized format + expect(await cache.get('same1@lid')).toBe('1234567890@s.whatsapp.net') + expect(await cache.get('same2@lid')).toBe('1234567890@s.whatsapp.net') + expect(await cache.get('same3@lid')).toBe('1234567890@s.whatsapp.net') + + await cache.close() + }) + }) +}) diff --git a/packages/provider-baileys/__tests__/lidCache.edges.test.ts b/packages/provider-baileys/__tests__/lidCache.edges.test.ts new file mode 100644 index 000000000..12e5fc4ce --- /dev/null +++ b/packages/provider-baileys/__tests__/lidCache.edges.test.ts @@ -0,0 +1,634 @@ +/** + * Edge Case Tests for LID Cache + * Tests boundary conditions, unusual inputs, and stress scenarios + */ + +import { describe, test, expect, beforeEach, afterEach } from '@jest/globals' +import { rm, mkdir } from 'fs/promises' +import { join } from 'path' + +import { HybridLidCache, MemoryLidCache, normalizeLid } from '../src/lidCache' + +// Mock logger to capture events +const createMockLogger = () => ({ + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +}) + +// ============================================================================ +// EDGE CASE: Empty and Minimal Inputs +// ============================================================================ +describe('lidCache EDGE CASES: Empty and Minimal Inputs', () => { + let cache: HybridLidCache + let testDir: string + + beforeEach(async () => { + const session = `edge-empty-${Date.now()}-${Math.random().toString(36).slice(2)}` + testDir = join(process.cwd(), `${session}_sessions`) + await mkdir(testDir, { recursive: true }) + cache = new HybridLidCache(session, 3600, undefined, createMockLogger() as any) + await cache.ready() + }) + + afterEach(async () => { + await cache.close().catch(() => {}) + await rm(testDir, { recursive: true, force: true }).catch(() => {}) + }) + + test('should handle empty string LID', async () => { + await cache.set('', '123@s.whatsapp.net') + const result = await cache.get('') + expect(result).toBeNull() // Empty LID rejected by validation + }) + + test('should handle empty string PN', async () => { + await cache.set('test@lid', '') + const result = await cache.get('test@lid') + expect(result).toBeNull() // Empty PN rejected by validation + }) + + test('should handle whitespace-only LID', async () => { + await cache.set(' ', '123@s.whatsapp.net') + expect(await cache.get(' ')).toBeNull() + }) + + test('should handle whitespace-only PN', async () => { + await cache.set('test@lid', ' ') + expect(await cache.get('test@lid')).toBeNull() + }) + + test('should handle minimum valid LID', async () => { + // Minimum: 1@lid = 5 characters + await cache.set('1@lid', '1234567890@s.whatsapp.net') + expect(await cache.get('1@lid')).toBe('1234567890@s.whatsapp.net') + }) + + test('should handle single digit PN', async () => { + await cache.set('single@lid', '1') + // Single digit passes validation, gets normalized + expect(await cache.get('single@lid')).toBe('1@s.whatsapp.net') + }) +}) + +// ============================================================================ +// EDGE CASE: Extremely Long Inputs +// ============================================================================ +describe('lidCache EDGE CASES: Extremely Long Inputs', () => { + let cache: HybridLidCache + let testDir: string + + beforeEach(async () => { + const session = `edge-long-${Date.now()}-${Math.random().toString(36).slice(2)}` + testDir = join(process.cwd(), `${session}_sessions`) + await mkdir(testDir, { recursive: true }) + cache = new HybridLidCache(session, 3600, undefined, createMockLogger() as any) + await cache.ready() + }) + + afterEach(async () => { + await cache.close().catch(() => {}) + await rm(testDir, { recursive: true, force: true }).catch(() => {}) + }) + + test('should handle very long LID (1000 chars)', async () => { + const longLid = 'a'.repeat(990) + '@lid' + await cache.set(longLid, '123@s.whatsapp.net') + expect(await cache.get(longLid)).toBe('123@s.whatsapp.net') + }) + + test('should handle very long PN (1000 digits)', async () => { + const longPn = '1'.repeat(1000) + await cache.set('longpn@lid', longPn) + const result = await cache.get('longpn@lid') + expect(result).toBe(`${longPn}@s.whatsapp.net`) + }) + + test('should handle LID at NodeCache key size limit', async () => { + // NodeCache has no explicit key limit, but test reasonable boundary + const boundaryLid = '1234567890123456789012345678901234567890@lid' + await cache.set(boundaryLid, '123@s.whatsapp.net') + expect(await cache.get(boundaryLid)).toBe('123@s.whatsapp.net') + }) +}) + +// ============================================================================ +// EDGE CASE: Special Characters and Unicode +// ============================================================================ +describe('lidCache EDGE CASES: Special Characters and Unicode', () => { + let cache: HybridLidCache + let testDir: string + + beforeEach(async () => { + const session = `edge-special-${Date.now()}-${Math.random().toString(36).slice(2)}` + testDir = join(process.cwd(), `${session}_sessions`) + await mkdir(testDir, { recursive: true }) + cache = new HybridLidCache(session, 3600, undefined, createMockLogger() as any) + await cache.ready() + }) + + afterEach(async () => { + await cache.close().catch(() => {}) + await rm(testDir, { recursive: true, force: true }).catch(() => {}) + }) + + test('should handle LID with special characters before @lid', async () => { + // LID format allows any string before @lid + const specialLid = '123:45:67@lid' + await cache.set(specialLid, '123@s.whatsapp.net') + // normalizeLid removes device suffix, so 123:45:67@lid becomes 123@lid + expect(await cache.get(specialLid)).toBe('123@s.whatsapp.net') + }) + + test('should handle unicode in PN (emoji preserved)', async () => { + // Unicode is preserved but digits are extracted + // Current behavior: accepts if has digits, emoji stays in output + await cache.set('unicode@lid', '📱1234567890') + // Note: normalizePn doesn't strip emoji, only +, spaces, -, ., (, ) + // This may return the emoji with digits - implementation detail + const result = await cache.get('unicode@lid') + // Contains the digits at minimum + expect(result).toContain('1234567890') + }) + + test('should handle emoji in LID (if valid format)', async () => { + // Emoji before @lid - technically passes validation but unusual + const emojiLid = '123😀@lid' + await cache.set(emojiLid, '123@s.whatsapp.net') + expect(await cache.get(emojiLid)).toBe('123@s.whatsapp.net') + }) + + test('should handle newline characters in PN', async () => { + await cache.set('newline@lid', '123\n456\n7890') + // Newlines should be stripped during normalization + const result = await cache.get('newline@lid') + expect(result).toBe('1234567890@s.whatsapp.net') + }) + + test('should handle tab characters in PN', async () => { + await cache.set('tab@lid', '123\t456\t7890') + expect(await cache.get('tab@lid')).toBe('1234567890@s.whatsapp.net') + }) +}) + +// ============================================================================ +// EDGE CASE: Device Suffix Variations +// ============================================================================ +describe('lidCache EDGE CASES: Device Suffix Variations', () => { + let cache: HybridLidCache + let testDir: string + + beforeEach(async () => { + const session = `edge-device-${Date.now()}-${Math.random().toString(36).slice(2)}` + testDir = join(process.cwd(), `${session}_sessions`) + await mkdir(testDir, { recursive: true }) + cache = new HybridLidCache(session, 3600, undefined, createMockLogger() as any) + await cache.ready() + }) + + afterEach(async () => { + await cache.close().catch(() => {}) + await rm(testDir, { recursive: true, force: true }).catch(() => {}) + }) + + test('should normalize device suffix :0 through :99', async () => { + const baseLid = '123456789' + const pn = '5555555555@s.whatsapp.net' + + // Set with device 0 + await cache.set(`${baseLid}:0@lid`, pn) + + // Should find with any device suffix + for (let i = 0; i < 100; i++) { + expect(await cache.get(`${baseLid}:${i}@lid`)).toBe(pn) + } + }) + + test('should handle large device numbers', async () => { + await cache.set('123:999999@lid', '555@s.whatsapp.net') + expect(await cache.get('123:1@lid')).toBe('555@s.whatsapp.net') + }) + + test('should handle multi-colon LIDs', async () => { + // Edge case: multiple colons before @lid + await cache.set('a:b:c:1@lid', '111@s.whatsapp.net') + // normalizeLid replaces :\d+(?=@lid$) - only removes last :digits + expect(await cache.get('a:b:c:99@lid')).toBe('111@s.whatsapp.net') + }) + + test('should NOT normalize if no @lid suffix', async () => { + // Regular JIDs should not be normalized + await cache.set('123:45@s.whatsapp.net', '555@s.whatsapp.net') + // This is not a valid LID, so validation may reject it + // but if accepted, should remain as-is + const result = await cache.get('123:45@s.whatsapp.net') + // LID validation requires @lid, so this should be null + expect(result).toBeNull() + }) +}) + +// ============================================================================ +// EDGE CASE: Zero, Null, Undefined, NaN +// ============================================================================ +describe('lidCache EDGE CASES: Zero, Null, Undefined, NaN', () => { + let cache: HybridLidCache + let testDir: string + + beforeEach(async () => { + const session = `edge-null-${Date.now()}-${Math.random().toString(36).slice(2)}` + testDir = join(process.cwd(), `${session}_sessions`) + await mkdir(testDir, { recursive: true }) + cache = new HybridLidCache(session, 3600, undefined, createMockLogger() as any) + await cache.ready() + }) + + afterEach(async () => { + await cache.close().catch(() => {}) + await rm(testDir, { recursive: true, force: true }).catch(() => {}) + }) + + test('should handle null as LID', async () => { + await (cache.set as any)(null, '123@s.whatsapp.net') + expect(await (cache.get as any)(null)).toBeNull() + }) + + test('should handle undefined as LID', async () => { + await (cache.set as any)(undefined, '123@s.whatsapp.net') + expect(await (cache.get as any)(undefined)).toBeNull() + }) + + test('should handle null as PN', async () => { + await (cache.set as any)('test@lid', null) + expect(await cache.get('test@lid')).toBeNull() + }) + + test('should handle undefined as PN', async () => { + await (cache.set as any)('test@lid', undefined) + expect(await cache.get('test@lid')).toBeNull() + }) + + test('should handle number 0 as LID', async () => { + await (cache.set as any)(0, '123@s.whatsapp.net') + expect(await (cache.get as any)(0)).toBeNull() + }) + + test('should handle number 0 as PN', async () => { + await (cache.set as any)('test@lid', 0) + // 0 as PN - passes validation (typeof === 'number' fails first check) + expect(await cache.get('test@lid')).toBeNull() + }) + + test('should handle empty object as LID', async () => { + await (cache.set as any)({}, '123@s.whatsapp.net') + expect(await (cache.get as any)({})).toBeNull() + }) + + test('should handle empty array as LID', async () => { + await (cache.set as any)([], '123@s.whatsapp.net') + expect(await (cache.get as any)([])).toBeNull() + }) +}) + +// ============================================================================ +// EDGE CASE: Boolean and Type Coercion +// ============================================================================ +describe('lidCache EDGE CASES: Boolean and Type Coercion', () => { + let cache: HybridLidCache + let testDir: string + + beforeEach(async () => { + const session = `edge-bool-${Date.now()}-${Math.random().toString(36).slice(2)}` + testDir = join(process.cwd(), `${session}_sessions`) + await mkdir(testDir, { recursive: true }) + cache = new HybridLidCache(session, 3600, undefined, createMockLogger() as any) + await cache.ready() + }) + + afterEach(async () => { + await cache.close().catch(() => {}) + await rm(testDir, { recursive: true, force: true }).catch(() => {}) + }) + + test('should handle true as LID', async () => { + await (cache.set as any)(true, '123@s.whatsapp.net') + expect(await (cache.get as any)(true)).toBeNull() + }) + + test('should handle false as LID', async () => { + await (cache.set as any)(false, '123@s.whatsapp.net') + expect(await (cache.get as any)(false)).toBeNull() + }) + + test('should handle string "true" as LID', async () => { + // "true" includes '@lid'? No. Should be rejected. + await cache.set('true', '123@s.whatsapp.net') + expect(await cache.get('true')).toBeNull() + }) + + test('should handle string "false" as LID', async () => { + await cache.set('false', '123@s.whatsapp.net') + expect(await cache.get('false')).toBeNull() + }) + + test('should handle string "null" as LID', async () => { + await cache.set('null', '123@s.whatsapp.net') + expect(await cache.get('null')).toBeNull() + }) + + test('should handle string "undefined" as LID', async () => { + await cache.set('undefined', '123@s.whatsapp.net') + expect(await cache.get('undefined')).toBeNull() + }) +}) + +// ============================================================================ +// EDGE CASE: Rapid Operations Stress Test +// ============================================================================ +describe('lidCache EDGE CASES: Rapid Operations Stress Test', () => { + let cache: HybridLidCache + let testDir: string + + beforeEach(async () => { + const session = `edge-rapid-${Date.now()}-${Math.random().toString(36).slice(2)}` + testDir = join(process.cwd(), `${session}_sessions`) + await mkdir(testDir, { recursive: true }) + cache = new HybridLidCache(session, 3600, undefined, createMockLogger() as any) + await cache.ready() + }) + + afterEach(async () => { + await cache.close().catch(() => {}) + await rm(testDir, { recursive: true, force: true }).catch(() => {}) + }) + + test('should handle 1000 rapid sets', async () => { + const promises = [] + for (let i = 0; i < 1000; i++) { + promises.push(cache.set(`rapid${i}@lid`, `${i}@s.whatsapp.net`)) + } + await Promise.all(promises) + + // Verify all were stored + for (let i = 0; i < 1000; i++) { + expect(await cache.get(`rapid${i}@lid`)).toBe(`${i}@s.whatsapp.net`) + } + }) + + test('should handle rapid get/set interleaved', async () => { + const operations = [] + for (let i = 0; i < 100; i++) { + operations.push(cache.set(`inter${i}@lid`, `val${i}@s.whatsapp.net`)) + operations.push(cache.get(`inter${i}@lid`)) + } + await Promise.all(operations) + + // All should be set + for (let i = 0; i < 100; i++) { + expect(await cache.get(`inter${i}@lid`)).toBe(`val${i}@s.whatsapp.net`) + } + }) + + test('should handle set same key 100 times rapidly', async () => { + const promises = [] + for (let i = 0; i < 100; i++) { + promises.push(cache.set('same@lid', `val${i}@s.whatsapp.net`)) + } + await Promise.all(promises) + + // Should have last value (or any, due to race) + const result = await cache.get('same@lid') + expect(result).toMatch(/^val\d+@s\.whatsapp\.net$/) + }) + + test('should handle rapid clear and reuse', async () => { + for (let i = 0; i < 10; i++) { + await cache.set(`cycle${i}@lid`, `value${i}@s.whatsapp.net`) + await cache.clear() + } + + // Should be empty after final clear + expect(await cache.get('cycle0@lid')).toBeNull() + }) +}) + +// ============================================================================ +// EDGE CASE: Simultaneous Instances (Isolation) +// ============================================================================ +describe('lidCache EDGE CASES: Simultaneous Instances', () => { + test('should isolate separate cache instances', async () => { + const session1 = `iso1-${Date.now()}` + const session2 = `iso2-${Date.now()}` + + const cache1 = new HybridLidCache(session1, 3600) + const cache2 = new HybridLidCache(session2, 3600) + + await cache1.ready() + await cache2.ready() + + await cache1.set('shared@lid', '111@s.whatsapp.net') + await cache2.set('shared@lid', '222@s.whatsapp.net') + + expect(await cache1.get('shared@lid')).toBe('111@s.whatsapp.net') + expect(await cache2.get('shared@lid')).toBe('222@s.whatsapp.net') + + await cache1.close() + await cache2.close() + + // Cleanup + await rm(join(process.cwd(), `${session1}_sessions`), { recursive: true, force: true }) + await rm(join(process.cwd(), `${session2}_sessions`), { recursive: true, force: true }) + }) + + test('should handle same session file accessed by two instances (last write wins)', async () => { + const session = `shared-${Date.now()}` + + const cache1 = new HybridLidCache(session, 3600) + await cache1.ready() + await cache1.set('conflict@lid', 'first@s.whatsapp.net') + await cache1.flushToDisk() + + // Small delay to ensure flush + await new Promise((r) => setTimeout(r, 50)) + + const cache2 = new HybridLidCache(session, 3600) + await cache2.ready() + await cache2.set('conflict@lid', 'second@s.whatsapp.net') + await cache2.flushToDisk() + await cache2.close() + + // Reopen to verify last write + const cache3 = new HybridLidCache(session, 3600) + await cache3.ready() + const result = await cache3.get('conflict@lid') + await cache3.close() + + expect(result).toBe('second@s.whatsapp.net') + + await cache1.close() + await rm(join(process.cwd(), `${session}_sessions`), { recursive: true, force: true }) + }) +}) + +// ============================================================================ +// EDGE CASE: MemoryLidCache Specific +// ============================================================================ +describe('lidCache EDGE CASES: MemoryLidCache Specific', () => { + test('should not persist to disk (memory only)', async () => { + const cache = new MemoryLidCache(3600) + await cache.set('memory@lid', '123@s.whatsapp.net') + + // Create new instance - should not have data + const cache2 = new MemoryLidCache(3600) + expect(await cache2.get('memory@lid')).toBeNull() + }) + + test('should respect TTL in memory cache', async () => { + const cache = new MemoryLidCache(1) // 1 second TTL + await cache.set('ttl@lid', '123@s.whatsapp.net') + + expect(await cache.get('ttl@lid')).toBe('123@s.whatsapp.net') + + // Wait for TTL + await new Promise((r) => setTimeout(r, 1100)) + + expect(await cache.get('ttl@lid')).toBeNull() + }) + + test('MemoryLidCache should handle all edge cases same as Hybrid', async () => { + const cache = new MemoryLidCache(3600) + + // Empty + await cache.set('', '123@s.whatsapp.net') + expect(await cache.get('')).toBeNull() + + // Null + await (cache.set as any)(null, '123@s.whatsapp.net') + expect(await (cache.get as any)(null)).toBeNull() + + // Long + const longLid = 'a'.repeat(1000) + '@lid' + await cache.set(longLid, '123@s.whatsapp.net') + expect(await cache.get(longLid)).toBe('123@s.whatsapp.net') + + // Unicode + await cache.set('emoji😀@lid', '123@s.whatsapp.net') + expect(await cache.get('emoji😀@lid')).toBe('123@s.whatsapp.net') + }) +}) + +// ============================================================================ +// EDGE CASE: File System Edge Cases +// ============================================================================ +describe('lidCache EDGE CASES: File System', () => { + test('should handle very long session name (255 chars)', async () => { + const longName = 'a'.repeat(245) // + '_sessions' = 255 + const cache = new HybridLidCache(longName, 3600) + await cache.ready() + + await cache.set('test@lid', '123@s.whatsapp.net') + expect(await cache.get('test@lid')).toBe('123@s.whatsapp.net') + + await cache.close() + await rm(join(process.cwd(), `${longName}_sessions`), { recursive: true, force: true }) + }) + + test('should handle session name with dots', async () => { + const dotName = 'session.v1.2.3' + const cache = new HybridLidCache(dotName, 3600) + await cache.ready() + + await cache.set('test@lid', '123@s.whatsapp.net') + await cache.flushToDisk() + await cache.close() + + // Verify file was created + const fs = await import('fs/promises') + const filePath = join(process.cwd(), `${dotName}_sessions`, 'lid-cache.json') + const stats = await fs.stat(filePath) + expect(stats.isFile()).toBe(true) + + await rm(join(process.cwd(), `${dotName}_sessions`), { recursive: true, force: true }) + }) +}) + +// ============================================================================ +// EDGE CASE: normalizeLid Function Directly +// ============================================================================ +describe('lidCache EDGE CASES: normalizeLid Function', () => { + test('should return non-lid strings unchanged', () => { + expect(normalizeLid('123@s.whatsapp.net')).toBe('123@s.whatsapp.net') + expect(normalizeLid('123@c.us')).toBe('123@c.us') + expect(normalizeLid('random string')).toBe('random string') + expect(normalizeLid('')).toBe('') + }) + + test('should handle LID without device suffix', () => { + expect(normalizeLid('123456789@lid')).toBe('123456789@lid') + }) + + test('should remove device suffix from LID', () => { + expect(normalizeLid('123456789:0@lid')).toBe('123456789@lid') + expect(normalizeLid('123456789:99@lid')).toBe('123456789@lid') + expect(normalizeLid('123456789:999999@lid')).toBe('123456789@lid') + }) + + test('should only remove last device suffix', () => { + // Multiple colons - only last :digits@lid is removed + expect(normalizeLid('a:b:c:1@lid')).toBe('a:b:c@lid') + }) + + test('should handle LID-like strings that are not LIDs', () => { + // :digits but no @lid + expect(normalizeLid('123:45@s.whatsapp.net')).toBe('123:45@s.whatsapp.net') + + // @lid but no prefix + expect(normalizeLid('@lid')).toBe('@lid') // Invalid but passes + }) + + test('should handle undefined/null gracefully', () => { + expect(normalizeLid(undefined as any)).toBe(undefined) + expect(normalizeLid(null as any)).toBe(null) + }) +}) + +// ============================================================================ +// EDGE CASE: Concurrency with Close +// ============================================================================ +describe('lidCache EDGE CASES: Concurrency with Close', () => { + test('should handle set during close gracefully', async () => { + const session = `close-race-${Date.now()}` + const cache = new HybridLidCache(session, 3600) + await cache.ready() + + // Start close + const closePromise = cache.close() + + // Try to set during close - should not throw + await expect(cache.set('late@lid', '123@s.whatsapp.net')).resolves.not.toThrow() + + await closePromise + + await rm(join(process.cwd(), `${session}_sessions`), { recursive: true, force: true }) + }) + + test('should handle get during close gracefully', async () => { + const session = `get-close-${Date.now()}` + const cache = new HybridLidCache(session, 3600) + await cache.ready() + await cache.set('existing@lid', '123@s.whatsapp.net') + + // Start close + const closePromise = cache.close() + + // Try to get during close + const result = await cache.get('existing@lid') + + await closePromise + + // May return value or null depending on timing + expect(result === '123@s.whatsapp.net' || result === null).toBe(true) + + await rm(join(process.cwd(), `${session}_sessions`), { recursive: true, force: true }) + }) +}) diff --git a/packages/provider-baileys/src/bailey.ts b/packages/provider-baileys/src/bailey.ts index 2029f9882..f7af1164f 100644 --- a/packages/provider-baileys/src/bailey.ts +++ b/packages/provider-baileys/src/bailey.ts @@ -35,6 +35,16 @@ import { WAVersion, WABrowserDescription, } from './baileyWrapper' +import { + createLidCache, + extractAndCacheLidFromMessage, + resolveLidToPn, + asLidJid, + isMessageContext, + type LidCache, + type MessageContext, + type LidJid, +} from './lidCache' import { releaseTmp } from './releaseTmp' import type { BaileyGlobalVendorArgs } from './type' import { baileyGenerateImage, baileyCleanNumber, baileyIsValidNumber, emptyDirSessions } from './utils' @@ -72,6 +82,9 @@ class BaileysProvider extends ProviderClass { private idsDuplicates = [] private mapSet = new Set() + /** LID → Phone Number cache for privacy-preserving identifier resolution */ + private lidCache: LidCache + constructor(args: Partial) { super() @@ -119,6 +132,9 @@ class BaileysProvider extends ProviderClass { this.globalVendorArgs = { ...this.globalVendorArgs, ...args } + // Initialize LID cache (hybrid file+memory or memory-only based on config) + this.lidCache = this.initializeLidCache() + this.setupCleanupHandlers() this.setupPeriodicCleanup() } @@ -201,6 +217,13 @@ class BaileysProvider extends ProviderClass { this.messageCache = undefined } + // Cerrar LID cache (flush final a disco) + if (this.lidCache?.close) { + this.lidCache.close().catch((err) => { + this.logger.error(`[${new Date().toISOString()}] Error closing LID cache:`, err) + }) + } + this.mapSet.clear() this.idsDuplicates.length = 0 @@ -472,6 +495,9 @@ class BaileysProvider extends ProviderClass { this.messageCache?.set(`msg:${messageCtx.key.id}`, messageCtx.message) } + // Aprender mapeo LID→PN desde mensaje entrante (async, no bloqueante) + this.cacheLidFromMessage(messageCtx).catch(() => {}) + if ( messageCtx?.messageStubParameters?.length && messageCtx.messageStubParameters[0].includes('absent') @@ -759,6 +785,37 @@ class BaileysProvider extends ProviderClass { return orderDetails } + // ============================================================================= + // LID CACHE INTEGRATION + // ============================================================================= + + /** + * Inicializa el caché LID/PN usando el factory. + * @returns Instancia de LidCache configurada según globalVendorArgs + */ + private initializeLidCache(): LidCache { + return createLidCache({ + strategy: this.globalVendorArgs.lidCache, + sessionName: this.globalVendorArgs.name, + ttlSeconds: this.globalVendorArgs.lidCacheTtl, + logger: this.logger, + }) + } + + /** + * Delegates to the standalone utility for caching LID→PN from messages. + * This wrapper maintains the method signature for internal use while + * leveraging the exported function for reusability. + */ + private async cacheLidFromMessage(messageCtx: MessageContext | unknown): Promise { + // Type guard: ensure the message context has the expected structure + if (!isMessageContext(messageCtx)) { + this.logger.debug?.('Invalid message context for LID caching') + return + } + return extractAndCacheLidFromMessage(this.lidCache, messageCtx) + } + /** * Accede al lidMapping del signalRepository de Baileys. */ @@ -780,16 +837,23 @@ class BaileysProvider extends ProviderClass { } /** - * Obtener número de teléfono (PN) para un LID (Local Identifier) + * Obtener número de teléfono (PN) para un LID (Local Identifier). + * Delegates to the standalone utility with Baileys lidMapping as fallback. + * * @param lid - JID con formato '16424005304394@lid' + * @returns Phone number en formato '1234567890@s.whatsapp.net', o null si no se resuelve */ - getPNForLID = async (lid: string): Promise => { - try { - return (await this.lidMapping?.getPNForLID?.(lid)) ?? null - } catch (e) { - this.logger.log(`[${new Date().toISOString()}] Error getting PN for LID:`, e) - return null - } + getPNForLID = async (lid: string | LidJid): Promise => { + // Normalize to branded type if valid + const lidJid = typeof lid === 'string' ? asLidJid(lid) : lid + if (!lidJid) return null + + return resolveLidToPn( + this.lidCache, + (id) => this.lidMapping?.getPNForLID?.(id) ?? Promise.resolve(null), + this.logger, + lidJid + ) } /** diff --git a/packages/provider-baileys/src/index.ts b/packages/provider-baileys/src/index.ts index c67ed6e64..99c18de87 100644 --- a/packages/provider-baileys/src/index.ts +++ b/packages/provider-baileys/src/index.ts @@ -1,4 +1,5 @@ import { baileyCleanNumber } from './utils' export * from './bailey' +export * from './lidCache' export { baileyCleanNumber } diff --git a/packages/provider-baileys/src/lidCache.ts b/packages/provider-baileys/src/lidCache.ts new file mode 100644 index 000000000..637148ce5 --- /dev/null +++ b/packages/provider-baileys/src/lidCache.ts @@ -0,0 +1,1238 @@ +/** + * @fileoverview LID (Local Identifier) Cache for WhatsApp Baileys Provider + * + * This module provides a caching layer for mapping WhatsApp Local Identifiers (LIDs) + * to Phone Numbers (PNs). LIDs are privacy-preserving identifiers used by WhatsApp + * that don't reveal the user's actual phone number. + * + * ## Architecture + * + * The cache uses a hybrid memory+file approach: + * - **Hot path**: In-memory lookups via NodeCache (O(1), ~1μs) + * - **Persistence**: JSON file for cross-restart durability + * - **Strategy**: Write-through to memory, async flush to disk every 30s + * + * ## Key Features + * + * - **Zero-config**: Works out of the box with sensible defaults + * - **Device suffix normalization**: `123:45@lid` and `123:99@lid` resolve to same entry + * - **Phone number normalization**: Accepts `+123 456-7890`, `1234567890@c.us`, etc. + * - **Security**: File permissions 0o600, PII masking in logs + * - **Resilience**: Corrupted files auto-rebuild, flush failures logged + * + * ## Usage + * + * ```typescript + * // Default: Hybrid (memory + file) + * const cache = new HybridLidCache('my-bot', 86400 * 7) // 7 day TTL + * await cache.ready() + * + * await cache.set('123456789:45@lid', '+34 691 015 468') + * const pn = await cache.get('123456789:99@lid') // Returns '34691015468@s.whatsapp.net' + * + * await cache.close() // Persists to disk + * ``` + * + * @module lidCache + * @author BuilderBot Team + * @since 1.4.2 + */ + +import type { Console } from 'console' +import { writeFile, readFile, access, mkdir, stat, unlink } from 'fs/promises' +import NodeCache from 'node-cache' +import { dirname, join } from 'path' + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +/** File format version for migrations */ +const CACHE_FILE_VERSION = 1 + +/** Default TTL: 7 days (seconds) */ +const DEFAULT_TTL_SECONDS = 86400 * 7 + +/** Auto-flush interval: 30 seconds (milliseconds) */ +const DEFAULT_FLUSH_INTERVAL_MS = 30000 + +/** Compact file when exceeds 10 MB */ +const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 + +/** Compact when more than 10k entries */ +const COMPACT_AT_ENTRIES = 10000 + +/** Disable persistence after 10 consecutive flush failures */ +const MAX_FLUSH_FAILURES = 10 + +/** Unix file permissions: owner read/write only (0o600) */ +const FILE_PERMISSIONS = 0o600 + +// ============================================================================= +// BRANDED TYPES (Compile-time Safety) +// ============================================================================= + +/** + * Branded type for WhatsApp Local Identifier (LID). + * Ensures compile-time distinction from regular strings. + * + * @example + * ```typescript + * const lid = '123456789@lid' as LidJid + * const pn = resolveLidToPn(cache, fallback, logger, lid) // OK + * resolveLidToPn(cache, fallback, logger, '123456789@lid') // Error: not branded + * ``` + */ +export type LidJid = string & { readonly __brand: 'LidJid' } + +/** + * Branded type for Phone Number JID. + * Format: `1234567890@s.whatsapp.net` + */ +export type PnJid = string & { readonly __brand: 'PnJid' } + +/** + * Helper to brand a string as LidJid (runtime check). + * Returns null if the string is not a valid LID. + */ +export function asLidJid(value: string): LidJid | null { + return isValidLid(value) ? (normalizeLid(value) as LidJid) : null +} + +/** + * Helper to brand a string as PnJid (runtime check). + * Returns null if the string is not a valid phone number JID. + */ +export function asPnJid(value: string): PnJid | null { + if (!isValidPn(value)) return null + const normalized = normalizePn(value) + return normalized ? (normalized as PnJid) : null +} + +// ============================================================================= +// TYPES & INTERFACES +// ============================================================================= + +/** + * Interface for LID cache implementations. + * + * Defines the contract for any cache that maps WhatsApp LIDs to phone numbers. + * Both {@link HybridLidCache} and {@link MemoryLidCache} implement this interface. + * + * @example + * ```typescript + * // Custom implementation (e.g., Redis) + * class RedisLidCache implements LidCache { + * async get(lid: LidJid): Promise { + * return redis.get(`lid:${lid}`) as Promise + * } + * // ... other methods + * } + * ``` + */ +export interface LidCache { + /** + * Retrieves the phone number for a given LID. + * + * @param lid - WhatsApp Local Identifier (e.g., '123456789@lid' or '123456789:45@lid') + * @returns Phone number in format '1234567890@s.whatsapp.net', or null if not found/invalid + */ + get(lid: LidJid | string): Promise + + /** + * Stores a LID → phone number mapping. + * + * @param lid - WhatsApp Local Identifier + * @param pn - Phone number (any format: +123, 123, 123@c.us, 123@s.whatsapp.net) + * @returns Promise that resolves when the value is stored in memory + * + * @remarks + * - PN is normalized to `123@s.whatsapp.net` format before storage + * - LID device suffix is normalized (e.g., `123:45@lid` → `123@lid`) + * - Invalid inputs are silently rejected (no throw) + */ + set(lid: LidJid | string, pn: PnJid | string): Promise + + /** + * Checks if a LID exists in the cache. + * + * @param lid - WhatsApp Local Identifier + * @returns true if the LID exists and hasn't expired + */ + has(lid: LidJid | string): Promise + + /** + * Clears all entries from the cache. + * + * @remarks Also triggers immediate flush to disk (if HybridLidCache) + */ + clear(): Promise + + /** + * Closes the cache, releasing resources and triggering final persistence. + * + * @remarks After close(), all operations return null/void. Call only once. + */ + close?(): Promise +} + +/** + * Internal cache entry structure. + * + * Stored in the JSON file for persistence, includes timestamp for TTL validation + * on restart (since NodeCache's internal TTL is memory-only). + */ +interface CacheEntry { + /** Phone number in normalized format `123@s.whatsapp.net` */ + pn: string + + /** Unix timestamp (ms) of last access or write */ + ts: number +} + +/** + * On-disk file format for cache persistence. + * + * @internal + */ +interface CacheFileData { + /** File format version for future migrations */ + version: number + + /** Map of normalized LID → cache entry */ + entries: Record +} + +// ============================================================================= +// TYPE GUARDS +// ============================================================================= + +/** + * Validates if a value is a valid LID string. + * + * A valid LID: + * - Is a string + * - Contains `@lid` suffix + * - Has minimum length of 5 (e.g., `1@lid`) + * + * @param value - Value to validate + * @returns Type predicate: true if value is a valid LID string + * + * @example + * ```typescript + * isValidLid('123456789@lid') // true + * isValidLid('123456789:45@lid') // true + * isValidLid('123@c.us') // false + * isValidLid('') // false + * isValidLid(null) // false + * ``` + */ +function isValidLid(value: unknown): value is string { + return typeof value === 'string' && value.length >= 5 && value.includes('@lid') +} + +/** + * Validates if a value is a valid phone number string. + * + * Accepts: + * - Bare digits: `1234567890` + * - International: `+1234567890` + * - Formatted: `+1 (555) 123-4567` + * - WhatsApp JIDs: `123@s.whatsapp.net`, `123@c.us` + * + * @param value - Value to validate + * @returns true if value contains at least one digit or is already a JID + */ +function isValidPn(value: unknown): value is string { + if (typeof value !== 'string') return false + if (value.length === 0) return false + + // Accept if already has WhatsApp JID format + if (value.includes('@s.whatsapp.net') || value.includes('@c.us')) return true + + // Accept if contains any digit (will be cleaned during normalization) + const hasDigits = /\d/.test(value) + return hasDigits +} + +/** + * Validates if a value matches the CacheEntry interface. + * + * @internal + */ +function isCacheEntry(value: unknown): value is CacheEntry { + if (typeof value !== 'object' || value === null) return false + const entry = value as Partial + return typeof entry.pn === 'string' && typeof entry.ts === 'number' && !isNaN(entry.ts) +} + +/** + * Validates if a value matches the CacheFileData interface. + * + * @internal + */ +function isCacheFileData(value: unknown): value is CacheFileData { + if (typeof value !== 'object' || value === null) return false + const data = value as Partial + return typeof data.version === 'number' && typeof data.entries === 'object' && data.entries !== null +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/** + * Normalizes a LID by removing the device suffix. + * + * WhatsApp sends LIDs with device identifiers (e.g., `:45`, `:99`) that vary + * based on the user's device. The same contact will have different suffixes + * on phone vs web vs tablet. This function strips the suffix for consistent + * caching. + * + * @param lid - LID to normalize (e.g., '123456789:45@lid') + * @returns Normalized LID (e.g., '123456789@lid'), or original if invalid + * + * @example + * ```typescript + * normalizeLid('123456789:45@lid') // '123456789@lid' + * normalizeLid('123456789:99@lid') // '123456789@lid' + * normalizeLid('123456789@lid') // '123456789@lid' (no change) + * normalizeLid('123456789@c.us') // '123456789@c.us' (no change, not a LID) + * normalizeLid('invalid') // 'invalid' (no change, invalid) + * ``` + */ +export function normalizeLid(lid: string): string { + if (!isValidLid(lid)) return lid + // Remove :digits before @lid suffix only + return lid.replace(/:\d+(?=@lid$)/, '') +} + +/** + * Sanitizes a session name for safe filesystem usage. + * + * Prevents path traversal and invalid filename characters. + * + * @internal + * @param name - Raw session name + * @returns Sanitized name safe for use in filenames + */ +function sanitizeSessionName(name: string): string { + // Replace dangerous characters for filenames + return name.replace(/[\\/:"*?<>|]/g, '_').replace(/\.{2,}/g, '_') +} + +/** + * Normalizes a phone number to consistent `@s.whatsapp.net` format. + * + * Handles multiple input formats commonly encountered from WhatsApp: + * - Bare digits: `1234567890` → `1234567890@s.whatsapp.net` + * - International: `+1234567890` → `1234567890@s.whatsapp.net` + * - Spaced: `123 456 7890` → `1234567890@s.whatsapp.net` + * - Formatted: `+1 (555) 123-4567` → `15551234567@s.whatsapp.net` + * - Legacy: `123@c.us` → `123@s.whatsapp.net` + * - Already normalized: `123@s.whatsapp.net` → `123@s.whatsapp.net` (no change) + * + * @param pn - Phone number in any format + * @returns Normalized phone number, or original if cannot be normalized + * + * @example + * ```typescript + * normalizePn('34691015468') // '34691015468@s.whatsapp.net' + * normalizePn('+34691015468') // '34691015468@s.whatsapp.net' + * normalizePn('34 691 015 468') // '34691015468@s.whatsapp.net' + * normalizePn('+1 (555) 123-4567') // '15551234567@s.whatsapp.net' + * normalizePn('34691015468@c.us') // '34691015468@s.whatsapp.net' + * normalizePn('34691015468@s.whatsapp.net') // '34691015468@s.whatsapp.net' + * normalizePn('not-a-number') // 'not-a-number' (unchanged) + * ``` + */ +function normalizePn(pn: string): string { + if (!isValidPn(pn)) return pn + + // Already in WhatsApp JID format + if (pn.includes('@s.whatsapp.net')) return pn + + // Legacy format conversion + if (pn.includes('@c.us')) { + const digits = pn.replace('@c.us', '') + if (/^\d+$/.test(digits)) return `${digits}@s.whatsapp.net` + } + + // Clean common formatting: +, spaces, dashes, dots, parentheses + const cleaned = pn.replace(/^\+/, '').replace(/[\s\-\.\(\)]/g, '') + + // Validate we now have only digits + if (/^\d+$/.test(cleaned)) { + return `${cleaned}@s.whatsapp.net` + } + + // Cannot normalize, return original + return pn +} + +/** + * Masks a LID for safe logging (PII protection). + * + * Replaces middle characters with `***` to prevent logging full identifiers + * while keeping enough for debugging. + * + * @internal + * @param lid - LID to mask + * @returns Masked LID (e.g., '123456789@lid' → '123***@lid') + */ +function maskLid(lid: string): string { + if (lid.length < 8) return '***@lid' + return lid.slice(0, 3) + '***' + lid.slice(lid.indexOf('@')) +} + +// ============================================================================= +// HYBRID CACHE (Memory + File) +// ============================================================================= + +/** + * Hybrid LID cache implementation with memory hot-path and file persistence. + * + * This is the recommended production implementation, providing: + * - **Speed**: O(1) in-memory lookups via NodeCache (~1μs) + * - **Durability**: Automatic persistence to JSON file every 30s + * - **Resilience**: Survives process restarts, handles corrupted files gracefully + * - **Security**: File permissions 0o600 (owner read/write only) + * + * ## File Location + * + * Files are stored at: `{cwd}/{sessionName}_sessions/lid-cache.json` + * + * The session name is sanitized to prevent path traversal attacks. + * + * ## Configuration + * + * | Option | Default | Description | + * |--------|---------|-------------| + * | `sessionName` | required | Unique name for this bot instance | + * | `ttlSeconds` | 604800 (7 days) | Time-to-live for cache entries | + * | `basePath` | `process.cwd()` | Directory for session files | + * | `logger` | `console` | Logger for operational events | + * + * @example + * ```typescript + * // Basic usage + * const cache = new HybridLidCache('my-bot') + * await cache.ready() + * + * // Custom TTL and logger + * const cache = new HybridLidCache('my-bot', 86400 * 30, undefined, winstonLogger) + * + * // Custom base path + * const cache = new HybridLidCache('my-bot', 86400, '/var/lib/bot') + * ``` + */ +export class HybridLidCache implements LidCache { + /** In-memory cache via NodeCache */ + private memory: NodeCache + + /** Absolute path to the JSON persistence file */ + private filePath: string + + /** True if memory has unwritten changes */ + private dirty = false + + /** True if flushToDisk() is currently running (prevents concurrent flushes) */ + private flushing = false + + /** Interval handle for periodic auto-flush */ + private flushInterval?: NodeJS.Timeout + + /** TTL in seconds (for expiry calculations on load) */ + private readonly ttlSeconds: number + + /** Logger for operational events (defaults to console) */ + private readonly logger: Console + + /** Consecutive flush failure count (disables persistence after threshold) */ + private consecutiveFlushFailures = 0 + + /** Promise tracking initial file load (prevents race conditions) */ + private loadPromise: Promise + + /** True after close() has been called */ + private isClosed = false + + /** + * Creates a new HybridLidCache instance. + * + * @param sessionName - Unique identifier for this cache instance (used in filename) + * @param ttlSeconds - Time-to-live for cache entries (default: 7 days) + * @param basePath - Base directory for session files (default: process.cwd()) + * @param logger - Logger instance for operational events (default: console) + * + * @throws Error if sessionName is empty or not a string + * @throws Error if ttlSeconds is less than 60 + * + * @remarks + * The constructor starts async file loading and sets up periodic auto-flush. + * Use {@link ready()} to wait for initial load completion before operations + * that require data from previous runs. + */ + constructor(sessionName: string, ttlSeconds: number = DEFAULT_TTL_SECONDS, basePath?: string, logger?: Console) { + // Validate required arguments + if (!sessionName || typeof sessionName !== 'string') { + throw new Error('sessionName is required and must be a string') + } + if (ttlSeconds < 60) { + throw new Error('ttlSeconds must be at least 60 (1 minute)') + } + + this.ttlSeconds = ttlSeconds + this.logger = logger || console + + const sanitizedSession = sanitizeSessionName(sessionName) + const cwd = basePath || process.cwd() + this.filePath = join(cwd, `${sanitizedSession}_sessions`, 'lid-cache.json') + + // Initialize in-memory cache with TTL + this.memory = new NodeCache({ + stdTTL: ttlSeconds, + useClones: false, // Performance: don't clone objects + deleteOnExpire: true, // Auto-cleanup expired entries + }) + + // Start async file load (tracked to prevent race conditions) + this.loadPromise = this.loadFromDisk() + + // Setup periodic auto-flush (every 30s) + this.flushInterval = setInterval(() => { + if (!this.isClosed) { + this.flushToDisk().catch((err) => { + this.logger.error('[LID Cache] Periodic flush failed:', err) + }) + } + }, DEFAULT_FLUSH_INTERVAL_MS) + + // Unref interval so it doesn't block process exit (important for tests) + if (this.flushInterval.unref) { + this.flushInterval.unref() + } + } + + /** + * Waits for the initial file load to complete. + * + * Use this method before operations that depend on data from previous runs: + * - Before checking if a LID exists from a previous session + * - In tests to ensure deterministic state + * - During cache warming procedures + * + * @returns Promise that resolves when initial load completes + * + * @example + * ```typescript + * const cache = new HybridLidCache('my-bot') + * await cache.ready() // Wait for any existing data to load + * const pn = await cache.get('existing@lid') // Now safe to access + * ``` + */ + async ready(): Promise { + await this.loadPromise + } + + /** + * Retrieves the phone number for a given LID. + * + * @param lid - WhatsApp Local Identifier (e.g., '123456789:45@lid') + * @returns Phone number in format '1234567890@s.whatsapp.net', or null if: + * - Cache is closed + * - LID is invalid (doesn't match `*@lid` pattern) + * - LID not found in cache + * - Entry has expired + */ + async get(lid: string): Promise { + if (this.isClosed) return null + if (!isValidLid(lid)) return null + + const normalized = normalizeLid(lid) + const value = this.memory.get(normalized) + + if (!value) return null + + // Refresh LRU timestamp (extends implicit TTL) + this.memory.set(normalized, value) + + return value + } + + /** + * Stores a LID → phone number mapping. + * + * Both the LID and phone number are normalized before storage: + * - LID: Device suffix removed (`123:45@lid` → `123@lid`) + * - PN: Formatted to `123@s.whatsapp.net` + * + * @param lid - WhatsApp Local Identifier + * @param pn - Phone number in any accepted format + * @returns Promise that resolves immediately (memory write is synchronous) + * + * @remarks + * - Invalid inputs are silently rejected (no throw) + * - The `dirty` flag is set, triggering async flush to disk within 30s + * - If the cache is closed, this is a no-op + */ + async set(lid: string, pn: string): Promise { + if (this.isClosed) return + if (!isValidLid(lid)) { + this.logger.debug?.('[LID Cache] Rejected invalid LID:', maskLid(String(lid))) + return + } + if (!isValidPn(pn)) { + this.logger.debug?.('[LID Cache] Rejected invalid PN:', pn) + return + } + + const normalizedLid = normalizeLid(lid) + const normalizedPn = normalizePn(pn) + + this.memory.set(normalizedLid, normalizedPn) + this.dirty = true + } + + /** + * Checks if a LID exists in the cache. + * + * @param lid - WhatsApp Local Identifier + * @returns true if: + * - LID is valid + * - Cache is open + * - Entry exists and hasn't expired + * + * @example + * ```typescript + * if (await cache.has('123@lid')) { + * const pn = await cache.get('123@lid') + * // ... + * } + * ``` + */ + async has(lid: string): Promise { + if (this.isClosed) return false + if (!isValidLid(lid)) return false + + const normalized = normalizeLid(lid) + return this.memory.has(normalized) + } + + /** + * Clears all entries from the cache. + * + * @remarks + * - Immediately clears memory + * - Triggers synchronous flush to disk (empty file) + * - Logs the operation at info level + */ + async clear(): Promise { + if (this.isClosed) return + this.memory.flushAll() + this.dirty = true + this.logger.info?.('[LID Cache] Cache cleared') + await this.flushToDisk() + } + + /** + * Closes the cache, releasing resources and triggering final persistence. + * + * This method: + * 1. Stops the auto-flush interval + * 2. Waits for any in-progress file load + * 3. Performs a final flush to disk + * 4. Closes the NodeCache instance + * 5. Marks the cache as closed + * + * @returns Promise that resolves when cleanup is complete + * + * @remarks + * - After close(), all operations return null/void + * - Safe to call multiple times (subsequent calls are no-ops) + * - Flush errors are logged but don't throw + */ + async close(): Promise { + if (this.isClosed) return + + if (this.flushInterval) { + clearInterval(this.flushInterval) + this.flushInterval = undefined + } + + // Wait for initial load to complete if still in progress + try { + await this.loadPromise + } catch { + // Ignore load errors during close + } + + // Final flush attempt + try { + await this.flushToDisk() + } catch (err) { + this.logger.error('[LID Cache] Final flush failed on close:', err) + } + + this.isClosed = true + this.memory.close() + this.logger.info?.('[LID Cache] Closed') + } + + /** + * Forces compaction of the cache file by rewriting it with only valid entries. + * + * This removes any expired entries that might still be in the file + * (since NodeCache auto-expiry only removes from memory, not disk). + * + * @returns Promise that resolves when compaction completes + * + * @remarks + * - If the cache is empty, the file is deleted instead + * - Automatically called when file exceeds 10MB or 10k entries + */ + async compact(): Promise { + if (this.isClosed) return + + const keys = this.memory.keys() + if (keys.length === 0) { + // Empty cache - delete file + try { + await unlink(this.filePath) + this.dirty = false + this.logger.info?.('[LID Cache] Empty cache file removed') + } catch { + // File may not exist + } + return + } + + // Force full rewrite + this.dirty = true + await this.flushToDisk() + this.logger.info?.('[LID Cache] Compacted', { entries: keys.length }) + } + + /** + * Persists the current cache state to disk. + * + * @internal + * @remarks + * - Concurrent calls are deduplicated (only one flush runs at a time) + * - No-op if nothing has changed since last flush (`dirty` flag check) + * - File is written with 0o600 permissions (owner read/write only) + * - After 10 consecutive failures, persistence is disabled + */ + async flushToDisk(): Promise { + // Prevent concurrent flushes + if (this.flushing) return + if (!this.dirty) return + + this.flushing = true + + try { + await mkdir(dirname(this.filePath), { recursive: true }) + + const keys = this.memory.keys() + + // Compact if entry count exceeds threshold + if (keys.length > COMPACT_AT_ENTRIES) { + await this.compact() + } + + const entries: Record = {} + const now = Date.now() + + for (const key of keys) { + const value = this.memory.get(key) + if (!value) continue + + entries[key] = { + pn: value, + ts: now, + } + } + + const data: CacheFileData = { + version: CACHE_FILE_VERSION, + entries, + } + + // Write with secure permissions + await writeFile(this.filePath, JSON.stringify(data, null, 2), { + encoding: 'utf-8', + mode: FILE_PERMISSIONS, + }) + + this.dirty = false + this.consecutiveFlushFailures = 0 // Reset on success + + // Check file size and compact if needed + await this.checkAndCompactIfNeeded() + } catch (err) { + this.consecutiveFlushFailures++ + + if (this.consecutiveFlushFailures >= MAX_FLUSH_FAILURES) { + this.logger.error( + `[LID Cache] Flush failed ${MAX_FLUSH_FAILURES} times, disabling persistence. ` + + 'Cache will work in-memory only until restart.', + { error: err, filePath: this.filePath } + ) + this.dirty = false // Stop trying + } else { + this.logger.warn( + `[LID Cache] Flush failed (${this.consecutiveFlushFailures}/${MAX_FLUSH_FAILURES}):`, + err + ) + } + + throw err // Re-throw for caller awareness + } finally { + this.flushing = false + } + } + + /** + * Checks file size and triggers compaction if exceeds threshold. + * + * @internal + */ + private async checkAndCompactIfNeeded(): Promise { + try { + const stats = await stat(this.filePath) + if (stats.size > MAX_FILE_SIZE_BYTES) { + this.logger.warn(`[LID Cache] File size ${stats.size} bytes exceeds threshold, compacting...`) + await this.compact() + } + } catch { + // File may not exist + } + } + + /** + * Loads cache data from disk on startup. + * + * @internal + * @remarks + * - Silently handles missing file (first run) + * - Automatically removes and recovers from corrupted files + * - Validates TTL on each entry (entries older than TTL are skipped) + */ + private async loadFromDisk(): Promise { + try { + await access(this.filePath) + } catch { + // File doesn't exist - clean start + return + } + + let data: unknown + try { + const raw = await readFile(this.filePath, 'utf-8') + data = JSON.parse(raw) + } catch (err) { + // Corrupted file - remove and start fresh + try { + await unlink(this.filePath) + } catch { + // ignore unlink errors + } + this.logger.error('[LID Cache] Corrupted cache file removed, starting fresh:', err) + return + } + + // Validate file structure + if (!isCacheFileData(data)) { + this.logger.warn('[LID Cache] Invalid cache file format, starting fresh') + return + } + + // Load valid, non-expired entries + let loadedCount = 0 + let expiredCount = 0 + const now = Date.now() + const ttlMs = this.ttlSeconds * 1000 + + for (const [key, entry] of Object.entries(data.entries)) { + if (!isCacheEntry(entry)) continue + + // Check TTL from stored timestamp + const age = now - entry.ts + if (age >= ttlMs) { + expiredCount++ + continue + } + + this.memory.set(key, entry.pn) + loadedCount++ + } + + if (loadedCount > 0 || expiredCount > 0) { + this.logger.info('[LID Cache] Loaded entries from disk', { + valid: loadedCount, + expired: expiredCount, + file: this.filePath, + }) + } + } + + /** + * Returns cache statistics for monitoring. + * + * @returns Object with: + * - `keys`: Number of entries currently in memory + * - `hits`: Cache hit count (from NodeCache stats) + * - `misses`: Cache miss count (from NodeCache stats) + * + * @example + * ```typescript + * const stats = cache.getStats() + * console.log(`Cache: ${stats.keys} entries, ${stats.hits} hits, ${stats.misses} misses`) + * // → Cache: 1523 entries, 4500 hits, 123 misses + * ``` + */ + getStats(): { keys: number; hits: number; misses: number } { + return { + keys: this.memory.keys().length, + hits: (this.memory as any).getStats()?.hits || 0, + misses: (this.memory as any).getStats()?.misses || 0, + } + } +} + +// ============================================================================= +// MEMORY-ONLY CACHE (Testing) +// ============================================================================= + +/** + * Memory-only LID cache implementation for testing. + * + * This implementation provides the same {@link LidCache} interface but without + * file persistence. Useful for: + * - Unit tests (no file I/O, no cleanup needed) + * - Ephemeral caches that don't need durability + * - Reducing disk wear in high-frequency test scenarios + * + * ## Differences from HybridLidCache + * + * | Feature | MemoryLidCache | HybridLidCache | + * |---------|---------------|----------------| + * | Persistence | ❌ None | ✅ JSON file | + * | `ready()` | Optional | Recommended | + * | `close()` | No-op | Flushes to disk | + * | `compact()` | No-op | Rewrites file | + * | Cross-restart | Data lost | Data preserved | + * + * @example + * ```typescript + * // Testing scenario + * const cache = new MemoryLidCache(3600) // 1 hour TTL + * await cache.set('123@lid', '456@s.whatsapp.net') + * expect(await cache.get('123@lid')).toBe('456@s.whatsapp.net') + * // No cleanup needed - data is ephemeral + * ``` + */ +export class MemoryLidCache implements LidCache { + /** In-memory cache via NodeCache (no persistence) */ + private memory: NodeCache + + /** + * Creates a new MemoryLidCache instance. + * + * @param ttlSeconds - Time-to-live for cache entries (default: 7 days) + */ + constructor(ttlSeconds: number = DEFAULT_TTL_SECONDS) { + this.memory = new NodeCache({ + stdTTL: ttlSeconds, + useClones: false, + }) + } + + /** + * Retrieves the phone number for a given LID. + * + * @param lid - WhatsApp Local Identifier + * @returns Phone number or null if not found/invalid + */ + async get(lid: string): Promise { + if (!isValidLid(lid)) return null + + const normalized = normalizeLid(lid) + return this.memory.get(normalized) || null + } + + /** + * Stores a LID → phone number mapping. + * + * @param lid - WhatsApp Local Identifier + * @param pn - Phone number in any format + */ + async set(lid: string, pn: string): Promise { + if (!isValidLid(lid)) return + if (!isValidPn(pn)) return + + const normalized = normalizeLid(lid) + this.memory.set(normalized, pn) + } + + /** + * Checks if a LID exists in the cache. + * + * @param lid - WhatsApp Local Identifier + * @returns true if entry exists and hasn't expired + */ + async has(lid: string): Promise { + if (!isValidLid(lid)) return false + + const normalized = normalizeLid(lid) + return this.memory.has(normalized) + } + + /** + * Clears all entries from the cache. + */ + async clear(): Promise { + this.memory.flushAll() + } + + /** + * Closes the cache. For MemoryLidCache, this is a no-op. + * + * @remarks The NodeCache instance is not closed to allow continued testing. + * If you need to free memory, use `clear()` before `close()`. + */ + async close(): Promise { + // No-op for memory cache - data is already ephemeral + } +} + +// ============================================================================= +// FACTORY +// ============================================================================= + +/** + * Configuration options for creating a LidCache instance. + */ +export interface LidCacheFactoryOptions { + /** Cache strategy: 'file' (default), 'memory', or custom LidCache instance */ + strategy?: 'file' | 'memory' | LidCache + + /** Session name for file-based cache (used in filename) */ + sessionName?: string + + /** TTL in seconds (default: 7 days) */ + ttlSeconds?: number + + /** Base path for session files (default: process.cwd()) */ + basePath?: string + + /** Logger for operational events (default: console) */ + logger?: Console +} + +/** + * Factory function to create a LidCache instance based on configuration. + * + * This factory centralizes cache creation logic, making it reusable across + * the codebase and easier to test/maintain. + * + * @param options - Factory configuration options + * @returns Configured LidCache instance + * + * @example + * ```typescript + * // Default: HybridLidCache (file + memory) + * const cache = createLidCache({ sessionName: 'my-bot' }) + * + * // Memory-only (testing) + * const cache = createLidCache({ strategy: 'memory', ttlSeconds: 3600 }) + * + * // Custom implementation + * const cache = createLidCache({ strategy: new RedisLidCache() }) + * ``` + */ +export function createLidCache(options: LidCacheFactoryOptions = {}): LidCache { + const { strategy = 'file', sessionName = 'default', ttlSeconds = DEFAULT_TTL_SECONDS, basePath, logger } = options + + // If custom instance passed, use it directly + if (strategy && typeof strategy === 'object') { + return strategy + } + + // Memory-only strategy + if (strategy === 'memory') { + return new MemoryLidCache(ttlSeconds) + } + + // Default: Hybrid (file + memory) + return new HybridLidCache(sessionName, ttlSeconds, basePath, logger) +} + +// ============================================================================= +// MESSAGE UTILITIES +// ============================================================================= + +/** + * Minimal interface for Baileys message context key. + * Used for type-safe extraction of LID/PN mappings from incoming messages. + * + * @internal + */ +export interface MessageContextKey { + /** Remote JID (LID for DMs, group ID for groups) */ + remoteJid?: string + /** Participant LID in group messages */ + participant?: string + /** Participant phone number (if available) */ + participantAlt?: string + /** Alternative remote JID with phone number */ + remoteJidAlt?: string + /** Sender phone number (if available) */ + senderPn?: string + /** Participant phone number (alternative field) */ + participantPn?: string +} + +/** + * Minimal interface for Baileys message context. + * + * @internal + */ +export interface MessageContext { + /** Message key with JID information */ + key?: MessageContextKey +} + +/** + * Type guard to check if a value is a valid MessageContext. + * + * @param value - Value to check + * @returns true if the value matches MessageContext structure + */ +export function isMessageContext(value: unknown): value is MessageContext { + if (typeof value !== 'object' || value === null) return false + const ctx = value as Record + + // If key exists, it must be an object (MessageContextKey) + if ('key' in ctx && ctx.key !== undefined) { + if (typeof ctx.key !== 'object' || ctx.key === null) return false + } + + return true +} + +/** + * Extracts and caches LID → PN mapping from an incoming message. + * + * This utility function inspects the message context to find LID/PN pairs + * and stores them in the provided cache for future lookups. + * + * @param cache - LidCache instance to store the mapping + * @param messageCtx - Baileys message context (WAMessage) + * @returns Promise that resolves when caching is complete (or silently fails) + * + * @example + * ```typescript + * // In message handler: + * for (const message of messages) { + * await extractAndCacheLidFromMessage(lidCache, message) + * } + * ``` + */ +export async function extractAndCacheLidFromMessage(cache: LidCache, messageCtx: MessageContext): Promise { + try { + const key = messageCtx?.key + if (!key) return + + const isGroup = key.remoteJid?.includes('@g.us') + + if (isGroup) { + // Groups: participant has the LID, participantAlt has the PN + if (key.participant?.includes('@lid') && key.participantAlt) { + await cache.set(key.participant, key.participantAlt) + } + } else { + // DMs: remoteJid has the LID + if (key.remoteJid?.includes('@lid')) { + // Priority: remoteJidAlt > senderPn > participantPn + const pn = key.remoteJidAlt || key.senderPn || key.participantPn + if (pn) { + await cache.set(key.remoteJid, pn) + } + } + } + } catch { + // Silent failure - don't block message processing + } +} + +/** + * LID resolver function type. + * Takes a LID string and returns the corresponding phone number JID or null. + */ +export type LidResolver = (lid: LidJid) => Promise + +/** + * Resolves a LID to a phone number using cache-first strategy. + * + * This function implements the resolution chain: + * 1. Check cache first (O(1), ~1μs) + * 2. If miss, call fallback resolver (e.g., Baileys lidMapping) + * 3. If resolved, store in cache for future lookups + * + * @param cache - LidCache instance for storing/retrieving mappings + * @param fallbackResolver - Async function to resolve LID when not in cache + * @param logger - Logger for operational events (optional) + * @param lid - WhatsApp Local Identifier to resolve + * @returns Phone number in format '1234567890@s.whatsapp.net', or null + * + * @example + * ```typescript + * const pn = await resolveLidToPn( + * lidCache, + * (lid) => baileysSignalRepo.lidMapping.getPNForLID(lid), + * console, + * '123456789@lid' as LidJid + * ) + * ``` + */ +export async function resolveLidToPn( + cache: LidCache, + fallbackResolver: LidResolver | ((lid: string) => Promise), + logger: Console | undefined, + lid: LidJid | string +): Promise { + try { + // Validate/normalize the LID + const normalizedLid = asLidJid(typeof lid === 'string' ? lid : lid) + if (!normalizedLid) { + logger?.error?.('[LID Cache] Invalid LID format:', lid) + return null + } + + // 1. Check cache first (fast, O(1)) + const cached = await cache.get(normalizedLid) + if (cached) { + logger?.log?.(`[LID Cache] Hit: ${normalizedLid} -> ${cached}`) + return cached as PnJid + } + + // 2. Fallback to provided resolver + const resolved = await fallbackResolver(normalizedLid) + if (resolved) { + // Validate the resolved value is a valid PN + const normalizedPn = asPnJid(resolved) + if (normalizedPn) { + // Store in cache for next time + await cache.set(normalizedLid, normalizedPn) + logger?.log?.(`[LID Cache] Resolved: ${normalizedLid} -> ${normalizedPn}`) + return normalizedPn + } + } + + return null + } catch (e) { + logger?.error?.('[LID Cache] Error resolving LID:', e) + return null + } +} diff --git a/packages/provider-baileys/src/type.ts b/packages/provider-baileys/src/type.ts index c40da34f8..ae4b06522 100644 --- a/packages/provider-baileys/src/type.ts +++ b/packages/provider-baileys/src/type.ts @@ -1,5 +1,8 @@ import type { GlobalVendorArgs } from '@builderbot/bot/dist/types' import { proto, WABrowserDescription, WAVersion } from 'baileys' + +import type { LidCache } from './lidCache' + export interface BaileyGlobalVendorArgs extends GlobalVendorArgs { gifPlayback: boolean usePairingCode: boolean @@ -15,4 +18,18 @@ export interface BaileyGlobalVendorArgs extends GlobalVendorArgs { version?: WAVersion // autoRefresh?: number host?: any + + /** + * Estrategia de caché para resolución LID→PN. + * - 'file' (default): HybridLidCache (memory + file persistence) + * - 'memory': MemoryLidCache (solo memoria, no persiste) + * - LidCache: Implementación custom (e.g., Redis) + */ + lidCache?: 'file' | 'memory' | LidCache + + /** + * TTL (time-to-live) en segundos para entradas del LID cache. + * Default: 604800 (7 días) + */ + lidCacheTtl?: number } From c622c6dc9819075a24c8b069b9063774d91e6c74 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 16 Apr 2026 12:18:01 +0200 Subject: [PATCH 14/31] chore: publish dev --- .../__tests__/dialogflow-cx.class.test.ts | 28 ++++-- .../__tests__/dialogflow.class.test.ts | 92 +++++++------------ 2 files changed, 49 insertions(+), 71 deletions(-) diff --git a/packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts b/packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts index a1ce090e4..2bfa2e4d6 100644 --- a/packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts +++ b/packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts @@ -11,6 +11,14 @@ import { Message } from '../src/types' const mockProvider = new ProviderClass() +const mockLogger = { + log: stub(), + error: stub(), + warn: stub(), + info: stub(), + debug: stub(), +} + const credentialMock = { project_id: 'project_id', private_key: 'private_key', @@ -36,7 +44,7 @@ test.before.each(async () => { test('init - should return an error message', () => { const messageError = `No se encontró` try { - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider) dialogFlowContext['existsCredential'] = existsCredentialStub.returns(false) dialogFlowContext.init() } catch (error) { @@ -50,7 +58,7 @@ test('init - should call initializeDialogFlowClient if credentials are available private_key: 'tu_private_key', client_email: 'tu_client_email', } - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider, optionsDX) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) stub(dialogFlowContext, 'loadCredentials').returns(credentials) const initializeDialogFlowClientStub = stub(dialogFlowContext as any, 'initializeDialogFlowClient') dialogFlowContext.init() @@ -59,7 +67,7 @@ test('init - should call initializeDialogFlowClient if credentials are available }) test('initializeDialogFlowClient should set projectId, configuration, and sessionClient', () => { - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider, optionsDX) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) const credentials = { project_id: 'test_project', private_key: 'private_key', @@ -70,7 +78,7 @@ test('initializeDialogFlowClient should set projectId, configuration, and sessio }) test('createSession should return the correct session path', () => { - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider, optionsDX) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) const mockProjectAgentSessionPath = stub(dialogFlowContext.sessionClient as any, 'projectLocationAgentSessionPath') mockProjectAgentSessionPath.callsFake((projectId, from) => `${projectId}/sessions/${from}`) @@ -83,7 +91,7 @@ test('createSession should return the correct session path', () => { }) test('detectIntent - should return the correct result', async () => { - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider, optionsDX) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) const mockDetectIntent = stub(dialogFlowContext.sessionClient as any, 'detectIntent') const mockResult = { queryResult: { @@ -110,7 +118,7 @@ test('detectIntent - should return the correct result', async () => { }) test('detectIntent - should return null', async () => { - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider, optionsDX) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) const mockDetectIntent = stub(dialogFlowContext.sessionClient as any, 'detectIntent') mockDetectIntent.resolves(null) @@ -138,7 +146,7 @@ test('handleMsg - You should send the text message', async () => { body: 'some_message_body', } - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider, optionsDX) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) dialogFlowContext['createSession'] = stub().resolves('session') dialogFlowContext['detectIntent'] = stub().resolves({ queryResult: { @@ -160,7 +168,7 @@ test('handleMsg - You should send the payload type message', async () => { body: 'some_message_body', } - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider) dialogFlowContext['createSession'] = stub().resolves('session') dialogFlowContext['detectIntent'] = stub().resolves({ queryResult: { @@ -206,7 +214,7 @@ test('handleMsg - You should send the payload type media', async () => { body: 'some_message_body', } - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider) dialogFlowContext['createSession'] = stub().resolves('session') dialogFlowContext['detectIntent'] = stub().resolves({ queryResult: { @@ -248,7 +256,7 @@ test('handleMsg - should handle unknown message type with empty answer', async ( body: 'some_message_body', } - const dialogFlowContext = new DialogFlowContextCX(null, mockProvider, optionsDX) + const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) dialogFlowContext['createSession'] = stub().resolves('session') dialogFlowContext['detectIntent'] = stub().resolves({ queryResult: { diff --git a/packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts b/packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts index fd8241408..8854dd1e9 100644 --- a/packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts +++ b/packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts @@ -10,6 +10,14 @@ import { Message } from '../src/types' const mockProvider = new ProviderClass() +const mockLogger = { + log: stub(), + error: stub(), + warn: stub(), + info: stub(), + debug: stub(), +} + const credentialMock = { project_id: 'project_id', private_key: 'private_key', @@ -31,7 +39,7 @@ test('init - I should call the initializeSessionClient function', () => { const expectedData = { credentials: { private_key: 'private_key', client_email: 'client_email' }, } - const dialogFlowContext = new DialogFlowContext(null, mockProvider) + const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) dialogFlowContext['existsCredential'] = existsCredentialStub.returns(true) dialogFlowContext['getCredential'] = getCredentialStub.returns(credentialMock) dialogFlowContext['initializeSessionClient'] = initializeSessionClientStub @@ -42,7 +50,7 @@ test('init - I should call the initializeSessionClient function', () => { test('init - should return an error message', () => { const messageError = `No se encontró` try { - const dialogFlowContext = new DialogFlowContext(null, mockProvider) + const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) dialogFlowContext['existsCredential'] = existsCredentialStub.returns(false) dialogFlowContext.init() } catch (error) { @@ -56,7 +64,7 @@ test('handleMsg - You should send the text message', async () => { body: 'some_message_body', } - const dialogFlowContext = new DialogFlowContext(null, mockProvider) + const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) dialogFlowContext['createSession'] = stub().resolves('session') dialogFlowContext['detectIntent'] = stub().resolves({ queryResult: { @@ -68,8 +76,8 @@ test('handleMsg - You should send the text message', async () => { dialogFlowContext['sendFlowSimple'] = sendFlowSimpleStub await dialogFlowContext.handleMsg(messageCtxInComming) - assert.equal(sendFlowSimpleStub.called, true) - assert.equal(sendFlowSimpleStub.firstCall.args[0][0], expectedMessage) + + assert.equal(sendFlowSimpleStub.calledWith([expectedMessage]), true) }) test('handleMsg - You should send the payload type message', async () => { @@ -78,111 +86,73 @@ test('handleMsg - You should send the payload type message', async () => { body: 'some_message_body', } - const dialogFlowContext = new DialogFlowContext(null, mockProvider) + const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) dialogFlowContext['createSession'] = stub().resolves('session') dialogFlowContext['detectIntent'] = stub().resolves({ queryResult: { fulfillmentMessages: [ { - message: Message.PAYLOAD, + message: 'payload', payload: { fields: { - buttons: { - listValue: { - values: [ - { - structValue: { fields: { body: { stringValue: 'Test button' } } }, - }, - ], - }, - }, - media: { stringValue: 'url-example' }, - answer: { stringValue: 'test image' }, + media: { kind: 'stringValue', stringValue: 'image' }, + body: { kind: 'stringValue', stringValue: 'image' }, }, }, }, ], }, }) - const expectedMessage = [ - { - options: { media: 'url-example', buttons: [{ body: 'Test button' }] }, - answer: 'test image', - }, - ] dialogFlowContext['sendFlowSimple'] = sendFlowSimpleStub await dialogFlowContext.handleMsg(messageCtxInComming) + assert.equal(sendFlowSimpleStub.called, true) - assert.equal(sendFlowSimpleStub.args[0][0], expectedMessage) +}) + +test.after.each(() => { + unlinkSync(pathFile) }) test('createSession should return the correct session path', () => { - const dialogFlowContext = new DialogFlowContext(null, mockProvider) + const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) const mockProjectAgentSessionPath = stub(dialogFlowContext.sessionClient as any, 'projectAgentSessionPath') mockProjectAgentSessionPath.callsFake((projectId, from) => `${projectId}/sessions/${from}`) const projectId = 'project_id' - const from = 'test_user_id' + const from = 'user123' const expectedSessionPath = `${projectId}/sessions/${from}` const sessionPath = dialogFlowContext['createSession'](from) + assert.equal(sessionPath, expectedSessionPath) - assert.equal(mockProjectAgentSessionPath.args[0], [projectId, from]) - mockProjectAgentSessionPath.restore() }) test('detectIntent - should return the correct result', async () => { - const dialogFlowContext = new DialogFlowContext(null, mockProvider) + const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) const mockDetectIntent = stub(dialogFlowContext.sessionClient as any, 'detectIntent') const mockResult = { queryResult: { - fulfillmentMessages: [{ message: 'TEXT', text: { text: ['Response from DialogFlow'] } }], + fulfillmentMessages: [{ message: Message.TEXT, text: { text: ['Hello!'] } }], }, } - mockDetectIntent.resolves([mockResult]) - const reqDialog = { - session: 'session_path', - queryInput: { - text: { - text: 'test_message', - languageCode: 'en', - }, - }, - } + mockDetectIntent.resolves([mockResult]) - const result = await dialogFlowContext['detectIntent'](reqDialog) + const result = await dialogFlowContext['detectIntent']('session123', 'Hello') assert.equal(result, mockResult) - - mockDetectIntent.restore() }) test('detectIntent - should return null', async () => { - const dialogFlowContext = new DialogFlowContext(null, mockProvider) + const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) const mockDetectIntent = stub(dialogFlowContext.sessionClient as any, 'detectIntent') mockDetectIntent.resolves(null) - const reqDialog = { - session: 'session_path', - queryInput: { - text: { - text: 'test_message', - languageCode: 'en', - }, - }, - } - - const result = await dialogFlowContext['detectIntent'](reqDialog) + const result = await dialogFlowContext['detectIntent']('session123', 'Hello') assert.equal(result, null) - - mockDetectIntent.restore() }) -test.after.each(() => { - unlinkSync(pathFile) -}) test.run() From 196caefb5fa2551429a80fcaf6f673f23674323b Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 16 Apr 2026 12:18:57 +0200 Subject: [PATCH 15/31] v1.4.2-alpha.7 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 2 +- packages/contexts-dialogflow/package.json | 2 +- packages/create-builderbot/package.json | 2 +- packages/database-json/package.json | 2 +- packages/database-mongo/package.json | 2 +- packages/database-mysql/package.json | 2 +- packages/database-postgres/package.json | 2 +- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 2 +- packages/provider-baileys/package.json | 2 +- packages/provider-email/package.json | 2 +- packages/provider-evolution-api/package.json | 2 +- packages/provider-facebook-messenger/package.json | 2 +- packages/provider-gohighlevel/package.json | 2 +- packages/provider-gupshup/package.json | 2 +- packages/provider-instagram/package.json | 2 +- packages/provider-meta/package.json | 2 +- packages/provider-sherpa/package.json | 2 +- packages/provider-telegram/package.json | 2 +- packages/provider-twilio/package.json | 2 +- packages/provider-venom/package.json | 2 +- packages/provider-web-whatsapp/package.json | 2 +- packages/provider-wppconnect/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lerna.json b/lerna.json index 69581c26b..52c4fb444 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index c9b26bede..9a3b82cc4 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index 762cf32b5..9b403efb4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index 07c60ce12..0cf775fdd 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index 3fb0cb657..05825b725 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index 784cdd078..dfc8bb3ac 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "", "license": "ISC", "main": "dist/index.cjs", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index 951b70bf0..c8d118ae0 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index 005d2c099..e2cda9ba1 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index 0a6fee0ec..54347cecd 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 2f0aecc20..0704f9255 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index 76713e60f..f2439c423 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index 9178a66a1..0bf41b58d 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index e2711aa0e..7645a5507 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index 0f3c6ba77..2b8cd2316 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index b658f1eb0..2899654cd 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index 5b2b1d48c..1fcf8fa9a 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index 729e0a64f..9b10b777d 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index 987602a96..a28561584 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index 47f14765f..9e98cd7c4 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index 791f7c6f7..cd1f38959 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index 9e566d533..7e23b11d6 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index bbeb18436..e318fce21 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index 848b49ae1..d0b06db9c 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Provider for Telegram", "keywords": [], "author": "", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index 0f9eb3cdc..05624bae8 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index c5cd89972..42a370077 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index d20b5f0b8..1b66dcad9 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index f303ec77c..0a079b86d 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.6", + "version": "1.4.2-alpha.7", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", From 92d6eec83e8e5073398d92efcb137ad085358239 Mon Sep 17 00:00:00 2001 From: Manuel Ortiz Date: Tue, 28 Apr 2026 10:20:57 -0500 Subject: [PATCH 16/31] fix(provider-baileys): fallback @lid JID when remoteJidAlt is absent on orderMessage --- .../__tests__/baileysProvider.test.ts | 51 +++++++++++++++++++ packages/provider-baileys/src/bailey.ts | 7 +-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/provider-baileys/__tests__/baileysProvider.test.ts b/packages/provider-baileys/__tests__/baileysProvider.test.ts index 33a937f3b..69da5a87d 100644 --- a/packages/provider-baileys/__tests__/baileysProvider.test.ts +++ b/packages/provider-baileys/__tests__/baileysProvider.test.ts @@ -1002,6 +1002,57 @@ describe('#BaileysProvider', () => { expect(provider.emit).toHaveBeenCalled() }) + test('Detect orderMessage with standard JID', async () => { + // Arrange + jest.mocked(utils.generateRefProvider).mockReturnValue('_event_order___mock-uuid') + + const mockMessage = { + message: { + orderMessage: { orderId: 'order-123', token: 'token-abc' }, + }, + pushName: 'Buyer Name', + key: { + remoteJid: '5491112223344@s.whatsapp.net', + id: 'msg-order-001', + }, + } + + // Act + await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) + + // Assert + expect(provider.emit).toHaveBeenCalledWith( + 'message', + expect.objectContaining({ body: '_event_order___mock-uuid' }) + ) + }) + + test('Detect orderMessage with @lid JID and no remoteJidAlt', async () => { + // Arrange — escenario del bug: @lid sin remoteJidAlt crasheaba baileyCleanNumber(undefined) + jest.mocked(utils.generateRefProvider).mockReturnValue('_event_order___mock-uuid') + + const mockMessage = { + message: { + orderMessage: { orderId: 'order-456', token: 'token-xyz' }, + }, + pushName: 'Buyer Name', + key: { + remoteJid: '5491112223344@lid', + remoteJidAlt: undefined, + id: 'msg-order-002', + }, + } + + // Act + await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) + + // Assert + expect(provider.emit).toHaveBeenCalledWith( + 'message', + expect.objectContaining({ body: '_event_order___mock-uuid' }) + ) + }) + test('Detect broadcast in a message', async () => { // Arrange const mockMessage = { diff --git a/packages/provider-baileys/src/bailey.ts b/packages/provider-baileys/src/bailey.ts index f7af1164f..bfae03ef7 100644 --- a/packages/provider-baileys/src/bailey.ts +++ b/packages/provider-baileys/src/bailey.ts @@ -587,9 +587,10 @@ class BaileysProvider extends ProviderClass { } } - // Preferir @s.whatsapp.net (remoteJidAlt) sobre @lid cuando esté disponible - const { remoteJid, remoteJidAlt } = (messageCtx?.key ?? {}) as any - const fromParse = remoteJid?.includes('@lid') ? remoteJidAlt || remoteJid : remoteJid + // Buscar siempre el que tenga formato @s.whatsapp.net (puede estar en remoteJid o remoteJidAlt) + const remoteJid = (messageCtx?.key as any)?.remoteJid + const remoteJidAlt = (messageCtx?.key as any)?.remoteJidAlt + const fromParse = remoteJid?.includes('@lid') ? remoteJidAlt || remoteJid?.split('@')[0] : remoteJid let payload = { ...messageCtx, From 1ea84512f0113f2326e06988bd28206917cede6f Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Tue, 28 Apr 2026 22:54:49 +0200 Subject: [PATCH 17/31] chore: publish dev --- .cursor/.gitignore | 1 + .../provider-meta/INTERACTIVE_MESSAGES.md | 296 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 .cursor/.gitignore create mode 100644 packages/provider-meta/INTERACTIVE_MESSAGES.md diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 000000000..8bf7cc27a --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/packages/provider-meta/INTERACTIVE_MESSAGES.md b/packages/provider-meta/INTERACTIVE_MESSAGES.md new file mode 100644 index 000000000..2490a4b33 --- /dev/null +++ b/packages/provider-meta/INTERACTIVE_MESSAGES.md @@ -0,0 +1,296 @@ +# Meta Provider — Mensajes Interactivos sin Plantillas + +Este documento describe los métodos del `MetaProvider` para enviar **botones** y **listas** de forma interactiva usando la WhatsApp Cloud API, **sin necesidad de plantillas aprobadas en Meta Business**. + +> **Requisito importante:** Los mensajes interactivos solo pueden enviarse dentro de la **ventana de conversación activa de 24 horas**. Si el usuario no ha escrito en las últimas 24h, es obligatorio usar un template aprobado para reabrir la ventana. + +--- + +## Diferencia: Interactivo vs Template + +| Tipo | Requiere aprobación | Válido fuera de 24h | +|---|---|---| +| `interactive/button` | No | No | +| `interactive/list` | No | No | +| `interactive/cta_url` | No | No | +| `template` | Sí (Meta Business) | Sí | + +--- + +## Botones + +### `sendButtons` — Reply buttons (máx. 3) + +Envía hasta 3 botones de respuesta rápida. Cada título tiene un límite de **20 caracteres** (internamente se trunca a 16). + +```typescript +await provider.sendButtons( + '5491112345678', + [ + { body: 'Sí, confirmo' }, + { body: 'No, cancelar' }, + { body: 'Ver más info' }, + ], + '¿Deseas confirmar tu pedido?' +) +``` + +**Payload enviado a la API:** + +```json +{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "5491112345678", + "type": "interactive", + "interactive": { + "type": "button", + "body": { "text": "¿Deseas confirmar tu pedido?" }, + "action": { + "buttons": [ + { "type": "reply", "reply": { "id": "btn-0", "title": "Sí, confirmo" } }, + { "type": "reply", "reply": { "id": "btn-1", "title": "No, cancelar" } }, + { "type": "reply", "reply": { "id": "btn-2", "title": "Ver más info" } } + ] + } + } +} +``` + +--- + +### `sendButtonUrl` — Botón con URL externa (CTA) + +Envía un único botón que abre una URL en el navegador del usuario. + +```typescript +await provider.sendButtonUrl( + '5491112345678', + { body: 'Ir al sitio', url: 'https://example.com/pedido/123' }, + 'Tu pedido está listo. Haz clic para ver el detalle:' +) +``` + +**Payload enviado a la API:** + +```json +{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "5491112345678", + "type": "interactive", + "interactive": { + "type": "cta_url", + "body": { "text": "Tu pedido está listo. Haz clic para ver el detalle:" }, + "action": { + "name": "cta_url", + "parameters": { + "display_text": "Ir al sitio", + "url": "https://example.com/pedido/123" + } + } + } +} +``` + +--- + +### `sendButtonsMedia` — Botones con imagen o video como header + +Envía botones de respuesta rápida con una imagen o video como encabezado visual. + +```typescript +// Con imagen +await provider.sendButtonsMedia( + '5491112345678', + 'image', + [{ body: 'Comprar' }, { body: 'Ver más' }], + 'Producto destacado de la semana', + 'https://example.com/producto.jpg' +) + +// Con video +await provider.sendButtonsMedia( + '5491112345678', + 'video', + [{ body: 'Me interesa' }], + 'Mira nuestro nuevo producto', + 'https://example.com/demo.mp4' +) +``` + +**Payload enviado a la API (ejemplo imagen):** + +```json +{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "5491112345678", + "type": "interactive", + "interactive": { + "type": "button", + "header": { + "type": "image", + "image": { "link": "https://example.com/producto.jpg" } + }, + "body": { "text": "Producto destacado de la semana" }, + "action": { + "buttons": [ + { "type": "reply", "reply": { "id": "btn-0", "title": "Comprar" } }, + { "type": "reply", "reply": { "id": "btn-1", "title": "Ver más" } } + ] + } + } +} +``` + +--- + +## Listas + +### `sendListComplete` — Lista con parámetros explícitos (recomendado) + +Envía una lista interactiva con header, body, footer y secciones con filas. Ideal cuando se construye la lista desde código. + +```typescript +await provider.sendListComplete( + '5491112345678', + 'Menú principal', // header + 'Elige una opción:', // body + 'Horario: Lun-Vie 9-18h', // footer + 'Ver opciones', // texto del botón para abrir la lista + [ + { + title: 'Soporte', + rows: [ + { id: 'soporte-tecnico', title: 'Soporte técnico', description: 'Problemas con el sistema' }, + { id: 'soporte-factura', title: 'Facturación', description: 'Consultas sobre pagos' }, + ], + }, + { + title: 'Ventas', + rows: [ + { id: 'ventas-nuevo', title: 'Nuevo cliente', description: 'Quiero conocer los planes' }, + { id: 'ventas-upgrade', title: 'Actualizar plan', description: 'Mejorar mi suscripción' }, + ], + }, + ] +) +``` + +**Payload enviado a la API:** + +```json +{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "5491112345678", + "type": "interactive", + "interactive": { + "type": "list", + "header": { "type": "text", "text": "Menú principal" }, + "body": { "text": "Elige una opción:" }, + "footer": { "text": "Horario: Lun-Vie 9-18h" }, + "action": { + "button": "Ver opciones", + "sections": [ + { + "title": "Soporte", + "rows": [ + { "id": "soporte-tecnico", "title": "Soporte técnico", "description": "Problemas con el sistema" }, + { "id": "soporte-factura", "title": "Facturación", "description": "Consultas sobre pagos" } + ] + }, + { + "title": "Ventas", + "rows": [ + { "id": "ventas-nuevo", "title": "Nuevo cliente", "description": "Quiero conocer los planes" }, + { "id": "ventas-upgrade", "title": "Actualizar plan", "description": "Mejorar mi suscripción" } + ] + } + ] + } + } +} +``` + +--- + +### `sendList` — Lista con objeto `MetaList` raw + +Versión de bajo nivel que acepta directamente el objeto de la Cloud API. Útil cuando se construye el payload manualmente o se tiene mayor control. + +```typescript +await provider.sendList('5491112345678', { + header: { type: 'text', text: 'Opciones disponibles' }, + body: { text: 'Selecciona lo que necesitas' }, + footer: { text: 'Bot de atención' }, + action: { + button: 'Abrir menú', + sections: [ + { + title: 'Categoría A', + rows: [ + { id: 'a1', title: 'Opción A1', description: 'Descripción opcional' }, + ], + }, + ], + }, +}) +``` + +--- + +## Limitaciones de la Cloud API + +| Restricción | Valor | +|---|---| +| Máx. botones por mensaje (`sendButtons`) | 3 | +| Máx. caracteres por título de botón | 20 (se trunca a 16 internamente) | +| Máx. secciones por lista | 10 | +| Máx. filas por sección | 10 | +| Máx. filas totales por lista | 10 | +| Máx. caracteres en título de fila | 24 | +| Máx. caracteres en descripción de fila | 72 | +| Máx. caracteres en texto del botón de lista | 20 | +| Ventana de uso sin template | 24 horas desde último mensaje del usuario | + +--- + +## Cómo capturar la respuesta del usuario + +Cuando el usuario selecciona un botón o fila de lista, el mensaje entrante llega con la siguiente estructura en `ctx`: + +```typescript +// Botón de reply +ctx.body // Texto del botón (ej: "Sí, confirmo") +ctx.type // "interactive" + +// Fila de lista +ctx.body // id de la fila seleccionada (ej: "soporte-tecnico") +ctx.type // "interactive" +``` + +Ejemplo en un flujo: + +```typescript +const flow = addKeyword(['menu']) + .addAction(async (ctx, { provider }) => { + await provider.sendListComplete( + ctx.from, + 'Menú', + '¿En qué te ayudamos?', + 'Atención 24/7', + 'Ver opciones', + [ + { + title: 'Soporte', + rows: [{ id: 'tecnico', title: 'Soporte técnico', description: '' }], + }, + ] + ) + }) + .addAnswer('', null, async (ctx, { gotoFlow }) => { + if (ctx.body === 'tecnico') return gotoFlow(flujoSoporteTecnico) + }) +``` From 2165374602d45253a099b5e40965731943f093a8 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Tue, 28 Apr 2026 22:55:15 +0200 Subject: [PATCH 18/31] v1.4.2-alpha.8 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 2 +- packages/contexts-dialogflow/package.json | 2 +- packages/create-builderbot/package.json | 2 +- packages/database-json/package.json | 2 +- packages/database-mongo/package.json | 2 +- packages/database-mysql/package.json | 2 +- packages/database-postgres/package.json | 2 +- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 2 +- packages/provider-baileys/package.json | 2 +- packages/provider-email/package.json | 2 +- packages/provider-evolution-api/package.json | 2 +- packages/provider-facebook-messenger/package.json | 2 +- packages/provider-gohighlevel/package.json | 2 +- packages/provider-gupshup/package.json | 2 +- packages/provider-instagram/package.json | 2 +- packages/provider-meta/package.json | 2 +- packages/provider-sherpa/package.json | 2 +- packages/provider-telegram/package.json | 2 +- packages/provider-twilio/package.json | 2 +- packages/provider-venom/package.json | 2 +- packages/provider-web-whatsapp/package.json | 2 +- packages/provider-wppconnect/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lerna.json b/lerna.json index 52c4fb444..d393824b0 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index 9a3b82cc4..45f2a136a 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index 9b403efb4..e33b6a70f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index 0cf775fdd..d7a282251 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index 05825b725..20052a120 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index dfc8bb3ac..3abc1490f 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "", "license": "ISC", "main": "dist/index.cjs", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index c8d118ae0..8be05bd1f 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index e2cda9ba1..6b1dd23ea 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index 54347cecd..8a2a25995 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 0704f9255..65cd83e17 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index f2439c423..05becc105 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index 0bf41b58d..f08178486 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index 7645a5507..f9f5bc8a7 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index 2b8cd2316..f99f37b83 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index 2899654cd..823a4bafb 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index 1fcf8fa9a..c3a5bd52f 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index 9b10b777d..125ec0d85 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index a28561584..7627a4661 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index 9e98cd7c4..784547b94 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index cd1f38959..b99d6bab4 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index 7e23b11d6..28bc30d54 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index e318fce21..5b22c4192 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index d0b06db9c..16de21f57 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Provider for Telegram", "keywords": [], "author": "", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index 05624bae8..b23131c0f 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index 42a370077..358aa7fb3 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index 1b66dcad9..0a2f0998a 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index 0a079b86d..2cc8966ce 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.7", + "version": "1.4.2-alpha.8", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", From 16ee7a597ec55b7db69a3d6e8e418566a67de100 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 30 Apr 2026 12:28:46 +0200 Subject: [PATCH 19/31] chore: publish dev --- packages/provider-instagram/src/instagram.provider.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/provider-instagram/src/instagram.provider.ts b/packages/provider-instagram/src/instagram.provider.ts index f3a1eb517..d70a0bf1e 100644 --- a/packages/provider-instagram/src/instagram.provider.ts +++ b/packages/provider-instagram/src/instagram.provider.ts @@ -233,7 +233,9 @@ class InstagramProvider extends ProviderClass { } sendMessage = async (userId: string, message: string, options?: SendOptions): Promise => { - // Check if media is provided in options + if (options?.comment?.id) { + return this.sendPrivateReply(options.comment.id, message) + } if (options?.media) { return this.sendMedia(userId, message, options.media) } @@ -540,8 +542,13 @@ class InstagramProvider extends ProviderClass { console.info('[Instagram] Private reply sent successfully') return response.data } catch (error) { + const igError = error.response?.data?.error + if (igError?.error_subcode === 2534022 || igError?.code === 10) { + console.warn('[Instagram] Comment window expired, skipping private reply to comment:', commentId) + return null + } console.error('[Instagram] Error sending private reply:', { - error: error.response?.data || error.message, + error: igError || error.message, }) throw new Error('Failed to send private reply') } From f0187d087442f165b6d746945e4533fcd81a02a9 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 30 Apr 2026 12:29:47 +0200 Subject: [PATCH 20/31] v1.4.2-alpha.9 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 2 +- packages/contexts-dialogflow/package.json | 2 +- packages/create-builderbot/package.json | 2 +- packages/database-json/package.json | 2 +- packages/database-mongo/package.json | 2 +- packages/database-mysql/package.json | 2 +- packages/database-postgres/package.json | 2 +- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 2 +- packages/provider-baileys/package.json | 2 +- packages/provider-email/package.json | 2 +- packages/provider-evolution-api/package.json | 2 +- packages/provider-facebook-messenger/package.json | 2 +- packages/provider-gohighlevel/package.json | 2 +- packages/provider-gupshup/package.json | 2 +- packages/provider-instagram/package.json | 2 +- packages/provider-meta/package.json | 2 +- packages/provider-sherpa/package.json | 2 +- packages/provider-telegram/package.json | 2 +- packages/provider-twilio/package.json | 2 +- packages/provider-venom/package.json | 2 +- packages/provider-web-whatsapp/package.json | 2 +- packages/provider-wppconnect/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lerna.json b/lerna.json index d393824b0..6a8198c0a 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index 45f2a136a..d7a84caaa 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index e33b6a70f..cbe0f1a30 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index d7a282251..08e38e78c 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index 20052a120..27dc7cd3d 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index 3abc1490f..1f9bba843 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "", "license": "ISC", "main": "dist/index.cjs", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index 8be05bd1f..cafb5773d 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index 6b1dd23ea..4676464d7 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index 8a2a25995..bc5908ea2 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 65cd83e17..3be32fb7a 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index 05becc105..869aa7f6f 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index f08178486..93ed7b15f 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index f9f5bc8a7..3bf41f84b 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index f99f37b83..2e169d752 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index 823a4bafb..b3eb47af0 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index c3a5bd52f..faecba0f1 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index 125ec0d85..3728c9fea 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index 7627a4661..5e8386e4a 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index 784547b94..a22672b1b 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index b99d6bab4..af7d39b71 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index 28bc30d54..9c2611592 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index 5b22c4192..5718b464f 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index 16de21f57..3ed9b32df 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Provider for Telegram", "keywords": [], "author": "", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index b23131c0f..78c7a5757 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index 358aa7fb3..074718389 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index 0a2f0998a..5eaf4b4a5 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index 2cc8966ce..4eb23c14f 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.8", + "version": "1.4.2-alpha.9", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", From e43f7fcb844feae7dded213dc576beb9cc1683ee Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 30 Apr 2026 16:31:45 +0200 Subject: [PATCH 21/31] feat(provider-instagram): enhance message handling with --- .../__tests__/instagram.provider.spec.ts | 104 ++++++++++++++++++ .../src/instagram.provider.ts | 102 ++++++++++++++--- 2 files changed, 192 insertions(+), 14 deletions(-) diff --git a/packages/provider-instagram/__tests__/instagram.provider.spec.ts b/packages/provider-instagram/__tests__/instagram.provider.spec.ts index 1290bcb5f..af6037529 100644 --- a/packages/provider-instagram/__tests__/instagram.provider.spec.ts +++ b/packages/provider-instagram/__tests__/instagram.provider.spec.ts @@ -161,6 +161,49 @@ describe('InstagramProvider', () => { await expect(provider.sendText('user123', 'Hello')).rejects.toThrow('Failed to send message') }) + + it('should return null and emit window_expired when 24h window is closed (error code 10)', async () => { + const axios = require('axios') + axios.post.mockRejectedValue({ + response: { + data: { + error: { + message: 'This message is sent outside of allowed window.', + type: 'IGApiException', + code: 10, + error_subcode: 2534022, + }, + }, + }, + }) + + const result = await provider.sendText('user123', 'Hello') + + expect(result).toBeNull() + expect(provider.emit).toHaveBeenCalledWith('window_expired', { userId: 'user123', message: 'Hello' }) + }) + + it('should return null and emit window_expired when error_subcode is 2534022', async () => { + const axios = require('axios') + axios.post.mockRejectedValue({ + response: { + data: { + error: { + code: 10, + error_subcode: 2534022, + }, + }, + }, + }) + + const result = await provider.sendText('user456', 'Test message') + + expect(result).toBeNull() + expect(provider.emit).toHaveBeenCalledWith('window_expired', { + userId: 'user456', + message: 'Test message', + }) + }) }) describe('sendMessage', () => { @@ -222,6 +265,67 @@ describe('InstagramProvider', () => { await expect(provider.sendMessage('user123', 'Hello')).rejects.toThrow('Failed to send message') }) + + it('should call sendPrivateReply when options.comment.id is provided', async () => { + const axios = require('axios') + axios.post.mockResolvedValue({ + status: 200, + data: { message_id: 'msg_private' }, + }) + + const result = await provider.sendMessage('user123', 'Hi from bot', { comment: { id: 'comment_abc' } }) + + expect(axios.post).toHaveBeenCalledWith( + `https://graph.instagram.com/${mockConfig.version}/me/messages`, + expect.objectContaining({ + recipient: { comment_id: 'comment_abc' }, + message: { text: 'Hi from bot' }, + }) + ) + expect(result).toEqual({ message_id: 'msg_private' }) + }) + + it('should auto-route to sendPrivateReply when pendingComments has an entry for the user', async () => { + const axios = require('axios') + axios.post.mockResolvedValue({ + status: 200, + data: { message_id: 'msg_auto_private' }, + }) + ;(provider as any).pendingComments.set('user_commenter', { + commentId: 'comment_xyz', + timestamp: Date.now(), + }) + + const result = await provider.sendMessage('user_commenter', 'Thanks for commenting!') + + expect(axios.post).toHaveBeenCalledWith( + `https://graph.instagram.com/${mockConfig.version}/me/messages`, + expect.objectContaining({ + recipient: { comment_id: 'comment_xyz' }, + message: { text: 'Thanks for commenting!' }, + }) + ) + expect(result).toEqual({ message_id: 'msg_auto_private' }) + }) + + it('should consume pending comment only once (second send uses sendText)', async () => { + const axios = require('axios') + axios.post.mockResolvedValue({ + status: 200, + data: { message_id: 'msg_follow_up' }, + }) + ;(provider as any).pendingComments.set('user_once', { + commentId: 'comment_once', + timestamp: Date.now(), + }) + + await provider.sendMessage('user_once', 'First reply via Private Reply') + await provider.sendMessage('user_once', 'Second reply via DM') + + const calls = axios.post.mock.calls + expect(calls[0][1]).toMatchObject({ recipient: { comment_id: 'comment_once' } }) + expect(calls[1][1]).toMatchObject({ recipient: { id: 'user_once' } }) + }) }) describe('sendMedia', () => { diff --git a/packages/provider-instagram/src/instagram.provider.ts b/packages/provider-instagram/src/instagram.provider.ts index d70a0bf1e..b7f908538 100644 --- a/packages/provider-instagram/src/instagram.provider.ts +++ b/packages/provider-instagram/src/instagram.provider.ts @@ -22,6 +22,8 @@ export type InstagramArgs = GlobalVendorArgs & { listenMode?: InstagramListenMode } +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 + /** * A class representing an InstagramProvider for interacting with Instagram Messaging API. * @extends ProviderClass @@ -37,6 +39,15 @@ class InstagramProvider extends ProviderClass { listenMode: 'message', } + /** + * Tracks the most recent comment.id per userId so that the first outbound + * message after a comment event is routed via Private Replies + * (recipient: { comment_id }) instead of a regular DM (recipient: { id }). + * Only the last comment per user is kept; entries older than 7 days are + * purged automatically to avoid memory leaks. + */ + private pendingComments = new Map() + constructor(args?: InstagramArgs) { super() this.globalVendorArgs = { ...this.globalVendorArgs, ...args } @@ -58,6 +69,26 @@ class InstagramProvider extends ProviderClass { this.vendor = vendor this.server = this.server.post('/webhook', this.ctrlInMsg).get('/webhook', this.ctrlVerify) + vendor.on('message', (payload: any) => { + if (payload?.comment?.id && payload?.from) { + this.pendingComments.set(payload.from, { + commentId: payload.comment.id, + timestamp: Date.now(), + }) + } + }) + + const cleanupInterval = setInterval( + () => { + const cutoff = Date.now() - SEVEN_DAYS_MS + for (const [userId, entry] of this.pendingComments) { + if (entry.timestamp < cutoff) this.pendingComments.delete(userId) + } + }, + 60 * 60 * 1000 + ) + cleanupInterval.unref() + await this.checkStatus() return vendor } @@ -225,9 +256,13 @@ class InstagramProvider extends ProviderClass { console.info('[Instagram] Message sent successfully') return response.data } catch (error) { - console.error('[Instagram] Error sending message:', { - error: error.response?.data || error.message, - }) + const igError = error.response?.data?.error + if (igError?.error_subcode === 2534022 || igError?.code === 10) { + console.warn('[Instagram] 24h window closed, skipping message to:', userId) + this.emit('window_expired', { userId, message }) + return null + } + console.error('[Instagram] Error sending message:', { error: igError || error.message }) throw new Error('Failed to send message') } } @@ -236,6 +271,13 @@ class InstagramProvider extends ProviderClass { if (options?.comment?.id) { return this.sendPrivateReply(options.comment.id, message) } + + const pending = this.pendingComments.get(userId) + if (pending) { + this.pendingComments.delete(userId) + return this.sendPrivateReply(pending.commentId, message) + } + if (options?.media) { return this.sendMedia(userId, message, options.media) } @@ -298,7 +340,7 @@ class InstagramProvider extends ProviderClass { recipient: { id: userId }, message: { attachment: { - type: 'image', // Type is determined by the attachment itself + type: 'image', payload: { attachment_id: attachmentId, }, @@ -311,9 +353,13 @@ class InstagramProvider extends ProviderClass { console.info('[Instagram] Attachment sent successfully') return response.data } catch (error) { - console.error('[Instagram] Error sending attachment:', { - error: error.response?.data || error.message, - }) + const igError = error.response?.data?.error + if (igError?.error_subcode === 2534022 || igError?.code === 10) { + console.warn('[Instagram] 24h window closed, skipping attachment to:', userId) + this.emit('window_expired', { userId }) + return null + } + console.error('[Instagram] Error sending attachment:', { error: igError || error.message }) throw new Error('Failed to send attachment') } } @@ -375,7 +421,13 @@ class InstagramProvider extends ProviderClass { console.info('[Instagram] Image sent successfully') return response.data } catch (error) { - console.error('[Instagram] Error sending image:', { error: error.response?.data || error.message }) + const igError = error.response?.data?.error + if (igError?.error_subcode === 2534022 || igError?.code === 10) { + console.warn('[Instagram] 24h window closed, skipping image to:', userId) + this.emit('window_expired', { userId }) + return null + } + console.error('[Instagram] Error sending image:', { error: igError || error.message }) throw new Error('Failed to send image') } } @@ -404,7 +456,13 @@ class InstagramProvider extends ProviderClass { console.info('[Instagram] Video sent successfully') return response.data } catch (error) { - console.error('[Instagram] Error sending video:', { error: error.response?.data || error.message }) + const igError = error.response?.data?.error + if (igError?.error_subcode === 2534022 || igError?.code === 10) { + console.warn('[Instagram] 24h window closed, skipping video to:', userId) + this.emit('window_expired', { userId }) + return null + } + console.error('[Instagram] Error sending video:', { error: igError || error.message }) throw new Error('Failed to send video') } } @@ -433,7 +491,13 @@ class InstagramProvider extends ProviderClass { console.info('[Instagram] Audio sent successfully') return response.data } catch (error) { - console.error('[Instagram] Error sending audio:', { error: error.response?.data || error.message }) + const igError = error.response?.data?.error + if (igError?.error_subcode === 2534022 || igError?.code === 10) { + console.warn('[Instagram] 24h window closed, skipping audio to:', userId) + this.emit('window_expired', { userId }) + return null + } + console.error('[Instagram] Error sending audio:', { error: igError || error.message }) throw new Error('Failed to send audio') } } @@ -462,7 +526,13 @@ class InstagramProvider extends ProviderClass { console.info('[Instagram] File sent successfully') return response.data } catch (error) { - console.error('[Instagram] Error sending file:', { error: error.response?.data || error.message }) + const igError = error.response?.data?.error + if (igError?.error_subcode === 2534022 || igError?.code === 10) { + console.warn('[Instagram] 24h window closed, skipping file to:', userId) + this.emit('window_expired', { userId }) + return null + } + console.error('[Instagram] Error sending file:', { error: igError || error.message }) throw new Error('Failed to send file') } } @@ -490,9 +560,13 @@ class InstagramProvider extends ProviderClass { console.info('[Instagram] Quick replies sent successfully') return response.data } catch (error) { - console.error('[Instagram] Error sending quick replies:', { - error: error.response?.data || error.message, - }) + const igError = error.response?.data?.error + if (igError?.error_subcode === 2534022 || igError?.code === 10) { + console.warn('[Instagram] 24h window closed, skipping quick replies to:', userId) + this.emit('window_expired', { userId, message: text }) + return null + } + console.error('[Instagram] Error sending quick replies:', { error: igError || error.message }) throw new Error('Failed to send quick replies') } } From 8e17e420a18fcd1a4e5e5fa315a5a0ac26d9bf00 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 30 Apr 2026 16:41:38 +0200 Subject: [PATCH 22/31] feat(provider-instagram): enhance message handling with --- .../provider-baileys/__tests__/lidCache.critical.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/provider-baileys/__tests__/lidCache.critical.test.ts b/packages/provider-baileys/__tests__/lidCache.critical.test.ts index 795c54cd8..3f37bb860 100644 --- a/packages/provider-baileys/__tests__/lidCache.critical.test.ts +++ b/packages/provider-baileys/__tests__/lidCache.critical.test.ts @@ -54,7 +54,11 @@ describe('lidCache CRITICAL fixes', () => { // Verificar permisos: 0o600 = 384 en decimal const mode = stats.mode & 0o777 - expect(mode).toBe(0o600) + if (process.platform === 'win32') { + expect(mode).toBe(0o666) + } else { + expect(mode).toBe(0o600) + } // Limpiar await rm(join(process.cwd(), `${session}_sessions`), { recursive: true, force: true }) @@ -82,7 +86,7 @@ describe('lidCache CRITICAL fixes', () => { await access(cacheFile) // Limpiar - const sessionDir = join(process.cwd(), safeCache['filePath'].replace(process.cwd(), '').split('/')[1]) + const sessionDir = join(process.cwd(), safeCache['filePath'].replace(process.cwd(), '').split(/[\\/]/)[1]) try { await rm(sessionDir, { recursive: true, force: true }) } catch { From 9a0f92c64c464860472c771ebbc6319ec78ee7c2 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 30 Apr 2026 16:47:25 +0200 Subject: [PATCH 23/31] feat(provider-instagram): enhance message handling with --- packages/provider-meta/__tests__/downloadFile.test.ts | 10 ++++------ .../__tests__/processIncomingMessage.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/provider-meta/__tests__/downloadFile.test.ts b/packages/provider-meta/__tests__/downloadFile.test.ts index 1af64d631..0365b8c4e 100644 --- a/packages/provider-meta/__tests__/downloadFile.test.ts +++ b/packages/provider-meta/__tests__/downloadFile.test.ts @@ -76,10 +76,9 @@ describe('#downloadFile', () => { jest.spyOn(mime, 'extension').mockReturnValue(false) const consoleErrorSpy = jest.spyOn(console, 'error') - // Act - await downloadFile(url, token) + // Act & Assert + await expect(downloadFile(url, token)).rejects.toThrow('Unable to determine file extension') - // Assert expect(consoleErrorSpy).toHaveBeenCalledWith('Unable to determine file extension') expect(axios.get).toHaveBeenCalledWith(url, { headers: { Authorization: `Bearer ${token}` }, @@ -96,10 +95,9 @@ describe('#downloadFile', () => { ;(axios.get as jest.MockedFunction).mockRejectedValueOnce(new Error(errorMessage)) const consoleErrorSpy = jest.spyOn(console, 'error') - // Act - await downloadFile(url, token) + // Act & Assert + await expect(downloadFile(url, token)).rejects.toThrow(errorMessage) - //Assert expect(consoleErrorSpy).toHaveBeenCalledWith(errorMessage) expect(axios.get).toHaveBeenCalledWith(url, { headers: { Authorization: `Bearer ${token}` }, diff --git a/packages/provider-meta/__tests__/processIncomingMessage.test.ts b/packages/provider-meta/__tests__/processIncomingMessage.test.ts index 6c28ad267..8370f77e3 100644 --- a/packages/provider-meta/__tests__/processIncomingMessage.test.ts +++ b/packages/provider-meta/__tests__/processIncomingMessage.test.ts @@ -326,6 +326,7 @@ describe('#processIncomingMessage ', () => { test('should process sticker message correctly', async () => { // Arrange + const stickerUrl = 'https://example.com/sticker.webp' const params = { messageId: '123', messageTimestamp: Date.now(), @@ -341,6 +342,8 @@ describe('#processIncomingMessage ', () => { numberId: '987', } + ;(require('../src/utils/mediaUrl').getMediaUrl as jest.Mock).mockImplementation(() => stickerUrl) + // Act const result = await processIncomingMessage(params) @@ -350,6 +353,9 @@ describe('#processIncomingMessage ', () => { from: 'sender', to: 'receiver', id: 'stickerId', + url: stickerUrl, + fileData: undefined, + fromMe: undefined, body: expect.any(String), pushName: 'John Doe', name: 'John Doe', From f9464d268ddfb4006e143159db3e6384be69530f Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Thu, 30 Apr 2026 17:27:30 +0200 Subject: [PATCH 24/31] v1.4.2-alpha.10 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 2 +- packages/contexts-dialogflow/package.json | 2 +- packages/create-builderbot/package.json | 2 +- packages/database-json/package.json | 2 +- packages/database-mongo/package.json | 2 +- packages/database-mysql/package.json | 2 +- packages/database-postgres/package.json | 2 +- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 2 +- packages/provider-baileys/package.json | 2 +- packages/provider-email/package.json | 2 +- packages/provider-evolution-api/package.json | 2 +- packages/provider-facebook-messenger/package.json | 2 +- packages/provider-gohighlevel/package.json | 2 +- packages/provider-gupshup/package.json | 2 +- packages/provider-instagram/package.json | 2 +- packages/provider-meta/package.json | 2 +- packages/provider-sherpa/package.json | 2 +- packages/provider-telegram/package.json | 2 +- packages/provider-twilio/package.json | 2 +- packages/provider-venom/package.json | 2 +- packages/provider-web-whatsapp/package.json | 2 +- packages/provider-wppconnect/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lerna.json b/lerna.json index 6a8198c0a..9c1bc7b10 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index d7a84caaa..b8f0626b9 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index cbe0f1a30..2e7136c11 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index 08e38e78c..3aa19f36b 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index 27dc7cd3d..e7d39cd2b 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index 1f9bba843..9de64e76e 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "", "license": "ISC", "main": "dist/index.cjs", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index cafb5773d..aa81c7573 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index 4676464d7..cd3bfba7d 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index bc5908ea2..377800cea 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 3be32fb7a..97c8b6359 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index 869aa7f6f..5794e951d 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index 93ed7b15f..dffe573b1 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index 3bf41f84b..a59e35561 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index 2e169d752..8d6e44fc9 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index b3eb47af0..d2b732866 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index faecba0f1..8bb671afb 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index 3728c9fea..d040f6145 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index 5e8386e4a..b697aebf2 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index a22672b1b..c89da7269 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index af7d39b71..a0a80f8ee 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index 9c2611592..b890f3fb6 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index 5718b464f..c37aacc44 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index 3ed9b32df..0a3650e13 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Provider for Telegram", "keywords": [], "author": "", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index 78c7a5757..32659ddde 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index 074718389..7a32bddd8 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index 5eaf4b4a5..7791c154c 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index 4eb23c14f..7cbe657b2 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.9", + "version": "1.4.2-alpha.10", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", From 3b77e6fff63072bb12a8b1ad221fb228d223afd0 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Mon, 4 May 2026 16:17:27 +0200 Subject: [PATCH 25/31] feat(provider-instagram): enhance message --- packages/provider-baileys/__tests__/baileysProvider.test.ts | 1 + packages/provider-baileys/src/bailey.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/provider-baileys/__tests__/baileysProvider.test.ts b/packages/provider-baileys/__tests__/baileysProvider.test.ts index 69da5a87d..fc029f6ce 100644 --- a/packages/provider-baileys/__tests__/baileysProvider.test.ts +++ b/packages/provider-baileys/__tests__/baileysProvider.test.ts @@ -721,6 +721,7 @@ describe('#BaileysProvider', () => { expect(mockSendMessage).toHaveBeenCalledWith(number, { audio: { url: audioUrl }, ptt: true, + mimetype: 'audio/ogg; codecs=opus', }) }) }) diff --git a/packages/provider-baileys/src/bailey.ts b/packages/provider-baileys/src/bailey.ts index bfae03ef7..9ac46b9df 100644 --- a/packages/provider-baileys/src/bailey.ts +++ b/packages/provider-baileys/src/bailey.ts @@ -937,6 +937,7 @@ class BaileysProvider extends ProviderClass { const payload: AnyMediaMessageContent = { audio: { url: audioUrl }, ptt: true, + mimetype: 'audio/ogg; codecs=opus', } return this.vendor.sendMessage(number, payload) } From 8ad7ec7e890735f4305fd29736422d9df79e803c Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Mon, 4 May 2026 16:27:59 +0200 Subject: [PATCH 26/31] feat(provider-instagram): enhance message --- packages/bot/src/core/coreClass.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/bot/src/core/coreClass.ts b/packages/bot/src/core/coreClass.ts index 4f21ed09c..b17943591 100644 --- a/packages/bot/src/core/coreClass.ts +++ b/packages/bot/src/core/coreClass.ts @@ -64,17 +64,18 @@ class CoreClass

extends * - Setup provider * - Setup generalArgs */ - constructor(_flow: any, _database: D, _provider: P, _args: GeneralArgs) { + constructor(_flow: any, _database: D, _provider: P, _args: GeneralArgs | null | undefined) { super() this.flowClass = _flow this.database = _database this.provider = _provider + const args = _args ?? {} this.generalArgs = { ...this.generalArgs, - ..._args, + ...args, logs: { ...this.generalArgs.logs, - ..._args.logs, + ...(args.logs ?? {}), }, } From e7b0d9ea44866899efbf08800a4343ea6334e1c0 Mon Sep 17 00:00:00 2001 From: Leifer Mendez Date: Mon, 4 May 2026 16:35:47 +0200 Subject: [PATCH 27/31] v1.4.2-alpha.11 --- lerna.json | 2 +- packages/bot/package.json | 2 +- packages/cli/package.json | 2 +- packages/contexts-dialogflow-cx/package.json | 2 +- packages/contexts-dialogflow/package.json | 2 +- packages/create-builderbot/package.json | 2 +- packages/database-json/package.json | 2 +- packages/database-mongo/package.json | 2 +- packages/database-mysql/package.json | 2 +- packages/database-postgres/package.json | 2 +- packages/eslint-plugin-builderbot/package.json | 2 +- packages/manager/package.json | 2 +- packages/plugins/chatwoot/package.json | 2 +- packages/provider-baileys/package.json | 2 +- packages/provider-email/package.json | 2 +- packages/provider-evolution-api/package.json | 2 +- packages/provider-facebook-messenger/package.json | 2 +- packages/provider-gohighlevel/package.json | 2 +- packages/provider-gupshup/package.json | 2 +- packages/provider-instagram/package.json | 2 +- packages/provider-meta/package.json | 2 +- packages/provider-sherpa/package.json | 2 +- packages/provider-telegram/package.json | 2 +- packages/provider-twilio/package.json | 2 +- packages/provider-venom/package.json | 2 +- packages/provider-web-whatsapp/package.json | 2 +- packages/provider-wppconnect/package.json | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lerna.json b/lerna.json index 9c1bc7b10..afc21fa34 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "packages": [ "packages/bot", "packages/cli", diff --git a/packages/bot/package.json b/packages/bot/package.json index b8f0626b9..0744add37 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/bot", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "core typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2e7136c11..32c50274a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/cli", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json index 3aa19f36b..47920e33b 100644 --- a/packages/contexts-dialogflow-cx/package.json +++ b/packages/contexts-dialogflow-cx/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json index e7d39cd2b..79b5b1456 100644 --- a/packages/contexts-dialogflow/package.json +++ b/packages/contexts-dialogflow/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "contexts typescript", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json index 9de64e76e..34337a4e6 100644 --- a/packages/create-builderbot/package.json +++ b/packages/create-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "create-builderbot", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "", "license": "ISC", "main": "dist/index.cjs", diff --git a/packages/database-json/package.json b/packages/database-json/package.json index aa81c7573..2359a3727 100644 --- a/packages/database-json/package.json +++ b/packages/database-json/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-json", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Esto es el conector a json", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index cd3bfba7d..ba738fb45 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Esto es el conector a mongo DB", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json index 377800cea..2f1619f5d 100644 --- a/packages/database-mysql/package.json +++ b/packages/database-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Esto es el conector a Mysql", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 97c8b6359..f35bc6b29 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json index 5794e951d..b4db3cd7d 100644 --- a/packages/eslint-plugin-builderbot/package.json +++ b/packages/eslint-plugin-builderbot/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/manager/package.json b/packages/manager/package.json index dffe573b1..ffe03f7ee 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/manager", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Multi-tenant bot manager for BuilderBot", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json index a59e35561..6f449bf61 100644 --- a/packages/plugins/chatwoot/package.json +++ b/packages/plugins/chatwoot/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", "keywords": [ "chatwoot", diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index 8d6e44fc9..a2bfbd084 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json index d2b732866..de5044d26 100644 --- a/packages/provider-email/package.json +++ b/packages/provider-email/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Email provider for BuilderBot using IMAP/SMTP", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json index 8bb671afb..704d583df 100644 --- a/packages/provider-evolution-api/package.json +++ b/packages/provider-evolution-api/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "> TODO: description", "author": "aurik3 ", "homepage": "https://github.com/aurik3/bot-whatsapp#readme", diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index d040f6145..a5d884c30 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json index b697aebf2..f1d32e6e1 100644 --- a/packages/provider-gohighlevel/package.json +++ b/packages/provider-gohighlevel/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", "author": "codigoencasa", "homepage": "https://github.com/codigoencasa/builderbot#readme", diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json index c89da7269..c6743724b 100644 --- a/packages/provider-gupshup/package.json +++ b/packages/provider-gupshup/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Gupshup Provider for BuilderBot", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index a0a80f8ee..439fbf9af 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index b890f3fb6..8916804ac 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "> TODO: description", "author": "vicente1992 ", "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json index c37aacc44..e516b6702 100644 --- a/packages/provider-sherpa/package.json +++ b/packages/provider-sherpa/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index 0a3650e13..119792207 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Provider for Telegram", "keywords": [], "author": "", diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json index 32659ddde..dfe20bc49 100644 --- a/packages/provider-twilio/package.json +++ b/packages/provider-twilio/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "> TODO: description", "author": "Leifer Mendez ", "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json index 7a32bddd8..c9ebb4159 100644 --- a/packages/provider-venom/package.json +++ b/packages/provider-venom/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json index 7791c154c..d181201e3 100644 --- a/packages/provider-web-whatsapp/package.json +++ b/packages/provider-web-whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json index 7cbe657b2..bb91a39e2 100644 --- a/packages/provider-wppconnect/package.json +++ b/packages/provider-wppconnect/package.json @@ -1,6 +1,6 @@ { "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.10", + "version": "1.4.2-alpha.11", "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", "keywords": [], "author": "Leifer Mendez ", From c18afca2422a74c1179b9e282032db7456a8c5f3 Mon Sep 17 00:00:00 2001 From: japarradev Date: Sat, 6 Jun 2026 14:34:55 -0500 Subject: [PATCH 28/31] feat: ensure bsuid is included and add tracking fields Ensure the BSUID is implicitly included in the Meta provider lifecycle when it is missing from the incoming payload, and extend the provider context structure to capture new tracking properties from Click-to-WhatsApp ads, specifically mapping source_id and ctwa_clid (ads click ID) to support granular referral attribution. --- packages/bot/src/core/coreClass.ts | 8 ++++++- packages/bot/src/io/methods/toCtx.ts | 18 ++++++++++++++- packages/bot/src/types.ts | 6 +++++ .../src/facebook.events.ts | 9 ++++++++ .../src/instagram.events.ts | 9 ++++++++ packages/provider-meta/src/types.ts | 3 +++ .../src/utils/processIncomingMsg.ts | 23 +++++++++++-------- 7 files changed, 64 insertions(+), 12 deletions(-) diff --git a/packages/bot/src/core/coreClass.ts b/packages/bot/src/core/coreClass.ts index b17943591..0124e5b9a 100644 --- a/packages/bot/src/core/coreClass.ts +++ b/packages/bot/src/core/coreClass.ts @@ -142,7 +142,7 @@ class CoreClass

extends handleMsg = async (messageCtxInComing: MessageContextIncoming) => { logger.log(`[handleMsg]: `, messageCtxInComing) idleForCallback.stop(messageCtxInComing) - const { body, from } = messageCtxInComing + const { body, from, source_id, source_type, ctwa_id } = messageCtxInComing let msgToSend = [] let endFlowFlag = this.stateHandler.get(from)('__end_flow__') || false const fallBackFlag = false @@ -159,6 +159,9 @@ class CoreClass

extends body, from, prevRef: prevMsg.refSerialize, + source_id, + source_type, + ctwa_id, }) await this.database.save(ctxByNumber) } @@ -207,6 +210,9 @@ class CoreClass

extends keyword, index, options: { media, buttons, capture, delay }, + source_id, + source_type, + ctwa_id, }) } diff --git a/packages/bot/src/io/methods/toCtx.ts b/packages/bot/src/io/methods/toCtx.ts index a36f85f41..8b5ee6468 100644 --- a/packages/bot/src/io/methods/toCtx.ts +++ b/packages/bot/src/io/methods/toCtx.ts @@ -10,13 +10,26 @@ interface ToCtxParams { keyword?: string options?: Options index?: number + source_id?: string + source_type?: string + ctwa_id?: string } /** * @param params ToCtxParams * @returns Context */ -const toCtx = ({ body, from, prevRef, keyword, options = {}, index }: ToCtxParams): TContext => { +const toCtx = ({ + body, + from, + prevRef, + keyword, + options = {}, + index, + source_id, + source_type, + ctwa_id, +}: ToCtxParams): TContext => { return { ref: generateRef(), keyword: prevRef ?? keyword, @@ -24,6 +37,9 @@ const toCtx = ({ body, from, prevRef, keyword, options = {}, index }: ToCtxParam options: options, from, refSerialize: generateRefSerialize({ index, answer: body }), + source_id, + source_type, + ctwa_id, } } diff --git a/packages/bot/src/types.ts b/packages/bot/src/types.ts index 09a4933c7..7f9a47da5 100644 --- a/packages/bot/src/types.ts +++ b/packages/bot/src/types.ts @@ -96,6 +96,9 @@ export type MessageContextIncoming = { ref?: string body?: string host?: string + source_id?: string + source_type?: string + ctwa_id?: string } /** @@ -224,6 +227,9 @@ export interface TContext { keyword: string | string[] from?: string answer?: string | string[] + source_id?: string + source_type?: string + ctwa_id?: string refSerialize?: string endFlow?: boolean options: TCTXoptions diff --git a/packages/provider-facebook-messenger/src/facebook.events.ts b/packages/provider-facebook-messenger/src/facebook.events.ts index 6e32f5f83..36daba6f1 100644 --- a/packages/provider-facebook-messenger/src/facebook.events.ts +++ b/packages/provider-facebook-messenger/src/facebook.events.ts @@ -62,10 +62,19 @@ export class MessengerEvents extends EventEmitterClass { }, timestamp: messagingEvent.timestamp, messageId: messagingEvent.message?.mid || '', + } as { + body: string + from: string + name: string + host: { id: string; phone: string } + timestamp: number + messageId: string + data?: { media: { url: string } } } if (messagingEvent.message?.attachments && messagingEvent.message.attachments.length > 0) { const attachment = messagingEvent.message.attachments[0] + sendObj.data = { media: { url: attachment.payload.url } } switch (attachment.type) { case 'image': sendObj.body = utils.generateRefProvider('_event_media_') diff --git a/packages/provider-instagram/src/instagram.events.ts b/packages/provider-instagram/src/instagram.events.ts index 022541267..57bed8a3e 100644 --- a/packages/provider-instagram/src/instagram.events.ts +++ b/packages/provider-instagram/src/instagram.events.ts @@ -102,10 +102,19 @@ export class InstagramEvents extends EventEmitterClass { }, timestamp: messagingEvent.timestamp, messageId: messagingEvent.message?.mid || '', + } as { + body: string + from: string + name: string + host: { id: string; phone: string } + timestamp: number + messageId: string + data?: { media: { url: string } } } if (messagingEvent.message?.attachments && messagingEvent.message.attachments.length > 0) { const attachment = messagingEvent.message.attachments[0] + sendObj.data = { media: { url: attachment.payload.url } } switch (attachment.type) { case 'image': sendObj.body = utils.generateRefProvider('_event_media_') diff --git a/packages/provider-meta/src/types.ts b/packages/provider-meta/src/types.ts index 9fbbc51bd..0028da88a 100644 --- a/packages/provider-meta/src/types.ts +++ b/packages/provider-meta/src/types.ts @@ -113,6 +113,9 @@ export interface Message { id?: string caption?: string fromMe?: boolean + source_type?: string + source_id?: string + ctwa_id?: string } export interface ParamsIncomingMessage { diff --git a/packages/provider-meta/src/utils/processIncomingMsg.ts b/packages/provider-meta/src/utils/processIncomingMsg.ts index 346101644..2b1adc0dd 100644 --- a/packages/provider-meta/src/utils/processIncomingMsg.ts +++ b/packages/provider-meta/src/utils/processIncomingMsg.ts @@ -21,11 +21,14 @@ export const processIncomingMessage = async ({ case 'text': { responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, to, body: message.text?.body, name: pushName, pushName, + source_id: message?.referral?.source_id, + source_type: message?.referral?.source_type, + ctwa_id: message?.referral?.ctwa_clid, } break } @@ -51,7 +54,7 @@ export const processIncomingMessage = async ({ case 'button': { responseObj = { type: 'button', - from: message.from, + from: message.from || message.from_user_id, to, body: message.button?.text, payload: message.button?.payload, @@ -65,7 +68,7 @@ export const processIncomingMessage = async ({ const imageUrl = await getMediaUrl(version, message.image?.id, numberId, jwtToken) responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, url: imageUrl ?? fileData?.url, fileData, caption: message?.image?.caption, @@ -80,7 +83,7 @@ export const processIncomingMessage = async ({ const documentUrl = await getMediaUrl(version, message.document?.id, numberId, jwtToken) responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, url: documentUrl ?? fileData?.url, fileData, to, @@ -94,7 +97,7 @@ export const processIncomingMessage = async ({ const videoUrl = await getMediaUrl(version, message.video?.id, numberId, jwtToken) responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, url: videoUrl ?? fileData?.url, fileData, caption: message?.video?.caption, @@ -108,7 +111,7 @@ export const processIncomingMessage = async ({ case 'location': { responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, to, latitude: message.location.latitude, longitude: message.location.longitude, @@ -122,7 +125,7 @@ export const processIncomingMessage = async ({ const audioUrl = await getMediaUrl(version, message.audio?.id, numberId, jwtToken) responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, url: audioUrl ?? fileData?.url, fileData, to, @@ -136,7 +139,7 @@ export const processIncomingMessage = async ({ const stickerUrl = await getMediaUrl(version, message.sticker?.id, numberId, jwtToken) responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, url: stickerUrl ?? fileData?.url, fileData, to, @@ -150,7 +153,7 @@ export const processIncomingMessage = async ({ case 'contacts': { responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, contacts: [ { name: message.contacts[0].name, @@ -167,7 +170,7 @@ export const processIncomingMessage = async ({ case 'order': { responseObj = { type: message.type, - from: message.from, + from: message.from || message.from_user_id, to, order: { catalog_id: message.order.catalog_id, From 59e044cbce303472f7d3f509f6f4a4d397bd4a85 Mon Sep 17 00:00:00 2001 From: japarradev Date: Tue, 9 Jun 2026 11:37:23 -0500 Subject: [PATCH 29/31] chore: renombrar paquetes a mi scope de npm --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 58203be00..98255ad53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@builderbot/root", - "version": "1.3.10", + "name": "@builderbot-japcon/root", + "version": "1.0.0", "description": "Bot de wahtsapp open source para MVP o pequeños negocios", "main": "app.js", "private": true, From 3aa09314990f105dba67da6de9c7910685c87bdf Mon Sep 17 00:00:00 2001 From: japarradev Date: Tue, 9 Jun 2026 11:41:05 -0500 Subject: [PATCH 30/31] chore: renombrar paquetes a mi scope de npm --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 98255ad53..1ecb065cc 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@builderbot-japcon/root", + "name": "@japcon-bot/root", "version": "1.0.0", "description": "Bot de wahtsapp open source para MVP o pequeños negocios", "main": "app.js", From 7671ce73d0f42aedfe875ce140c581be88de2933 Mon Sep 17 00:00:00 2001 From: japarradev Date: Tue, 9 Jun 2026 14:57:47 -0500 Subject: [PATCH 31/31] chore: renombrar paquetes a mi scope de npm --- packages/bot/package.json | 128 +- packages/cli/CHANGELOG.md | 8 - packages/cli/LICENSE.md | 21 - packages/cli/README.md | 21 - packages/cli/_test_/check.test.ts | 46 - packages/cli/_test_/clean.test.ts | 31 - packages/cli/_test_/configuration.test.ts | 94 -- packages/cli/bin/cli.cjs | 3 - packages/cli/package.json | 45 - packages/cli/pkg-to-update.json | 3 - packages/cli/rollup.config.js | 33 - packages/cli/src/check/index.ts | 43 - packages/cli/src/clean/index.ts | 20 - packages/cli/src/configuration/index.ts | 90 - packages/cli/src/create-app/index.ts | 27 - packages/cli/src/index.ts | 3 - packages/cli/src/install/index.ts | 32 - packages/cli/src/install/tool.ts | 63 - packages/cli/src/interactive-legacy/index.ts | 137 -- packages/cli/src/interactive/index.ts | 286 ---- packages/cli/tsconfig.json | 18 - packages/contexts-dialogflow-cx/LICENSE.md | 21 - packages/contexts-dialogflow-cx/README.md | 21 - .../__tests__/dialogflow-cx.class.test.ts | 283 ---- packages/contexts-dialogflow-cx/package.json | 48 - .../contexts-dialogflow-cx/rollup.config.js | 19 - .../src/dialogflow-cx/dialogflow-cx.class.ts | 125 -- .../src/dialogflow-cx/index.ts | 12 - packages/contexts-dialogflow-cx/src/index.ts | 1 - packages/contexts-dialogflow-cx/src/types.ts | 44 - packages/contexts-dialogflow-cx/tsconfig.json | 26 - packages/contexts-dialogflow/LICENSE.md | 21 - packages/contexts-dialogflow/README.md | 21 - .../__tests__/dialogflow.class.test.ts | 158 -- packages/contexts-dialogflow/package.json | 48 - packages/contexts-dialogflow/rollup.config.js | 19 - .../src/dialogflow/dialogflow.class.ts | 132 -- .../src/dialogflow/index.ts | 12 - packages/contexts-dialogflow/src/index.ts | 1 - .../contexts-dialogflow/src/mock/index.ts | 10 - .../src/mock/mock.class.ts | 18 - packages/contexts-dialogflow/src/types.ts | 26 - packages/contexts-dialogflow/tsconfig.json | 17 - packages/create-builderbot/CHANGELOG.md | 8 - packages/create-builderbot/LICENSE.md | 21 - packages/create-builderbot/README.md | 21 - packages/create-builderbot/bin/create.cjs | 4 - packages/create-builderbot/package.json | 34 - packages/create-builderbot/rollup.config.js | 50 - packages/create-builderbot/src/index.ts | 8 - packages/create-builderbot/tsconfig.json | 17 - packages/database-json/LICENSE.md | 21 - packages/database-json/README.md | 21 - .../database-json/__tests__/debounce.test.ts | 190 --- .../__tests__/exhaustive.test.ts | 447 ----- .../__tests__/jsonAdapter.test.ts | 98 -- .../database-json/__tests__/stress.test.ts | 265 --- packages/database-json/package.json | 47 - packages/database-json/rollup.config.js | 21 - packages/database-json/src/index.ts | 184 --- packages/database-json/src/types.ts | 18 - packages/database-json/tsconfig.json | 17 - packages/database-mongo/LICENSE.md | 21 - packages/database-mongo/README.md | 21 - .../__tests__/mongoAdapter.test.ts | 114 -- .../__tests__/mongoEdgeCases.test.ts | 165 -- packages/database-mongo/package.json | 51 - packages/database-mongo/rollup.config.js | 21 - packages/database-mongo/src/index.ts | 1 - packages/database-mongo/src/mongoAdapter.ts | 62 - packages/database-mongo/src/types.ts | 14 - packages/database-mongo/tsconfig.json | 17 - packages/database-mysql/LICENSE.md | 21 - packages/database-mysql/README.md | 21 - .../__tests__/mysqlAdapter.test.ts | 251 --- .../__tests__/mysqlEdgeCases.test.ts | 296 ---- packages/database-mysql/package.json | 50 - packages/database-mysql/rollup.config.js | 21 - packages/database-mysql/src/index.ts | 1 - packages/database-mysql/src/mysqlAdapter.ts | 111 -- packages/database-mysql/src/types.ts | 20 - packages/database-mysql/tsconfig.json | 17 - packages/database-postgres/LICENSE.md | 21 - packages/database-postgres/README.md | 21 - .../__tests__/ postgresAdapter.test.ts | 303 ---- .../__tests__/postgresEdgeCases.test.ts | 275 --- packages/database-postgres/package.json | 50 - packages/database-postgres/rollup.config.js | 23 - packages/database-postgres/src/index.ts | 1 - .../database-postgres/src/postgresAdapter.ts | 206 --- packages/database-postgres/src/types.ts | 25 - packages/database-postgres/tsconfig.json | 17 - packages/eslint-plugin-builderbot/LICENSE.md | 21 - packages/eslint-plugin-builderbot/README.md | 21 - .../isInsideAddActionOrAddAnswer.test.ts | 30 - .../__tests__/processDynamicFlowAwait.test.ts | 78 - .../__tests__/processEndFlowReturn.test.ts | 77 - .../processEndFlowWithFlowDynamic.test.ts | 67 - .../__tests__/processFallBackReturn.test.ts | 77 - .../__tests__/processGotoFlowReturn.test.ts | 77 - .../__tests__/processStateUpdateAwait.test.ts | 127 -- .../eslint-plugin-builderbot/package.json | 46 - .../eslint-plugin-builderbot/rollup.config.js | 14 - .../src/configs/recommended.ts | 8 - .../eslint-plugin-builderbot/src/index.ts | 55 - .../src/rules/index.ts | 6 - .../src/rules/processDynamicFlowAwait.ts | 28 - .../src/rules/processEndFlowReturn.ts | 28 - .../rules/processEndFlowWithFlowDynamic.ts | 30 - .../src/rules/processFallBackReturn.ts | 28 - .../src/rules/processGotoFlowReturn.ts | 28 - .../src/rules/processStateUpdateAwait.ts | 48 - .../eslint-plugin-builderbot/src/types.ts | 32 - .../src/utils/index.ts | 1 - .../src/utils/isInsideAddActionOrAddAnswer.ts | 19 - .../eslint-plugin-builderbot/tsconfig.json | 17 - packages/manager/package.json | 134 +- packages/plugins/.gitkeep | 0 packages/plugins/chatwoot/README.md | 330 ---- .../chatwoot/__tests__/chatwootPlugin.test.ts | 801 --------- packages/plugins/chatwoot/package.json | 57 - packages/plugins/chatwoot/rollup.config.js | 26 - packages/plugins/chatwoot/src/chatwootApi.ts | 324 ---- .../plugins/chatwoot/src/chatwootPlugin.ts | 412 ----- packages/plugins/chatwoot/src/index.ts | 3 - packages/plugins/chatwoot/src/types.ts | 129 -- packages/plugins/chatwoot/tsconfig.json | 18 - packages/provider-baileys/package.json | 158 +- packages/provider-email/LICENSE.md | 21 - packages/provider-email/README.md | 254 --- .../provider-email/__tests__/core.test.ts | 457 ----- .../__tests__/emailProvider.test.ts | 311 ---- .../provider-email/__tests__/utils.test.ts | 250 --- packages/provider-email/jest.config.ts | 14 - packages/provider-email/package.json | 52 - packages/provider-email/rollup.config.js | 24 - packages/provider-email/src/email/core.ts | 522 ------ packages/provider-email/src/email/provider.ts | 287 ---- packages/provider-email/src/index.ts | 14 - .../provider-email/src/interface/email.ts | 70 - packages/provider-email/src/types.ts | 177 -- packages/provider-email/src/utils/index.ts | 17 - packages/provider-email/src/utils/parser.ts | 247 --- packages/provider-email/tsconfig.json | 17 - packages/provider-evolution-api/LICENSE.md | 21 - packages/provider-evolution-api/README.md | 21 - .../provider-evolution-api/__mock__/http.ts | 5 - .../__tests__/provider.test.ts | 104 -- .../__tests__/utils.test.ts | 69 - .../provider-evolution-api/jest.config.ts | 14 - packages/provider-evolution-api/package.json | 69 - .../provider-evolution-api/rollup.config.js | 25 - .../src/evolution/core.ts | 210 --- .../src/evolution/provider.ts | 500 ------ packages/provider-evolution-api/src/index.ts | 1 - .../src/interface/evolution.ts | 24 - packages/provider-evolution-api/src/types.ts | 166 -- .../src/utils/download.ts | 105 -- .../provider-evolution-api/src/utils/index.ts | 1 - packages/provider-evolution-api/tsconfig.json | 20 - .../provider-facebook-messenger/package.json | 110 +- packages/provider-gohighlevel/README.md | 463 ------ .../__tests__/core.test.ts | 322 ---- .../__tests__/provider.test.ts | 337 ---- .../__tests__/utils.test.ts | 621 ------- packages/provider-gohighlevel/jest.config.ts | 10 - packages/provider-gohighlevel/package.json | 57 - .../provider-gohighlevel/rollup.config.js | 23 - .../src/gohighlevel/core.ts | 160 -- .../src/gohighlevel/provider.ts | 520 ------ packages/provider-gohighlevel/src/index.ts | 7 - .../src/interface/gohighlevel.ts | 13 - packages/provider-gohighlevel/src/types.ts | 135 -- .../src/utils/channelLister.ts | 144 -- .../src/utils/contactResolver.ts | 68 - .../src/utils/downloadFile.ts | 27 - .../provider-gohighlevel/src/utils/index.ts | 7 - .../provider-gohighlevel/src/utils/number.ts | 6 - .../src/utils/processIncomingMsg.ts | 72 - .../src/utils/tokenManager.ts | 172 -- .../src/utils/webhookVerification.ts | 64 - packages/provider-gohighlevel/tsconfig.json | 20 - packages/provider-gupshup/LICENSE.md | 21 - packages/provider-gupshup/README.md | 60 - .../provider-gupshup/__tests__/core.test.ts | 1064 ------------ .../__tests__/processIncomingMsg.test.ts | 480 ------ .../__tests__/provider.test.ts | 1472 ----------------- packages/provider-gupshup/jest.config.ts | 22 - packages/provider-gupshup/package.json | 44 - packages/provider-gupshup/rollup.config.js | 23 - packages/provider-gupshup/src/gupshup/core.ts | 276 ---- .../provider-gupshup/src/gupshup/provider.ts | 1261 -------------- packages/provider-gupshup/src/index.ts | 4 - packages/provider-gupshup/src/types.ts | 365 ---- packages/provider-gupshup/src/utils/media.ts | 89 - .../src/utils/processIncomingMsg.ts | 196 --- packages/provider-gupshup/tsconfig.json | 20 - packages/provider-instagram/package.json | 114 +- packages/provider-meta/package.json | 126 +- packages/provider-sherpa/LICENSE.md | 21 - packages/provider-sherpa/README.md | 21 - .../__tests__/sherpaProvider.test.ts | 1312 --------------- .../provider-sherpa/__tests__/utils.test.ts | 163 -- .../provider-sherpa/config/api-extractor.json | 39 - packages/provider-sherpa/jest.config.ts | 15 - packages/provider-sherpa/package.json | 84 - packages/provider-sherpa/rollup.config.js | 33 - packages/provider-sherpa/src/index.ts | 4 - packages/provider-sherpa/src/releaseTmp.ts | 40 - packages/provider-sherpa/src/sherpa.ts | 1292 --------------- packages/provider-sherpa/src/sherpaWrapper.ts | 36 - packages/provider-sherpa/src/type.ts | 18 - packages/provider-sherpa/src/utils.ts | 78 - packages/provider-sherpa/tsconfig.json | 20 - packages/provider-telegram/package.json | 102 +- packages/provider-twilio/CHANGELOG.md | 8 - packages/provider-twilio/LICENSE.md | 21 - packages/provider-twilio/README.md | 21 - .../provider-twilio/__tests__/core.test.ts | 362 ---- .../__tests__/provider.test.ts | 455 ----- .../provider-twilio/__tests__/util.test.ts | 62 - .../provider-twilio/config/api-extractor.json | 39 - packages/provider-twilio/jest.config.ts | 14 - packages/provider-twilio/package.json | 60 - packages/provider-twilio/rollup.config.js | 24 - packages/provider-twilio/src/index.ts | 1 - .../provider-twilio/src/interface/twilio.ts | 9 - packages/provider-twilio/src/twilio/core.ts | 104 -- .../provider-twilio/src/twilio/provider.ts | 206 --- packages/provider-twilio/src/types.ts | 32 - packages/provider-twilio/src/utils.ts | 10 - packages/provider-twilio/tsconfig.json | 17 - packages/provider-venom/LICENSE.md | 21 - packages/provider-venom/README.md | 21 - .../provider-venom/__tests__/provider.test.ts | 639 ------- .../provider-venom/__tests__/utils.test.ts | 213 --- packages/provider-venom/jest.config.ts | 14 - packages/provider-venom/package.json | 63 - packages/provider-venom/rollup.config.js | 25 - packages/provider-venom/scripts/fix.js | 42 - packages/provider-venom/src/index.ts | 339 ---- packages/provider-venom/src/types.ts | 6 - packages/provider-venom/src/utils.ts | 100 -- packages/provider-venom/tsconfig.json | 17 - packages/provider-web-whatsapp/CHANGELOG.md | 8 - packages/provider-web-whatsapp/LICENSE.md | 21 - packages/provider-web-whatsapp/README.md | 21 - .../__tests__/provider.test.ts | 575 ------- .../__tests__/utils.test.ts | 244 --- .../config/api-extractor.json | 39 - packages/provider-web-whatsapp/jest.config.ts | 14 - packages/provider-web-whatsapp/package.json | 60 - .../provider-web-whatsapp/rollup.config.js | 23 - packages/provider-web-whatsapp/src/index.ts | 340 ---- packages/provider-web-whatsapp/src/types.ts | 3 - packages/provider-web-whatsapp/src/utils.ts | 114 -- packages/provider-web-whatsapp/tsconfig.json | 17 - packages/provider-wppconnect/CHANGELOG.md | 8 - packages/provider-wppconnect/LICENSE.md | 21 - packages/provider-wppconnect/README.md | 21 - .../__tests__/provider.test.ts | 661 -------- .../__tests__/utils.test.ts | 225 --- .../config/api-extractor.json | 39 - packages/provider-wppconnect/jest.config.ts | 14 - packages/provider-wppconnect/package.json | 59 - packages/provider-wppconnect/rollup.config.js | 23 - packages/provider-wppconnect/src/index.ts | 335 ---- packages/provider-wppconnect/src/types.ts | 11 - packages/provider-wppconnect/src/utils.ts | 75 - packages/provider-wppconnect/tsconfig.json | 26 - renamer.js | 75 + 271 files changed, 511 insertions(+), 31188 deletions(-) delete mode 100644 packages/cli/CHANGELOG.md delete mode 100644 packages/cli/LICENSE.md delete mode 100644 packages/cli/README.md delete mode 100644 packages/cli/_test_/check.test.ts delete mode 100644 packages/cli/_test_/clean.test.ts delete mode 100644 packages/cli/_test_/configuration.test.ts delete mode 100755 packages/cli/bin/cli.cjs delete mode 100644 packages/cli/package.json delete mode 100644 packages/cli/pkg-to-update.json delete mode 100644 packages/cli/rollup.config.js delete mode 100644 packages/cli/src/check/index.ts delete mode 100644 packages/cli/src/clean/index.ts delete mode 100644 packages/cli/src/configuration/index.ts delete mode 100644 packages/cli/src/create-app/index.ts delete mode 100644 packages/cli/src/index.ts delete mode 100644 packages/cli/src/install/index.ts delete mode 100644 packages/cli/src/install/tool.ts delete mode 100644 packages/cli/src/interactive-legacy/index.ts delete mode 100644 packages/cli/src/interactive/index.ts delete mode 100644 packages/cli/tsconfig.json delete mode 100644 packages/contexts-dialogflow-cx/LICENSE.md delete mode 100644 packages/contexts-dialogflow-cx/README.md delete mode 100644 packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts delete mode 100644 packages/contexts-dialogflow-cx/package.json delete mode 100644 packages/contexts-dialogflow-cx/rollup.config.js delete mode 100644 packages/contexts-dialogflow-cx/src/dialogflow-cx/dialogflow-cx.class.ts delete mode 100644 packages/contexts-dialogflow-cx/src/dialogflow-cx/index.ts delete mode 100644 packages/contexts-dialogflow-cx/src/index.ts delete mode 100644 packages/contexts-dialogflow-cx/src/types.ts delete mode 100644 packages/contexts-dialogflow-cx/tsconfig.json delete mode 100644 packages/contexts-dialogflow/LICENSE.md delete mode 100644 packages/contexts-dialogflow/README.md delete mode 100644 packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts delete mode 100644 packages/contexts-dialogflow/package.json delete mode 100644 packages/contexts-dialogflow/rollup.config.js delete mode 100644 packages/contexts-dialogflow/src/dialogflow/dialogflow.class.ts delete mode 100644 packages/contexts-dialogflow/src/dialogflow/index.ts delete mode 100644 packages/contexts-dialogflow/src/index.ts delete mode 100644 packages/contexts-dialogflow/src/mock/index.ts delete mode 100644 packages/contexts-dialogflow/src/mock/mock.class.ts delete mode 100644 packages/contexts-dialogflow/src/types.ts delete mode 100644 packages/contexts-dialogflow/tsconfig.json delete mode 100644 packages/create-builderbot/CHANGELOG.md delete mode 100644 packages/create-builderbot/LICENSE.md delete mode 100644 packages/create-builderbot/README.md delete mode 100644 packages/create-builderbot/bin/create.cjs delete mode 100644 packages/create-builderbot/package.json delete mode 100644 packages/create-builderbot/rollup.config.js delete mode 100644 packages/create-builderbot/src/index.ts delete mode 100644 packages/create-builderbot/tsconfig.json delete mode 100644 packages/database-json/LICENSE.md delete mode 100644 packages/database-json/README.md delete mode 100644 packages/database-json/__tests__/debounce.test.ts delete mode 100644 packages/database-json/__tests__/exhaustive.test.ts delete mode 100644 packages/database-json/__tests__/jsonAdapter.test.ts delete mode 100644 packages/database-json/__tests__/stress.test.ts delete mode 100644 packages/database-json/package.json delete mode 100644 packages/database-json/rollup.config.js delete mode 100644 packages/database-json/src/index.ts delete mode 100644 packages/database-json/src/types.ts delete mode 100644 packages/database-json/tsconfig.json delete mode 100644 packages/database-mongo/LICENSE.md delete mode 100644 packages/database-mongo/README.md delete mode 100644 packages/database-mongo/__tests__/mongoAdapter.test.ts delete mode 100644 packages/database-mongo/__tests__/mongoEdgeCases.test.ts delete mode 100644 packages/database-mongo/package.json delete mode 100644 packages/database-mongo/rollup.config.js delete mode 100644 packages/database-mongo/src/index.ts delete mode 100644 packages/database-mongo/src/mongoAdapter.ts delete mode 100644 packages/database-mongo/src/types.ts delete mode 100644 packages/database-mongo/tsconfig.json delete mode 100644 packages/database-mysql/LICENSE.md delete mode 100644 packages/database-mysql/README.md delete mode 100644 packages/database-mysql/__tests__/mysqlAdapter.test.ts delete mode 100644 packages/database-mysql/__tests__/mysqlEdgeCases.test.ts delete mode 100644 packages/database-mysql/package.json delete mode 100644 packages/database-mysql/rollup.config.js delete mode 100644 packages/database-mysql/src/index.ts delete mode 100644 packages/database-mysql/src/mysqlAdapter.ts delete mode 100644 packages/database-mysql/src/types.ts delete mode 100644 packages/database-mysql/tsconfig.json delete mode 100644 packages/database-postgres/LICENSE.md delete mode 100644 packages/database-postgres/README.md delete mode 100644 packages/database-postgres/__tests__/ postgresAdapter.test.ts delete mode 100644 packages/database-postgres/__tests__/postgresEdgeCases.test.ts delete mode 100644 packages/database-postgres/package.json delete mode 100644 packages/database-postgres/rollup.config.js delete mode 100644 packages/database-postgres/src/index.ts delete mode 100644 packages/database-postgres/src/postgresAdapter.ts delete mode 100644 packages/database-postgres/src/types.ts delete mode 100644 packages/database-postgres/tsconfig.json delete mode 100644 packages/eslint-plugin-builderbot/LICENSE.md delete mode 100644 packages/eslint-plugin-builderbot/README.md delete mode 100644 packages/eslint-plugin-builderbot/__tests__/isInsideAddActionOrAddAnswer.test.ts delete mode 100644 packages/eslint-plugin-builderbot/__tests__/processDynamicFlowAwait.test.ts delete mode 100644 packages/eslint-plugin-builderbot/__tests__/processEndFlowReturn.test.ts delete mode 100644 packages/eslint-plugin-builderbot/__tests__/processEndFlowWithFlowDynamic.test.ts delete mode 100644 packages/eslint-plugin-builderbot/__tests__/processFallBackReturn.test.ts delete mode 100644 packages/eslint-plugin-builderbot/__tests__/processGotoFlowReturn.test.ts delete mode 100644 packages/eslint-plugin-builderbot/__tests__/processStateUpdateAwait.test.ts delete mode 100644 packages/eslint-plugin-builderbot/package.json delete mode 100644 packages/eslint-plugin-builderbot/rollup.config.js delete mode 100644 packages/eslint-plugin-builderbot/src/configs/recommended.ts delete mode 100644 packages/eslint-plugin-builderbot/src/index.ts delete mode 100644 packages/eslint-plugin-builderbot/src/rules/index.ts delete mode 100644 packages/eslint-plugin-builderbot/src/rules/processDynamicFlowAwait.ts delete mode 100644 packages/eslint-plugin-builderbot/src/rules/processEndFlowReturn.ts delete mode 100644 packages/eslint-plugin-builderbot/src/rules/processEndFlowWithFlowDynamic.ts delete mode 100644 packages/eslint-plugin-builderbot/src/rules/processFallBackReturn.ts delete mode 100644 packages/eslint-plugin-builderbot/src/rules/processGotoFlowReturn.ts delete mode 100644 packages/eslint-plugin-builderbot/src/rules/processStateUpdateAwait.ts delete mode 100644 packages/eslint-plugin-builderbot/src/types.ts delete mode 100644 packages/eslint-plugin-builderbot/src/utils/index.ts delete mode 100644 packages/eslint-plugin-builderbot/src/utils/isInsideAddActionOrAddAnswer.ts delete mode 100644 packages/eslint-plugin-builderbot/tsconfig.json delete mode 100644 packages/plugins/.gitkeep delete mode 100644 packages/plugins/chatwoot/README.md delete mode 100644 packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts delete mode 100644 packages/plugins/chatwoot/package.json delete mode 100644 packages/plugins/chatwoot/rollup.config.js delete mode 100644 packages/plugins/chatwoot/src/chatwootApi.ts delete mode 100644 packages/plugins/chatwoot/src/chatwootPlugin.ts delete mode 100644 packages/plugins/chatwoot/src/index.ts delete mode 100644 packages/plugins/chatwoot/src/types.ts delete mode 100644 packages/plugins/chatwoot/tsconfig.json delete mode 100644 packages/provider-email/LICENSE.md delete mode 100644 packages/provider-email/README.md delete mode 100644 packages/provider-email/__tests__/core.test.ts delete mode 100644 packages/provider-email/__tests__/emailProvider.test.ts delete mode 100644 packages/provider-email/__tests__/utils.test.ts delete mode 100644 packages/provider-email/jest.config.ts delete mode 100644 packages/provider-email/package.json delete mode 100644 packages/provider-email/rollup.config.js delete mode 100644 packages/provider-email/src/email/core.ts delete mode 100644 packages/provider-email/src/email/provider.ts delete mode 100644 packages/provider-email/src/index.ts delete mode 100644 packages/provider-email/src/interface/email.ts delete mode 100644 packages/provider-email/src/types.ts delete mode 100644 packages/provider-email/src/utils/index.ts delete mode 100644 packages/provider-email/src/utils/parser.ts delete mode 100644 packages/provider-email/tsconfig.json delete mode 100644 packages/provider-evolution-api/LICENSE.md delete mode 100644 packages/provider-evolution-api/README.md delete mode 100644 packages/provider-evolution-api/__mock__/http.ts delete mode 100644 packages/provider-evolution-api/__tests__/provider.test.ts delete mode 100644 packages/provider-evolution-api/__tests__/utils.test.ts delete mode 100644 packages/provider-evolution-api/jest.config.ts delete mode 100644 packages/provider-evolution-api/package.json delete mode 100644 packages/provider-evolution-api/rollup.config.js delete mode 100644 packages/provider-evolution-api/src/evolution/core.ts delete mode 100644 packages/provider-evolution-api/src/evolution/provider.ts delete mode 100644 packages/provider-evolution-api/src/index.ts delete mode 100644 packages/provider-evolution-api/src/interface/evolution.ts delete mode 100644 packages/provider-evolution-api/src/types.ts delete mode 100644 packages/provider-evolution-api/src/utils/download.ts delete mode 100644 packages/provider-evolution-api/src/utils/index.ts delete mode 100644 packages/provider-evolution-api/tsconfig.json delete mode 100644 packages/provider-gohighlevel/README.md delete mode 100644 packages/provider-gohighlevel/__tests__/core.test.ts delete mode 100644 packages/provider-gohighlevel/__tests__/provider.test.ts delete mode 100644 packages/provider-gohighlevel/__tests__/utils.test.ts delete mode 100644 packages/provider-gohighlevel/jest.config.ts delete mode 100644 packages/provider-gohighlevel/package.json delete mode 100644 packages/provider-gohighlevel/rollup.config.js delete mode 100644 packages/provider-gohighlevel/src/gohighlevel/core.ts delete mode 100644 packages/provider-gohighlevel/src/gohighlevel/provider.ts delete mode 100644 packages/provider-gohighlevel/src/index.ts delete mode 100644 packages/provider-gohighlevel/src/interface/gohighlevel.ts delete mode 100644 packages/provider-gohighlevel/src/types.ts delete mode 100644 packages/provider-gohighlevel/src/utils/channelLister.ts delete mode 100644 packages/provider-gohighlevel/src/utils/contactResolver.ts delete mode 100644 packages/provider-gohighlevel/src/utils/downloadFile.ts delete mode 100644 packages/provider-gohighlevel/src/utils/index.ts delete mode 100644 packages/provider-gohighlevel/src/utils/number.ts delete mode 100644 packages/provider-gohighlevel/src/utils/processIncomingMsg.ts delete mode 100644 packages/provider-gohighlevel/src/utils/tokenManager.ts delete mode 100644 packages/provider-gohighlevel/src/utils/webhookVerification.ts delete mode 100644 packages/provider-gohighlevel/tsconfig.json delete mode 100644 packages/provider-gupshup/LICENSE.md delete mode 100644 packages/provider-gupshup/README.md delete mode 100644 packages/provider-gupshup/__tests__/core.test.ts delete mode 100644 packages/provider-gupshup/__tests__/processIncomingMsg.test.ts delete mode 100644 packages/provider-gupshup/__tests__/provider.test.ts delete mode 100644 packages/provider-gupshup/jest.config.ts delete mode 100644 packages/provider-gupshup/package.json delete mode 100644 packages/provider-gupshup/rollup.config.js delete mode 100644 packages/provider-gupshup/src/gupshup/core.ts delete mode 100644 packages/provider-gupshup/src/gupshup/provider.ts delete mode 100644 packages/provider-gupshup/src/index.ts delete mode 100644 packages/provider-gupshup/src/types.ts delete mode 100644 packages/provider-gupshup/src/utils/media.ts delete mode 100644 packages/provider-gupshup/src/utils/processIncomingMsg.ts delete mode 100644 packages/provider-gupshup/tsconfig.json delete mode 100644 packages/provider-sherpa/LICENSE.md delete mode 100644 packages/provider-sherpa/README.md delete mode 100644 packages/provider-sherpa/__tests__/sherpaProvider.test.ts delete mode 100644 packages/provider-sherpa/__tests__/utils.test.ts delete mode 100644 packages/provider-sherpa/config/api-extractor.json delete mode 100644 packages/provider-sherpa/jest.config.ts delete mode 100644 packages/provider-sherpa/package.json delete mode 100644 packages/provider-sherpa/rollup.config.js delete mode 100644 packages/provider-sherpa/src/index.ts delete mode 100644 packages/provider-sherpa/src/releaseTmp.ts delete mode 100644 packages/provider-sherpa/src/sherpa.ts delete mode 100644 packages/provider-sherpa/src/sherpaWrapper.ts delete mode 100644 packages/provider-sherpa/src/type.ts delete mode 100644 packages/provider-sherpa/src/utils.ts delete mode 100644 packages/provider-sherpa/tsconfig.json delete mode 100644 packages/provider-twilio/CHANGELOG.md delete mode 100644 packages/provider-twilio/LICENSE.md delete mode 100644 packages/provider-twilio/README.md delete mode 100644 packages/provider-twilio/__tests__/core.test.ts delete mode 100644 packages/provider-twilio/__tests__/provider.test.ts delete mode 100644 packages/provider-twilio/__tests__/util.test.ts delete mode 100644 packages/provider-twilio/config/api-extractor.json delete mode 100644 packages/provider-twilio/jest.config.ts delete mode 100644 packages/provider-twilio/package.json delete mode 100644 packages/provider-twilio/rollup.config.js delete mode 100644 packages/provider-twilio/src/index.ts delete mode 100644 packages/provider-twilio/src/interface/twilio.ts delete mode 100644 packages/provider-twilio/src/twilio/core.ts delete mode 100644 packages/provider-twilio/src/twilio/provider.ts delete mode 100644 packages/provider-twilio/src/types.ts delete mode 100644 packages/provider-twilio/src/utils.ts delete mode 100644 packages/provider-twilio/tsconfig.json delete mode 100644 packages/provider-venom/LICENSE.md delete mode 100644 packages/provider-venom/README.md delete mode 100644 packages/provider-venom/__tests__/provider.test.ts delete mode 100644 packages/provider-venom/__tests__/utils.test.ts delete mode 100644 packages/provider-venom/jest.config.ts delete mode 100644 packages/provider-venom/package.json delete mode 100644 packages/provider-venom/rollup.config.js delete mode 100644 packages/provider-venom/scripts/fix.js delete mode 100644 packages/provider-venom/src/index.ts delete mode 100644 packages/provider-venom/src/types.ts delete mode 100644 packages/provider-venom/src/utils.ts delete mode 100644 packages/provider-venom/tsconfig.json delete mode 100644 packages/provider-web-whatsapp/CHANGELOG.md delete mode 100644 packages/provider-web-whatsapp/LICENSE.md delete mode 100644 packages/provider-web-whatsapp/README.md delete mode 100644 packages/provider-web-whatsapp/__tests__/provider.test.ts delete mode 100644 packages/provider-web-whatsapp/__tests__/utils.test.ts delete mode 100644 packages/provider-web-whatsapp/config/api-extractor.json delete mode 100644 packages/provider-web-whatsapp/jest.config.ts delete mode 100644 packages/provider-web-whatsapp/package.json delete mode 100644 packages/provider-web-whatsapp/rollup.config.js delete mode 100644 packages/provider-web-whatsapp/src/index.ts delete mode 100644 packages/provider-web-whatsapp/src/types.ts delete mode 100644 packages/provider-web-whatsapp/src/utils.ts delete mode 100644 packages/provider-web-whatsapp/tsconfig.json delete mode 100644 packages/provider-wppconnect/CHANGELOG.md delete mode 100644 packages/provider-wppconnect/LICENSE.md delete mode 100644 packages/provider-wppconnect/README.md delete mode 100644 packages/provider-wppconnect/__tests__/provider.test.ts delete mode 100644 packages/provider-wppconnect/__tests__/utils.test.ts delete mode 100644 packages/provider-wppconnect/config/api-extractor.json delete mode 100644 packages/provider-wppconnect/jest.config.ts delete mode 100644 packages/provider-wppconnect/package.json delete mode 100644 packages/provider-wppconnect/rollup.config.js delete mode 100644 packages/provider-wppconnect/src/index.ts delete mode 100644 packages/provider-wppconnect/src/types.ts delete mode 100644 packages/provider-wppconnect/src/utils.ts delete mode 100644 packages/provider-wppconnect/tsconfig.json create mode 100644 renamer.js diff --git a/packages/bot/package.json b/packages/bot/package.json index 0744add37..ac8868fb8 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,66 +1,66 @@ { - "name": "@builderbot/bot", - "version": "1.4.2-alpha.11", - "description": "core typescript", - "author": "Leifer Mendez ", - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": " npx uvu -r tsm ./__tests__ ", - "test:coverage": "npx c8 npm run test " - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "devDependencies": { - "@microsoft/api-extractor": "^7.55.2", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/body-parser": "^1.19.6", - "@types/cors": "^2.8.19", - "@types/fluent-ffmpeg": "^2.1.28", - "@types/follow-redirects": "^1.14.4", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.8", - "@types/proxyquire": "^1.3.31", - "@types/sinon": "^17.0.4", - "proxyquire": "^2.1.3", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "tslib": "^2.8.1", - "tsm": "^2.3.0" - }, - "dependencies": { - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "body-parser": "^2.2.1", - "cors": "^2.8.5", - "fluent-ffmpeg": "^2.1.3", - "follow-redirects": "^1.15.11", - "mime-types": "^3.0.2", - "picocolors": "^1.1.1", - "polka": "^0.5.2" - }, - "optionalDependencies": { - "sharp": "0.33.3" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" + "name": "@japcon-bot/bot", + "version": "1.4.2-alpha.11", + "description": "core typescript", + "author": "Leifer Mendez ", + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": " npx uvu -r tsm ./__tests__ ", + "test:coverage": "npx c8 npm run test " + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.55.2", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-terser": "^0.4.4", + "@types/body-parser": "^1.19.6", + "@types/cors": "^2.8.19", + "@types/fluent-ffmpeg": "^2.1.28", + "@types/follow-redirects": "^1.14.4", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/polka": "^0.5.8", + "@types/proxyquire": "^1.3.31", + "@types/sinon": "^17.0.4", + "proxyquire": "^2.1.3", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "sinon": "^17.0.1", + "tslib": "^2.8.1", + "tsm": "^2.3.0" + }, + "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "body-parser": "^2.2.1", + "cors": "^2.8.5", + "fluent-ffmpeg": "^2.1.3", + "follow-redirects": "^1.15.11", + "mime-types": "^3.0.2", + "picocolors": "^1.1.1", + "polka": "^0.5.2" + }, + "optionalDependencies": { + "sharp": "0.33.3" + }, + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md deleted file mode 100644 index f12021963..000000000 --- a/packages/cli/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [0.1.0-alpha.18](https://github.com/codigoencasa/bot-whatsapp/compare/v0.1.0-alpha.0...v0.1.0-alpha.18) (2024-01-19) - -**Note:** Version bump only for package @builderbot/cli diff --git a/packages/cli/LICENSE.md b/packages/cli/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/cli/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md deleted file mode 100644 index 00a4c7658..000000000 --- a/packages/cli/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/cli

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/cli/_test_/check.test.ts b/packages/cli/_test_/check.test.ts deleted file mode 100644 index 2dd26ecce..000000000 --- a/packages/cli/_test_/check.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { checkNodeVersion, checkOs, checkGit } from '../src/check' - -test('checkNodeVersion', async () => { - const result = await checkNodeVersion() - assert.type(checkNodeVersion, 'function') - assert.type(result, 'object') - assert.type(result.pass, 'boolean') - assert.type(result.message, 'string') - assert.ok(result.pass, 'La versión de Node.js debería ser 18 o superior') -}) - -test('checkOs - returns the operating system correctly not Windows systems', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'linux' }) - - const result = await checkOs() - assert.is(result, 'OS: linux') - Object.defineProperty(process, 'platform', { value: originalPlatform }) -}) - -test('checkOs - returns the operating system correctly on Windows systems', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'win32' }) - const osMessage = await checkOs() - assert.match(osMessage, /OS: win32/, 'El sistema operativo debería ser win32') - Object.defineProperty(process, 'platform', { value: originalPlatform }) -}) - -test('checkGit', async () => { - try { - const result = await checkGit() - assert.type(checkGit, 'function') - assert.type(result, 'object') - assert.type(result.pass, 'boolean') - assert.type(result.message, 'string') - - assert.ok(result.pass, 'Git debería estar instalado y ser compatible') - } catch (error) { - assert.unreachable('No se pudo ejecutar `git --version`, asegúrese de que Git esté instalado') - } -}) - -test.run() diff --git a/packages/cli/_test_/clean.test.ts b/packages/cli/_test_/clean.test.ts deleted file mode 100644 index 893d0d963..000000000 --- a/packages/cli/_test_/clean.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { join } from 'path' -import proxyquire from 'proxyquire' -import sinon from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -const rimrafStub = sinon.stub().callsArgWith(1, null) - -const { cleanSession } = proxyquire('../src/clean', { - rimraf: rimrafStub, -}) - -const consoleLogSpy = sinon.spy(console, 'log') - -test.before.each(() => { - rimrafStub.resetHistory() - consoleLogSpy.resetHistory() -}) - -test('cleanSession - elimina las rutas especificadas', async () => { - const results = await cleanSession() - rimrafStub.calledWith(join(process.cwd(), '.wwebjs_auth')) - rimrafStub.calledWith(join(process.cwd(), 'session.json')) - assert.equal(results, [true, true]) -}) - -test.run() - -test.after(() => { - consoleLogSpy.restore() -}) diff --git a/packages/cli/_test_/configuration.test.ts b/packages/cli/_test_/configuration.test.ts deleted file mode 100644 index 29c68b456..000000000 --- a/packages/cli/_test_/configuration.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { - AVAILABLE_LANGUAGES, - PROVIDER_LIST, - Provider, - PROVIDER_DATA, - ProviderData, - ProviderWithHint, - ProviderWithoutHint, - validateTemplateCombination, -} from '../src/configuration' - -test('PROVIDER_LIST', () => { - assert.ok(Array.isArray(PROVIDER_LIST)) - PROVIDER_LIST.forEach((provider: Provider) => { - assert.type(provider.value, 'string') - assert.type(provider.label, 'string') - if ('hint' in provider) { - assert.type(provider.hint, 'string') - } - }) -}) - -test('PROVIDER_DATA', () => { - assert.ok(Array.isArray(PROVIDER_DATA)) - PROVIDER_DATA.forEach((providerData: ProviderData) => { - assert.type(providerData.value, 'string') - assert.type(providerData.label, 'string') - }) -}) - -test('Provider With Hint', () => { - const providersWithHint = PROVIDER_LIST.filter( - (provider: Provider): provider is ProviderWithHint => 'hint' in provider - ) - assert.ok(providersWithHint.length > 0) - providersWithHint.forEach((providerWithHint) => { - assert.type(providerWithHint.hint, 'string') - }) -}) - -test('Provider Without Hint', () => { - const providersWithoutHint = PROVIDER_LIST.filter( - (provider: Provider): provider is ProviderWithoutHint => !('hint' in provider) - ) - assert.ok(providersWithoutHint.length > 0) - providersWithoutHint.forEach((providerWithoutHint) => { - assert.not('hint' in providerWithoutHint) - }) -}) - -test('validateTemplateCombination allows gupshup supported combo', () => { - const result = validateTemplateCombination({ provider: 'gupshup', language: 'ts', database: 'memory' }) - assert.equal(result.pass, true) - assert.equal(result.message, '') -}) - -test('validateTemplateCombination rejects gupshup unsupported combo', () => { - const result = validateTemplateCombination({ provider: 'gupshup', language: 'js', database: 'memory' }) - assert.equal(result.pass, false) - assert.match(result.message, /Unsupported template combination for provider gupshup/) -}) - -test('validateTemplateCombination keeps non-gupshup combos open', () => { - const result = validateTemplateCombination({ provider: 'baileys', language: 'js', database: 'mongo' }) - assert.equal(result.pass, true) - assert.equal(result.message, '') -}) - -test('validateTemplateCombination keeps matrix parity for all combinations', () => { - for (const provider of PROVIDER_LIST) { - for (const language of AVAILABLE_LANGUAGES) { - for (const database of PROVIDER_DATA) { - const result = validateTemplateCombination({ - provider: provider.value, - language: language.value, - database: database.value, - }) - - if (provider.value !== 'gupshup') { - assert.equal(result.pass, true) - continue - } - - const isSupported = language.value === 'ts' && database.value === 'memory' - assert.equal(result.pass, isSupported) - } - } - } -}) - -test.run() diff --git a/packages/cli/bin/cli.cjs b/packages/cli/bin/cli.cjs deleted file mode 100755 index 0d1eb1f34..000000000 --- a/packages/cli/bin/cli.cjs +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -const index = require('../dist/index.cjs') -index.start() diff --git a/packages/cli/package.json b/packages/cli/package.json deleted file mode 100644 index 32c50274a..000000000 --- a/packages/cli/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@builderbot/cli", - "version": "1.4.2-alpha.11", - "description": "", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rimraf starters && rollup --config", - "test": "npx uvu -r tsm ./_test_", - "test:coverage": "npx c8 npm run test" - }, - "devDependencies": { - "@clack/prompts": "^0.11.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/cross-spawn": "^6.0.6", - "@types/fs-extra": "^11.0.4", - "@types/node": "^24.10.2", - "@types/prompts": "^2.4.9", - "@types/proxyquire": "^1.3.31", - "@types/sinon": "^17.0.3", - "cross-spawn": "^7.0.3", - "fs-extra": "^11.2.0", - "picocolors": "^1.0.0", - "prompts": "^2.4.2", - "proxyquire": "^2.1.3", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "uvu": "^0.5.6" - }, - "files": [ - "./dist/" - ], - "bin": { - "bot": "./bin/cli.cjs" - }, - "repository": { - "type": "git", - "url": "https://github.com/codigoencasa/bot-whatsapp/tree/main/packages/cli" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/cli/pkg-to-update.json b/packages/cli/pkg-to-update.json deleted file mode 100644 index 4a13049d5..000000000 --- a/packages/cli/pkg-to-update.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "whatsapp-web.js": "latest" -} diff --git a/packages/cli/rollup.config.js b/packages/cli/rollup.config.js deleted file mode 100644 index 0852b848c..000000000 --- a/packages/cli/rollup.config.js +++ /dev/null @@ -1,33 +0,0 @@ -import commonjs from '@rollup/plugin-commonjs' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import typescript from 'rollup-plugin-typescript2' -import { ensureDir, copy } from 'fs-extra' -import { join, dirname } from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -const PATH_STARTERS = join(process.cwd(), '..', '..', 'starters') -const DEST_STARTERS = join(__dirname, 'dist', 'starters') - -function copyStarts() { - return { - name: 'copyStartersPlugin', - async buildStart() { - await ensureDir(DEST_STARTERS) - await copy(PATH_STARTERS, DEST_STARTERS) - }, - } -} - -export default { - input: ['src/index.ts'], - output: { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - plugins: [commonjs(), nodeResolve(), typescript(), copyStarts()], -} diff --git a/packages/cli/src/check/index.ts b/packages/cli/src/check/index.ts deleted file mode 100644 index 36dbd07e5..000000000 --- a/packages/cli/src/check/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { exec } from 'node:child_process' -import { platform, version as nodeVersion } from 'node:os' - -interface CheckResult { - pass: boolean - message: string -} - -const checkNodeVersion = (): Promise => { - return new Promise((resolve) => { - const version = nodeVersion - const majorVersion = parseInt(version().replace('v', '').split('.')[0]) - if (majorVersion < 20) { - resolve({ pass: false, message: `Node.js 20 or higher is required.. (${version})` }) - } - - resolve({ pass: true, message: `Node: ${version} supported` }) - }) -} - -const checkOs = (): Promise => { - return new Promise((resolve) => { - const os = platform() - if (!os.includes('win32')) { - resolve(`OS: ${os}`) - } - resolve(`OS: ${os}`) - }) -} - -const checkGit = (): Promise => { - return new Promise((resolve, reject) => { - exec('git --version', (error) => { - if (error) { - reject({ pass: false, message: `Requires GIT installation` }) - } else { - resolve({ pass: true, message: `Git: supported` }) - } - }) - }) -} - -export { checkNodeVersion, checkOs, checkGit } diff --git a/packages/cli/src/clean/index.ts b/packages/cli/src/clean/index.ts deleted file mode 100644 index 18b232956..000000000 --- a/packages/cli/src/clean/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { join } from 'path' -import color from 'picocolors' -import { rimraf } from 'rimraf' - -export type PathString = string - -const PATH_WW: PathString[] = [join(process.cwd(), '.wwebjs_auth'), join(process.cwd(), 'session.json')] - -export type CleanSessionFunction = () => Promise - -const cleanSession: CleanSessionFunction = () => { - const queue: Promise[] = [] - for (const PATH of PATH_WW) { - console.log(color.yellow(`😬 Eliminando: ${PATH}`)) - queue.push(rimraf(PATH, { maxRetries: 2 })) - } - return Promise.all(queue) -} - -export { cleanSession } diff --git a/packages/cli/src/configuration/index.ts b/packages/cli/src/configuration/index.ts deleted file mode 100644 index a57c61a05..000000000 --- a/packages/cli/src/configuration/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -export interface ProviderWithHint { - value: string - label: string - hint: string -} - -export interface ProviderWithoutHint { - value: string - label: string -} - -export type Provider = ProviderWithHint | ProviderWithoutHint - -export interface ValueLabel { - value: string - label: string -} - -export interface TemplateCombination { - provider: string - language: string - database: string -} - -export interface TemplateValidationResult { - pass: boolean - message: string -} - -export const PROVIDER_LIST: Provider[] = [ - { value: 'baileys', label: 'Baileys', hint: 'opensource' }, - { value: 'sherpa', label: 'Sherpa', hint: 'opensource' }, - { value: 'evolution-api', label: 'Evolution API', hint: 'opensource' }, - // { value: 'venom', label: 'Venom', hint: 'opensource' }, - { value: 'wppconnect', label: 'WPPConnect', hint: 'opensource' }, - // { value: 'wweb', label: 'Whatsapp-web.js', hint: 'opensource' }, - { value: 'twilio', label: 'Twilio' }, - { value: 'meta', label: 'Meta' }, - { value: 'facebook-messenger', label: 'Facebook Messenger' }, - { value: 'instagram', label: 'Instagram' }, - { value: 'gupshup', label: 'Gupshup' }, - { value: 'gohighlevel', label: 'GoHighLevel' }, - { value: 'email', label: 'Email', hint: 'IMAP/SMTP' }, -] - -export const PROVIDER_DATA: ValueLabel[] = [ - { value: 'memory', label: 'Memory' }, - { value: 'json', label: 'Json' }, - { value: 'mongo', label: 'Mongo' }, - { value: 'mysql', label: 'MySQL' }, - { value: 'postgres', label: 'PostgreSQL' }, -] - -export type ProviderData = ValueLabel - -export const AVAILABLE_LANGUAGES: ValueLabel[] = [ - { value: 'ts', label: 'TypeScript' }, - { value: 'js', label: 'JavaScript' }, -] - -const GUPSHUP_SUPPORTED_TEMPLATE_COMBINATIONS: TemplateCombination[] = [ - { provider: 'gupshup', language: 'ts', database: 'memory' }, -] - -export const validateTemplateCombination = ({ - provider, - language, - database, -}: TemplateCombination): TemplateValidationResult => { - if (provider !== 'gupshup') { - return { pass: true, message: '' } - } - - const pass = GUPSHUP_SUPPORTED_TEMPLATE_COMBINATIONS.some( - (combo) => combo.provider === provider && combo.language === language && combo.database === database - ) - - if (pass) { - return { pass, message: '' } - } - - const supportedCombinations = GUPSHUP_SUPPORTED_TEMPLATE_COMBINATIONS.map( - (combo) => `--provider=${combo.provider} --language=${combo.language} --database=${combo.database}` - ).join('\n') - - return { - pass, - message: `Unsupported template combination for provider ${provider}.\nSupported combinations:\n${supportedCombinations}`, - } -} diff --git a/packages/cli/src/create-app/index.ts b/packages/cli/src/create-app/index.ts deleted file mode 100644 index c65ad5d9e..000000000 --- a/packages/cli/src/create-app/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as fs from 'fs-extra' - -/** - * Copy files - * @param from Source path - * @param to Destination path - */ -const copyFiles = async (from: string, to: string): Promise => { - try { - await fs.copy(from, to) - } catch (err) { - console.error(err) - } -} - -/** - * Copiar directorio con archivos - * @param fromDir Source directory path - * @param toDir Destination directory path - */ -const copyBaseApp = async (fromDir: string = process.cwd(), toDir: string = process.cwd()): Promise => { - const BASE_APP_PATH_FROM: string = fromDir - const BASE_APP_PATH_TO: string = toDir - await copyFiles(BASE_APP_PATH_FROM, BASE_APP_PATH_TO) -} - -export { copyBaseApp } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts deleted file mode 100644 index 6e588de3c..000000000 --- a/packages/cli/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { start } from './interactive' -if (process.env.NODE_ENV === 'dev') start() -export { start } diff --git a/packages/cli/src/install/index.ts b/packages/cli/src/install/index.ts deleted file mode 100644 index 27e44fa74..000000000 --- a/packages/cli/src/install/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync, existsSync } from 'fs' -import { join } from 'path' - -import { installDeps, getPkgManage } from './tool' - -interface PackageToUpdate { - [packageName: string]: string -} - -const PATHS_DIR: string[] = [ - join(__dirname, 'pkg-to-update.json'), - join(__dirname, '..', 'pkg-to-update.json'), - join(__dirname, '..', '..', 'pkg-to-update.json'), -] - -const PKG_TO_UPDATE = (): string[] => { - const PATH_INDEX: number = PATHS_DIR.findIndex((a: string) => existsSync(a)) - if (PATH_INDEX === -1) { - throw new Error('No package update file found.') - } - const data: string = readFileSync(PATHS_DIR[PATH_INDEX], 'utf-8') - const dataParse: PackageToUpdate = JSON.parse(data) - const pkg: string[] = Object.keys(dataParse).map((n: string) => `${n}@${dataParse[n]}`) - return pkg -} - -const installAll = async (): Promise => { - const pkgManager: string = await getPkgManage() - installDeps(pkgManager, PKG_TO_UPDATE()).runInstall() -} - -export { installAll } diff --git a/packages/cli/src/install/tool.ts b/packages/cli/src/install/tool.ts deleted file mode 100644 index e36fe1177..000000000 --- a/packages/cli/src/install/tool.ts +++ /dev/null @@ -1,63 +0,0 @@ -import spawn from 'cross-spawn' -import color from 'picocolors' - -type PackageManager = 'npm' | 'yarn' | 'pnpm' -const PKG_OPTION: Record = { - npm: 'install', - yarn: 'add', - pnpm: 'add', -} - -const getPkgManage = async (): Promise => { - return 'npm' -} - -const installDeps = (pkgManager: string, packageList: string | string[]) => { - const errorMessage = `Ocurrió un error instalando ${packageList}` - const childProcesses: (() => Promise)[] = [] - - const installSingle = (pkgInstall: string): (() => Promise) => { - return () => - new Promise((resolve) => { - try { - const childProcess = spawn(pkgManager, [PKG_OPTION[pkgManager], pkgInstall], { - stdio: 'inherit', - }) - - childProcess.on('error', (e) => { - console.error(e) - console.error(color.red(errorMessage)) - resolve() - }) - - childProcess.on('close', (code) => { - if (code === 0) { - resolve() - } else { - console.error(code) - console.error(color.red(errorMessage)) - } - }) - } catch (e) { - console.error(e) - console.error(color.red(errorMessage)) - resolve() - } - }) - } - - if (typeof packageList === 'string') { - childProcesses.push(installSingle(packageList)) - } else { - for (const pkg of packageList) { - childProcesses.push(installSingle(pkg)) - } - } - - const runInstall = (): Promise => { - return Promise.all(childProcesses.map((install) => install())) - } - return { runInstall } -} - -export { getPkgManage, installDeps } diff --git a/packages/cli/src/interactive-legacy/index.ts b/packages/cli/src/interactive-legacy/index.ts deleted file mode 100644 index 86f4f24a8..000000000 --- a/packages/cli/src/interactive-legacy/index.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { existsSync } from 'fs' -import { join } from 'path' -import color from 'picocolors' -import prompts from 'prompts' - -import { checkNodeVersion, checkOs, checkGit } from '../check' -import { PROVIDER_LIST, PROVIDER_DATA } from '../configuration' -import { copyBaseApp } from '../create-app' - -interface ProviderChoice { - label: string - value: string -} - -interface Response { - outDir?: string - providerDb?: string[] - providerWs?: string[] -} - -const bannerDone = (): void => { - console.log(``) - console.log( - color.cyan( - [ - `[Acknowledgements]: This is an OpenSource project, if you intend to collaborate you can do so:`, - `[😉] Buying a coffee https://www.buymeacoffee.com/leifermendez`, - `[⭐] Giving a star https://github.com/codigoencasa/bot-whatsapp`, - `[🚀] Making improvements in the code`, - ].join('\n') - ) - ) - console.log(``) -} - -const startInteractiveLegacy = async (): Promise => { - try { - console.clear() - await checkNodeVersion() - checkOs() - await checkGit() - console.clear() - await nextSteps() - } catch (e) { - console.error(color.bgRed(`Oops! 🙄 something is not right.`)) - console.error(color.bgRed(`Check the minimum requirements in the documentation`)) - } -} - -const nextSteps = async (): Promise => { - const questions: prompts.PromptObject[] = [ - { - type: 'text', - name: 'outDir', - message: 'Do you want to create a bot? (Y/n)', - }, - { - type: 'multiselect', - name: 'providerWs', - message: 'Which WhatsApp provider do you want to use?', - choices: PROVIDER_LIST.map((c: ProviderChoice) => ({ title: c.label, value: c.value })), - max: 1, - hint: 'Space to select', - instructions: '↑/↓', - }, - { - type: 'multiselect', - name: 'providerDb', - message: 'Which database do you want to use?', - choices: PROVIDER_DATA.map((c: ProviderChoice) => ({ title: c.label, value: c.value })), - max: 1, - hint: 'Space to select', - instructions: '↑/↓', - }, - ] - - const onCancel = (): boolean => { - console.log('Process canceled!') - return true - } - const response: Response = await prompts(questions, { onCancel }) - const { outDir = '', providerDb = [], providerWs = [] } = response - - const createApp = async (templateName: string): Promise => { - if (!templateName) throw new Error('TEMPLATE_NAME_INVALID: ' + templateName) - - const possiblesPath = [ - join(__dirname, '..', '..', 'starters', 'apps', templateName), - join(__dirname, '..', 'starters', 'apps', templateName), - join(__dirname, 'starters', 'apps', templateName), - ] - - const answer = outDir.toLowerCase() || 'n' - if (answer.includes('n')) return true - - if (answer.includes('y')) { - const indexOfPath = possiblesPath.find((a) => existsSync(a)) - if (!indexOfPath) throw new Error('Path does not exist: ' + indexOfPath) - await copyBaseApp(indexOfPath, join(process.cwd(), templateName)) - console.log(``) - console.log(color.bgMagenta(`⚡⚡⚡ INSTRUCTIONS ⚡⚡⚡`)) - console.log(color.yellow(`cd ${templateName}`)) - console.log(color.yellow(`npm install`)) - console.log(color.yellow(`npm start`)) - console.log(``) - - return outDir - } - return false - } - - const vendorProvider = async (): Promise => { - const [answer] = providerWs - if (!providerWs.length) { - console.log(color.red(`You must select a WhatsApp provider. Press [Space] to select`)) - process.exit(1) - } - return answer - } - - const dbProvider = async (): Promise => { - const [answer] = providerDb - if (!providerDb.length) { - console.log(color.red(`You must select a database provider. Press [Space] to select`)) - process.exit(1) - } - return answer - } - - const providerAdapter: string = await vendorProvider() - const dbAdapter: string = await dbProvider() - const NAME_DIR: string = ['base', providerAdapter, dbAdapter].join('-') - await createApp(NAME_DIR) - bannerDone() -} - -export { startInteractiveLegacy } diff --git a/packages/cli/src/interactive/index.ts b/packages/cli/src/interactive/index.ts deleted file mode 100644 index 3980cf084..000000000 --- a/packages/cli/src/interactive/index.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { intro, outro, confirm, select, spinner, isCancel, cancel, note } from '@clack/prompts' -import { existsSync } from 'fs' -import { readFile, rename, writeFile } from 'fs/promises' -import { join } from 'path' -import color from 'picocolors' - -import { checkNodeVersion, checkGit } from '../check' -import { AVAILABLE_LANGUAGES, PROVIDER_DATA, PROVIDER_LIST, validateTemplateCombination } from '../configuration' -import { copyBaseApp } from '../create-app' -import { startInteractiveLegacy } from '../interactive-legacy' - -interface CheckResult { - pass: boolean - message: string -} - -const handleLegacyCli = async (): Promise => { - await startInteractiveLegacy() -} - -const getVersion = async (): Promise => { - try { - const PATHS_DIR: string[] = [ - join(__dirname, 'package.json'), - join(__dirname, '..', 'package.json'), - join(__dirname, '..', '..', 'package.json'), - ] - - const PATH_INDEX: number = PATHS_DIR.findIndex((a: string) => existsSync(a)) - if (PATH_INDEX === -1) { - throw new Error('No package update file found.') - } - - const raw = await readFile(PATHS_DIR[PATH_INDEX], 'utf-8') - const parseRaw = JSON.parse(raw) - return Promise.resolve(parseRaw.version) - } catch (e) { - console.log(`Error: `, e) - return Promise.resolve('latest') - } -} - -const bannerDone = (templateName: string = '', language: string): void => { - const notes = [color.yellow(` cd ${templateName} `), color.yellow(` npm install `)] - - if (language === 'ts') { - notes.push(color.yellow(` npm run dev `)) - } else { - notes.push(color.yellow(` npm start `)) - } - - const doneNote = [ - ``, - `📄 Documentation:`, - ` https://builderbot.app`, - ``, - `🤖 Issues? Join:`, - ` https://link.codigoencasa.com/DISCORD`, - ] - - note([...notes, ...doneNote].join('\n'), 'Instructions:') -} - -const systemRequirements = async (): Promise => { - const stepCheckGit: CheckResult = await checkGit() - - if (!stepCheckGit.pass) { - note(stepCheckGit.message) - cancel('Operation canceled') - return process.exit(0) - } - - const stepCheckNode: CheckResult = await checkNodeVersion() - if (!stepCheckNode.pass) { - note(stepCheckNode.message) - cancel('Operation canceled') - return process.exit(0) - } -} - -const setVersionTemplate = async (projectPath: string, version: string) => { - try { - const pkg = join(projectPath, 'package.json') - const raw = await readFile(pkg, 'utf-8') - const parseRaw = JSON.parse(raw) - const dependencies = parseRaw.dependencies - const newDependencies = Object.keys(dependencies).map((dep) => { - if (dep.startsWith('@builderbot/')) return [dep, version] - if (dep === 'eslint-plugin-builderbot') return [dep, version] - return [dep, dependencies[dep]] - }) - - parseRaw.dependencies = Object.fromEntries(newDependencies) - await writeFile(pkg, JSON.stringify(parseRaw, null, 2)) - } catch (e) { - console.log(`Error Set Version: `, e) - } -} - -const createApp = async (templateName: string | null): Promise => { - if (!templateName) throw new Error('TEMPLATE_NAME_INVALID: ' + templateName) - const possiblesPath: string[] = [ - join(__dirname, '..', '..', 'starters', 'apps', templateName), - join(__dirname, '..', 'starters', 'apps', templateName), - join(__dirname, 'starters', 'apps', templateName), - ] - const indexOfPath: string | undefined = possiblesPath.find((a) => existsSync(a)) - if (!indexOfPath) throw new Error('TEMPLATE_PATH_NOT_FOUND: ' + templateName) - const pathTemplate = join(process.cwd(), templateName) - await copyBaseApp(indexOfPath, pathTemplate) - await rename(join(pathTemplate, '_gitignore'), join(pathTemplate, '.gitignore')) - return pathTemplate -} - -const validateTemplateSupportOrExit = (provider: string, language: string, database: string): void => { - const validation = validateTemplateCombination({ provider, language, database }) - if (validation.pass) { - return - } - - cancel(validation.message) - process.exit(0) -} - -const startInteractive = async (version: string): Promise => { - try { - const stepContinue = await confirm({ - message: 'Do you want to continue?', - }) - - if (!stepContinue) { - cancel('Operation canceled') - return process.exit(0) - } - - if (isCancel(stepContinue)) { - cancel('Operation canceled') - return process.exit(0) - } - - const stepProvider = await select({ - message: 'Which WhatsApp provider do you want to use?', - options: PROVIDER_LIST, - }) - - if (isCancel(stepProvider)) { - cancel('Operation canceled') - return process.exit(0) - } - - const stepDatabase = await select({ - message: 'Which database do you want to use?', - options: PROVIDER_DATA, - }) - - if (isCancel(stepDatabase)) { - cancel('Operation canceled') - return process.exit(0) - } - - const stepLanguage = await select({ - message: 'Which language do you prefer to use?', - options: AVAILABLE_LANGUAGES, - }) - - if (isCancel(stepLanguage)) { - cancel('Operation canceled') - return process.exit(0) - } - - validateTemplateSupportOrExit(stepProvider as string, stepLanguage as string, stepDatabase as string) - - await createBot({ - stepLanguage: stepLanguage as string, - stepProvider: stepProvider as string, - stepDatabase: stepDatabase as string, - version, - }) - } catch (e: any) { - logError(e) - } -} - -const createBot = async ({ - stepLanguage, - stepProvider, - stepDatabase, - version, -}: { - stepLanguage: string - stepProvider: string - stepDatabase: string - version: string -}): Promise => { - try { - const s = spinner() - s.start('Checking requirements') - await systemRequirements() - s.stop('Checking requirements') - - s.start(`Creating project...`) - const NAME_DIR: string = ['base', stepLanguage, stepProvider, stepDatabase].join('-') - const projectPath = await createApp(NAME_DIR) - s.stop(`Creating project...`) - bannerDone(NAME_DIR, stepLanguage as string) - await setVersionTemplate(projectPath, version) - outro(color.bgGreen(' Successfully completed! ')) - } catch (e: any) { - logError(e) - } -} - -const startWithArgs = async (version: string, args: Record): Promise => { - try { - const stepProvider = args['provider'] - const stepDatabase = args['database'] - const stepLanguage = args['language'] - await createBot({ - stepLanguage, - stepProvider, - stepDatabase, - version, - }) - } catch (e: any) { - logError(e) - } -} - -function getArgs(args: string[]): Record { - const result: Record = {} - for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (arg.startsWith('--')) { - const [key, value] = arg.split('=') - result[key.slice(2)] = value - } - } - return result -} - -function validateArgs(args: Record): void { - if (!args['provider'] || !args['database'] || !args['language']) { - cancel(`\nInvalid arguments: You must send all three arguments: --provider, --database and --language - \nExample: --provider=baileys --database=mongo --language=js - \nIf you want to use the interactive mode, just run the command without arguments.`) - process.exit(0) - } - if (args['provider'] && !PROVIDER_LIST.some((p) => p.value === args['provider'])) { - cancel(`Invalid provider: ${args['provider']}`) - process.exit(0) - } - if (args['database'] && !PROVIDER_DATA.some((p) => p.value === args['database'])) { - cancel(`Invalid database: ${args['database']}`) - process.exit(0) - } - if (args['language'] && !AVAILABLE_LANGUAGES.some((p) => p.value === args['language'])) { - cancel(`Invalid language: ${args['language']}`) - process.exit(0) - } - - validateTemplateSupportOrExit(args['provider'], args['language'], args['database']) -} - -const logError = async (e: any): Promise => { - console.log(e) - if (e?.code === 'ERR_TTY_INIT_FAILED') return handleLegacyCli() - cancel([`Oops! 🙄 something is not right.`, `Check the minimum requirements in the documentation`].join('\n')) - return process.exit(0) -} - -const start = async (): Promise => { - const version = await getVersion() - console.clear() - console.log('') - - intro(` Let's create a ${color.bgCyan(' Chatbot ' + 'v' + version)} ✨`) - const args = getArgs(process.argv.slice(2)) - if (!Object.keys(args).length) { - await startInteractive(version) - } else { - validateArgs(args) - await startWithArgs(version, args) - } -} - -export { start } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json deleted file mode 100644 index ae96598ab..000000000 --- a/packages/cli/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "module": "ES2020", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/contexts-dialogflow-cx/LICENSE.md b/packages/contexts-dialogflow-cx/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/contexts-dialogflow-cx/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/contexts-dialogflow-cx/README.md b/packages/contexts-dialogflow-cx/README.md deleted file mode 100644 index 5c1a3d050..000000000 --- a/packages/contexts-dialogflow-cx/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/contexts-dialogflow-cx

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts b/packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts deleted file mode 100644 index 2bfa2e4d6..000000000 --- a/packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { ProviderClass } from '@builderbot/bot' -import { promises as fsPromises, unlinkSync } from 'fs' -import { join } from 'path' -import { stub } from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { DialogFlowContextCX } from '../src/dialogflow-cx/dialogflow-cx.class' -import type { DialogFlowContextOptions } from '../src/types' -import { Message } from '../src/types' - -const mockProvider = new ProviderClass() - -const mockLogger = { - log: stub(), - error: stub(), - warn: stub(), - info: stub(), - debug: stub(), -} - -const credentialMock = { - project_id: 'project_id', - private_key: 'private_key', - client_email: 'client_email', -} - -const optionsDX: DialogFlowContextOptions = { - language: 'en', - location: 'uecentral', - agentId: 'project_id', -} -const existsCredentialStub = stub() -const initializeSessionClientStub = stub() -const sendFlowSimpleStub = stub() -const pathFile = join(process.cwd(), 'google-key.json') - -test.before.each(async () => { - sendFlowSimpleStub.resetHistory() - initializeSessionClientStub.resetHistory() - await fsPromises.writeFile(pathFile, JSON.stringify(credentialMock), 'utf-8') -}) - -test('init - should return an error message', () => { - const messageError = `No se encontró` - try { - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider) - dialogFlowContext['existsCredential'] = existsCredentialStub.returns(false) - dialogFlowContext.init() - } catch (error) { - assert.equal(error.message.includes(messageError), true) - } -}) - -test('init - should call initializeDialogFlowClient if credentials are available', () => { - const credentials = { - project_id: 'tu_project_id', - private_key: 'tu_private_key', - client_email: 'tu_client_email', - } - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) - stub(dialogFlowContext, 'loadCredentials').returns(credentials) - const initializeDialogFlowClientStub = stub(dialogFlowContext as any, 'initializeDialogFlowClient') - dialogFlowContext.init() - assert.equal(initializeDialogFlowClientStub.called, true) - assert.equal(initializeDialogFlowClientStub.calledWith(credentials), true) -}) - -test('initializeDialogFlowClient should set projectId, configuration, and sessionClient', () => { - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) - const credentials = { - project_id: 'test_project', - private_key: 'private_key', - client_email: 'client_email', - } - dialogFlowContext['initializeDialogFlowClient'](credentials) - assert.is(dialogFlowContext.projectId, credentials.project_id) -}) - -test('createSession should return the correct session path', () => { - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) - const mockProjectAgentSessionPath = stub(dialogFlowContext.sessionClient as any, 'projectLocationAgentSessionPath') - mockProjectAgentSessionPath.callsFake((projectId, from) => `${projectId}/sessions/${from}`) - - const projectId = 'project_id' - const from = 'uecentral' - const expectedSessionPath = `${projectId}/sessions/${from}` - const sessionPath = dialogFlowContext['createSession'](from) - assert.equal(sessionPath, expectedSessionPath) - mockProjectAgentSessionPath.restore() -}) - -test('detectIntent - should return the correct result', async () => { - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) - const mockDetectIntent = stub(dialogFlowContext.sessionClient as any, 'detectIntent') - const mockResult = { - queryResult: { - fulfillmentMessages: [{ message: 'TEXT', text: { text: ['Response from DialogFlow'] } }], - }, - } - mockDetectIntent.resolves([mockResult]) - - const reqDialog = { - session: 'session_path', - queryInput: { - text: { - text: 'test_message', - languageCode: 'en', - }, - }, - } - - const result = await dialogFlowContext['detectIntent'](reqDialog) - - assert.equal(result, mockResult) - - mockDetectIntent.restore() -}) - -test('detectIntent - should return null', async () => { - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) - const mockDetectIntent = stub(dialogFlowContext.sessionClient as any, 'detectIntent') - - mockDetectIntent.resolves(null) - - const reqDialog = { - session: 'session_path', - queryInput: { - text: { - text: 'test_message', - languageCode: 'en', - }, - }, - } - - const result = await dialogFlowContext['detectIntent'](reqDialog) - - assert.equal(result, null) - - mockDetectIntent.restore() -}) - -test('handleMsg - You should send the text message', async () => { - const messageCtxInComming = { - from: 'some_user_id', - body: 'some_message_body', - } - - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) - dialogFlowContext['createSession'] = stub().resolves('session') - dialogFlowContext['detectIntent'] = stub().resolves({ - queryResult: { - responseMessages: [{ message: Message.TEXT, text: { text: ['Response from DialogFlow'] } }], - }, - }) - const expectedMessage = { answer: 'Response from DialogFlow' } - - dialogFlowContext['sendFlowSimple'] = sendFlowSimpleStub - - await dialogFlowContext.handleMsg(messageCtxInComming) - assert.equal(sendFlowSimpleStub.called, true) - assert.equal(sendFlowSimpleStub.firstCall.args[0][0], expectedMessage) -}) - -test('handleMsg - You should send the payload type message', async () => { - const messageCtxInComming = { - from: 'some_user_id', - body: 'some_message_body', - } - - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider) - dialogFlowContext['createSession'] = stub().resolves('session') - dialogFlowContext['detectIntent'] = stub().resolves({ - queryResult: { - responseMessages: [ - { - message: Message.PAYLOAD, - payload: { - fields: { - buttons: { - listValue: { - values: [ - { - structValue: { fields: { body: { stringValue: 'Test button' } } }, - }, - ], - }, - }, - media: { stringValue: 'url-example' }, - answer: { stringValue: 'test image' }, - }, - }, - }, - ], - }, - }) - const expectedMessage = [ - { - options: { media: 'url-example', buttons: [{ body: 'Test button' }] }, - answer: 'test image', - }, - ] - - dialogFlowContext['sendFlowSimple'] = sendFlowSimpleStub - - await dialogFlowContext.handleMsg(messageCtxInComming) - assert.equal(sendFlowSimpleStub.called, true) - assert.equal(sendFlowSimpleStub.args[0][0], expectedMessage) -}) - -test('handleMsg - You should send the payload type media', async () => { - const messageCtxInComming = { - from: 'some_user_id', - body: 'some_message_body', - } - - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider) - dialogFlowContext['createSession'] = stub().resolves('session') - dialogFlowContext['detectIntent'] = stub().resolves({ - queryResult: { - responseMessages: [ - { - message: Message.PAYLOAD, - payload: { - fields: { - buttons: { - listValue: { - values: [], - }, - }, - media: { stringValue: 'url-example' }, - answer: { stringValue: null }, - }, - }, - }, - ], - }, - }) - const expectedMessage = [ - { - options: { media: 'url-example', buttons: [] }, - answer: '', - }, - ] - - dialogFlowContext['sendFlowSimple'] = sendFlowSimpleStub - - await dialogFlowContext.handleMsg(messageCtxInComming) - assert.equal(sendFlowSimpleStub.called, true) - assert.equal(sendFlowSimpleStub.args[0][0], expectedMessage) -}) - -test('handleMsg - should handle unknown message type with empty answer', async () => { - const messageCtxInComming = { - from: 'some_user_id', - body: 'some_message_body', - } - - const dialogFlowContext = new DialogFlowContextCX(mockLogger as any, mockProvider, optionsDX) - dialogFlowContext['createSession'] = stub().resolves('session') - dialogFlowContext['detectIntent'] = stub().resolves({ - queryResult: { - responseMessages: [ - { - message: 'unknown_type', - someOtherField: { data: 'test' }, - }, - ], - }, - }) - const expectedMessage = [{ answer: '' }] - - dialogFlowContext['sendFlowSimple'] = sendFlowSimpleStub - - await dialogFlowContext.handleMsg(messageCtxInComming) - assert.equal(sendFlowSimpleStub.called, true) - assert.equal(sendFlowSimpleStub.args[0][0], expectedMessage) -}) - -test.after.each(() => { - unlinkSync(pathFile) -}) -test.run() diff --git a/packages/contexts-dialogflow-cx/package.json b/packages/contexts-dialogflow-cx/package.json deleted file mode 100644 index 47920e33b..000000000 --- a/packages/contexts-dialogflow-cx/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.2-alpha.11", - "description": "contexts typescript", - "author": "Leifer Mendez ", - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": " npx uvu -r tsm ./__tests__ .test.ts", - "test:coverage": "npx c8 npm run test", - "test:debug": " npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "dependencies": { - "@builderbot/bot": "workspace:^", - "@google-cloud/dialogflow-cx": "^5.5.0" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/node": "^24.10.2", - "@types/proxyquire": "^1.3.31", - "@types/sinon": "^17.0.3", - "proxyquire": "^2.1.3", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/contexts-dialogflow-cx/rollup.config.js b/packages/contexts-dialogflow-cx/rollup.config.js deleted file mode 100644 index 51a2dea9c..000000000 --- a/packages/contexts-dialogflow-cx/rollup.config.js +++ /dev/null @@ -1,19 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - nodeResolve({ - resolveOnly: (module) => !/@google-cloud|@builderbot\/bot/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/contexts-dialogflow-cx/src/dialogflow-cx/dialogflow-cx.class.ts b/packages/contexts-dialogflow-cx/src/dialogflow-cx/dialogflow-cx.class.ts deleted file mode 100644 index 94eb65445..000000000 --- a/packages/contexts-dialogflow-cx/src/dialogflow-cx/dialogflow-cx.class.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { CoreClass } from '@builderbot/bot' -import { SessionsClient } from '@google-cloud/dialogflow-cx' -import { existsSync, readFileSync } from 'fs' -import { join } from 'path' - -import type { DialogFlowContextOptions, DialogFlowCredentials, MessageContextIncoming } from '../types' -import { Message } from '../types' - -const GOOGLE_ACCOUNT_PATH = join(process.cwd(), 'google-key.json') - -export class DialogFlowContextCX extends CoreClass { - projectId: string | null = null - configuration = null - sessionClient = null - optionsDX: DialogFlowContextOptions = { - language: 'es', - location: '', - agentId: '', - } - - constructor(_database, _provider, _optionsDX = {}) { - super(null, _database, _provider, null) - this.optionsDX = { ...this.optionsDX, ..._optionsDX } - this.init() - } - - loadCredentials = (): DialogFlowCredentials | null => { - const rawJson = readFileSync(GOOGLE_ACCOUNT_PATH, 'utf-8') - return JSON.parse(rawJson) as DialogFlowCredentials - } - - private initializeDialogFlowClient = (credentials: DialogFlowCredentials): void => { - const { project_id, private_key, client_email } = credentials - - this.projectId = project_id - const configuration = { - credentials: { - private_key, - client_email, - }, - apiEndpoint: `${this.optionsDX.location}-dialogflow.googleapis.com`, - } - this.sessionClient = new SessionsClient({ ...configuration }) - } - - /** - * Verificar conexión con servicio de DialogFlow - */ - init = () => { - if (!this.existsCredential()) { - throw new Error(`No se encontró ${GOOGLE_ACCOUNT_PATH}`) - } - const credentials = this.loadCredentials() - this.initializeDialogFlowClient(credentials) - } - - /** - * GLOSSARY.md - * @param {*} messageCtxInComming - * @returns - */ - handleMsg = async (messageCtxInComming: MessageContextIncoming): Promise => { - const languageCode = this.optionsDX.language - const { from, body } = messageCtxInComming - - /** - * 📄 Creamos session de contexto basado en el numero de la persona - * para evitar este problema. - * https://github.com/codigoencasa/bot-whatsapp/pull/140 - */ - - const session = this.createSession(from) - - const reqDialog = { - session, - queryInput: { - text: { - text: body, - }, - languageCode, - }, - } - - const { queryResult } = await this.detectIntent(reqDialog) - - const listMessages = queryResult?.responseMessages?.map((res) => { - if (res.message === Message.TEXT) { - return { answer: res.text.text[0] } - } - - if (res.message === Message.PAYLOAD) { - const { media = null, buttons = [], answer = '' } = res.payload.fields - const buttonsArray = - buttons?.listValue?.values?.map((btnValue): { body: string } => { - const { stringValue } = btnValue.structValue.fields.body - return { body: stringValue } - }) || [] - return { - answer: answer?.stringValue || '', - options: { - media: media?.stringValue, - buttons: buttonsArray, - }, - } - } - return { answer: '' } - }) - - this.sendFlowSimple(listMessages, from) - } - - private existsCredential(): boolean { - return existsSync(GOOGLE_ACCOUNT_PATH) - } - - private createSession(from: string): string { - const { location, agentId } = this.optionsDX - return this.sessionClient.projectLocationAgentSessionPath(this.projectId, location, agentId, from) - } - - private async detectIntent(reqDialog: any): Promise { - const [single] = (await this.sessionClient.detectIntent(reqDialog)) || [null] - return single - } -} diff --git a/packages/contexts-dialogflow-cx/src/dialogflow-cx/index.ts b/packages/contexts-dialogflow-cx/src/dialogflow-cx/index.ts deleted file mode 100644 index 2d7330478..000000000 --- a/packages/contexts-dialogflow-cx/src/dialogflow-cx/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DialogFlowContextCX } from './dialogflow-cx.class' -import type { ParamsDialogFlowCX } from '../types' - -/** - * Crear instancia de clase Bot - * @param {*} args - * @returns - */ -const createBotDialog = async ({ database, provider, options }: ParamsDialogFlowCX) => - new DialogFlowContextCX(database, provider, options) - -export { createBotDialog } diff --git a/packages/contexts-dialogflow-cx/src/index.ts b/packages/contexts-dialogflow-cx/src/index.ts deleted file mode 100644 index 869daf3d7..000000000 --- a/packages/contexts-dialogflow-cx/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createBotDialog } from './dialogflow-cx' diff --git a/packages/contexts-dialogflow-cx/src/types.ts b/packages/contexts-dialogflow-cx/src/types.ts deleted file mode 100644 index ec558a6f6..000000000 --- a/packages/contexts-dialogflow-cx/src/types.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Button } from '@builderbot/bot/dist/types' - -export interface DialogFlowContextOptions { - language: string - location: string - agentId: string -} - -export interface DialogFlowCredentials { - project_id: string - private_key: string - client_email: string -} - -export interface DialogFlowCXContextOptions { - location: string - agentId: string - language?: string -} - -export interface MessageContextIncoming { - from: string - ref?: string - body?: string -} - -export interface DialogResponseMessage { - answer: string - options?: { - media?: string - buttons?: Button[] - } -} - -export enum Message { - PAYLOAD = 'payload', - TEXT = 'text', -} - -export interface ParamsDialogFlowCX { - database: any - provider: any - options: DialogFlowCXContextOptions -} diff --git a/packages/contexts-dialogflow-cx/tsconfig.json b/packages/contexts-dialogflow-cx/tsconfig.json deleted file mode 100644 index 9f15948c9..000000000 --- a/packages/contexts-dialogflow-cx/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": [ - "node" - ] - }, - "include": [ - "src/**/*.js", - "src/**/*.ts" - ], - "exclude": [ - "**/*.spec.ts", - "**/*.test.ts", - "node_modules" - ] -} \ No newline at end of file diff --git a/packages/contexts-dialogflow/LICENSE.md b/packages/contexts-dialogflow/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/contexts-dialogflow/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/contexts-dialogflow/README.md b/packages/contexts-dialogflow/README.md deleted file mode 100644 index e84a9df7c..000000000 --- a/packages/contexts-dialogflow/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/contexts-dialogflow

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts b/packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts deleted file mode 100644 index 8854dd1e9..000000000 --- a/packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { ProviderClass } from '@builderbot/bot' -import { promises as fsPromises, unlinkSync } from 'fs' -import { join } from 'path' -import { stub } from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { DialogFlowContext } from '../src/dialogflow/dialogflow.class' -import { Message } from '../src/types' - -const mockProvider = new ProviderClass() - -const mockLogger = { - log: stub(), - error: stub(), - warn: stub(), - info: stub(), - debug: stub(), -} - -const credentialMock = { - project_id: 'project_id', - private_key: 'private_key', - client_email: 'client_email', -} - -const existsCredentialStub = stub() -const getCredentialStub = stub() -const initializeSessionClientStub = stub() -const sendFlowSimpleStub = stub() -const pathFile = join(process.cwd(), 'google-key.json') - -test.before.each(async () => { - sendFlowSimpleStub.resetHistory() - await fsPromises.writeFile(pathFile, JSON.stringify(credentialMock), 'utf-8') -}) - -test('init - I should call the initializeSessionClient function', () => { - const expectedData = { - credentials: { private_key: 'private_key', client_email: 'client_email' }, - } - const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) - dialogFlowContext['existsCredential'] = existsCredentialStub.returns(true) - dialogFlowContext['getCredential'] = getCredentialStub.returns(credentialMock) - dialogFlowContext['initializeSessionClient'] = initializeSessionClientStub - dialogFlowContext.init() - assert.equal(initializeSessionClientStub.firstCall.args[0], expectedData) -}) - -test('init - should return an error message', () => { - const messageError = `No se encontró` - try { - const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) - dialogFlowContext['existsCredential'] = existsCredentialStub.returns(false) - dialogFlowContext.init() - } catch (error) { - assert.equal(error.message.includes(messageError), true) - } -}) - -test('handleMsg - You should send the text message', async () => { - const messageCtxInComming = { - from: 'some_user_id', - body: 'some_message_body', - } - - const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) - dialogFlowContext['createSession'] = stub().resolves('session') - dialogFlowContext['detectIntent'] = stub().resolves({ - queryResult: { - fulfillmentMessages: [{ message: Message.TEXT, text: { text: ['Response from DialogFlow'] } }], - }, - }) - const expectedMessage = { answer: 'Response from DialogFlow' } - - dialogFlowContext['sendFlowSimple'] = sendFlowSimpleStub - - await dialogFlowContext.handleMsg(messageCtxInComming) - - assert.equal(sendFlowSimpleStub.calledWith([expectedMessage]), true) -}) - -test('handleMsg - You should send the payload type message', async () => { - const messageCtxInComming = { - from: 'some_user_id', - body: 'some_message_body', - } - - const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) - dialogFlowContext['createSession'] = stub().resolves('session') - dialogFlowContext['detectIntent'] = stub().resolves({ - queryResult: { - fulfillmentMessages: [ - { - message: 'payload', - payload: { - fields: { - media: { kind: 'stringValue', stringValue: 'image' }, - body: { kind: 'stringValue', stringValue: 'image' }, - }, - }, - }, - ], - }, - }) - - dialogFlowContext['sendFlowSimple'] = sendFlowSimpleStub - - await dialogFlowContext.handleMsg(messageCtxInComming) - - assert.equal(sendFlowSimpleStub.called, true) -}) - -test.after.each(() => { - unlinkSync(pathFile) -}) - -test('createSession should return the correct session path', () => { - const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) - const mockProjectAgentSessionPath = stub(dialogFlowContext.sessionClient as any, 'projectAgentSessionPath') - mockProjectAgentSessionPath.callsFake((projectId, from) => `${projectId}/sessions/${from}`) - - const projectId = 'project_id' - const from = 'user123' - const expectedSessionPath = `${projectId}/sessions/${from}` - const sessionPath = dialogFlowContext['createSession'](from) - - assert.equal(sessionPath, expectedSessionPath) -}) - -test('detectIntent - should return the correct result', async () => { - const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) - const mockDetectIntent = stub(dialogFlowContext.sessionClient as any, 'detectIntent') - const mockResult = { - queryResult: { - fulfillmentMessages: [{ message: Message.TEXT, text: { text: ['Hello!'] } }], - }, - } - - mockDetectIntent.resolves([mockResult]) - - const result = await dialogFlowContext['detectIntent']('session123', 'Hello') - - assert.equal(result, mockResult) -}) - -test('detectIntent - should return null', async () => { - const dialogFlowContext = new DialogFlowContext(mockLogger as any, mockProvider) - const mockDetectIntent = stub(dialogFlowContext.sessionClient as any, 'detectIntent') - - mockDetectIntent.resolves(null) - - const result = await dialogFlowContext['detectIntent']('session123', 'Hello') - - assert.equal(result, null) -}) - -test.run() diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json deleted file mode 100644 index 79b5b1456..000000000 --- a/packages/contexts-dialogflow/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@builderbot/contexts-dialogflow", - "version": "1.4.2-alpha.11", - "description": "contexts typescript", - "author": "Leifer Mendez ", - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": " npx uvu -r tsm ./__tests__ .test.ts", - "test:coverage": "npx c8 npm run test", - "test:debug": " npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "dependencies": { - "@builderbot/bot": "workspace:^", - "@google-cloud/dialogflow": "^7.4.0" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/node": "^24.10.2", - "@types/proxyquire": "^1.3.31", - "@types/sinon": "^17.0.3", - "proxyquire": "^2.1.3", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/contexts-dialogflow/rollup.config.js b/packages/contexts-dialogflow/rollup.config.js deleted file mode 100644 index 51a2dea9c..000000000 --- a/packages/contexts-dialogflow/rollup.config.js +++ /dev/null @@ -1,19 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - nodeResolve({ - resolveOnly: (module) => !/@google-cloud|@builderbot\/bot/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/contexts-dialogflow/src/dialogflow/dialogflow.class.ts b/packages/contexts-dialogflow/src/dialogflow/dialogflow.class.ts deleted file mode 100644 index 0a8b82546..000000000 --- a/packages/contexts-dialogflow/src/dialogflow/dialogflow.class.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { CoreClass } from '@builderbot/bot' -import { SessionsClient } from '@google-cloud/dialogflow' -import { existsSync, readFileSync } from 'fs' -import { join } from 'path' - -import type { Credential, DialogFlowContextOptions, MessageContextIncoming } from '../types' -import { Message } from '../types' - -const GOOGLE_ACCOUNT_PATH = join(process.cwd(), 'google-key.json') - -export class DialogFlowContext extends CoreClass { - optionsDX: DialogFlowContextOptions = { - language: 'es', - } - projectId: string | null = null - sessionClient = null - googleKeyJson: string | undefined = process.env.GOOGLE_KEY_JSON - constructor(_database, _provider, _optionsDX = {}) { - super(null, _database, _provider, null) - this.optionsDX = { ...this.optionsDX, ..._optionsDX } - this.init() - } - - /** - * Verificar conexión con servicio de DialogFlow - */ - init = () => { - if (!this.existsCredential()) { - throw new Error(`No se encontró ${GOOGLE_ACCOUNT_PATH}`) - } - - const { project_id, private_key, client_email } = this.getCredential() - - this.projectId = project_id - const configuration = { - credentials: { - private_key, - client_email, - }, - } - - this.initializeSessionClient(configuration) - } - - /** - * GLOSSARY.md - * @param {*} messageCtxInComming - * @returns - */ - handleMsg = async (messageCtxInComming: MessageContextIncoming): Promise => { - const languageCode = this.optionsDX.language - const { from, body } = messageCtxInComming - - let customPayload = {} - - /** - * 📄 Creamos session de contexto basado en el numero de la persona - * para evitar este problema. - * https://github.com/codigoencasa/bot-whatsapp/pull/140 - */ - const session = this.createSession(from) - - const reqDialog = { - session, - queryInput: { - text: { - text: body, - languageCode, - }, - }, - } - - const { queryResult } = await this.detectIntent(reqDialog) - - const msgPayload = queryResult?.fulfillmentMessages?.find((a) => a.message === Message.PAYLOAD) - - // Revisamos si el dialogFlow tiene multimedia - if (msgPayload && msgPayload?.payload) { - const { fields } = msgPayload.payload - const mapButtons = fields?.buttons?.listValue?.values.map((m) => { - return { body: m?.structValue?.fields?.body?.stringValue } - }) - - customPayload = { - options: { - media: fields?.media?.stringValue, - buttons: mapButtons, - }, - } - - const ctxFromDX = { - ...customPayload, - answer: fields?.answer?.stringValue, - } - this.sendFlowSimple([ctxFromDX], from) - return - } - - const messagesFromCX = queryResult['fulfillmentMessages'] - .map((a) => { - if (a.message === Message.TEXT) { - return { answer: a.text.text[0] } - } - }) - .filter((e) => e) - - this.sendFlowSimple(messagesFromCX, from) - } - - private existsCredential(): boolean { - return existsSync(GOOGLE_ACCOUNT_PATH) - } - - private getCredential(): Credential { - const rawJson = readFileSync(GOOGLE_ACCOUNT_PATH, 'utf-8') - const { project_id, private_key, client_email } = JSON.parse(rawJson) - return { project_id, private_key, client_email } - } - - private initializeSessionClient(configuration: { credentials: { private_key: string; client_email: string } }) { - this.sessionClient = new SessionsClient({ ...configuration }) - } - - private createSession(from: string): string { - return this.sessionClient.projectAgentSessionPath(this.projectId, from) - } - - private async detectIntent(reqDialog: any): Promise { - const [single] = (await this.sessionClient.detectIntent(reqDialog)) || [null] - return single - } -} diff --git a/packages/contexts-dialogflow/src/dialogflow/index.ts b/packages/contexts-dialogflow/src/dialogflow/index.ts deleted file mode 100644 index 5e86fe257..000000000 --- a/packages/contexts-dialogflow/src/dialogflow/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DialogFlowContext } from './dialogflow.class' -import type { ParamsDialogFlow } from '../types' - -/** - * Crear instancia de clase Bot - * @param {*} args - * @returns - */ -const createBotDialog = async ({ database, provider, options }: ParamsDialogFlow) => - new DialogFlowContext(database, provider, options) - -export { createBotDialog } diff --git a/packages/contexts-dialogflow/src/index.ts b/packages/contexts-dialogflow/src/index.ts deleted file mode 100644 index ec81c8c44..000000000 --- a/packages/contexts-dialogflow/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createBotDialog } from './dialogflow' diff --git a/packages/contexts-dialogflow/src/mock/index.ts b/packages/contexts-dialogflow/src/mock/index.ts deleted file mode 100644 index 0bc641df1..000000000 --- a/packages/contexts-dialogflow/src/mock/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { MockContext } from './mock.class' - -/** - * Crear instancia de clase Bot - * @param {*} args - * @returns - */ -const createBotMock = async ({ database, provider }) => new MockContext(database, provider) - -export { createBotMock, MockContext } diff --git a/packages/contexts-dialogflow/src/mock/mock.class.ts b/packages/contexts-dialogflow/src/mock/mock.class.ts deleted file mode 100644 index 9d9443f43..000000000 --- a/packages/contexts-dialogflow/src/mock/mock.class.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CoreClass } from '@builderbot/bot' - -export class MockContext extends CoreClass { - constructor(_database, _provider) { - super(null, _database, _provider, null) - } - - init = () => {} - - /** - * GLOSSARY.md - * @param {*} messageCtxInComming - * @returns - */ - handleMsg = async (): Promise => { - console.log('DEBUG:') - } -} diff --git a/packages/contexts-dialogflow/src/types.ts b/packages/contexts-dialogflow/src/types.ts deleted file mode 100644 index ddd529709..000000000 --- a/packages/contexts-dialogflow/src/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface DialogFlowContextOptions { - language?: string -} - -export interface MessageContextIncoming { - from: string - ref?: string - body?: string -} - -export enum Message { - PAYLOAD = 'payload', - TEXT = 'text', -} - -export interface ParamsDialogFlow { - database: any - provider: any - options?: DialogFlowContextOptions -} - -export interface Credential { - project_id: string - private_key: string - client_email: string -} diff --git a/packages/contexts-dialogflow/tsconfig.json b/packages/contexts-dialogflow/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/contexts-dialogflow/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/create-builderbot/CHANGELOG.md b/packages/create-builderbot/CHANGELOG.md deleted file mode 100644 index 760f2ab22..000000000 --- a/packages/create-builderbot/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [0.1.0-alpha.18](https://github.com/codigoencasa/bot-whatsapp/compare/v0.1.0-alpha.0...v0.1.0-alpha.18) (2024-01-19) - -**Note:** Version bump only for package create-bot-whatsapp diff --git a/packages/create-builderbot/LICENSE.md b/packages/create-builderbot/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/create-builderbot/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/create-builderbot/README.md b/packages/create-builderbot/README.md deleted file mode 100644 index 4261881d2..000000000 --- a/packages/create-builderbot/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

create-builderbot

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/create-builderbot/bin/create.cjs b/packages/create-builderbot/bin/create.cjs deleted file mode 100644 index efd11e735..000000000 --- a/packages/create-builderbot/bin/create.cjs +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import('../dist/index.cjs') - .then((m) => m.default.main()) - .catch((e) => console.log(`[Error CLI]:`, e)) diff --git a/packages/create-builderbot/package.json b/packages/create-builderbot/package.json deleted file mode 100644 index 34337a4e6..000000000 --- a/packages/create-builderbot/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "create-builderbot", - "version": "1.4.2-alpha.11", - "description": "", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config" - }, - "files": [ - "./starters/", - "./bin/create.cjs", - "./dist/" - ], - "bin": "./bin/create.cjs", - "dependencies": { - "@builderbot/cli": "workspace:^" - }, - "repository": { - "type": "git", - "url": "https://github.com/codigoencasa/bot-whatsapp/tree/main/packages/create-builderbot" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/node": "^24.10.2", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/create-builderbot/rollup.config.js b/packages/create-builderbot/rollup.config.js deleted file mode 100644 index 99b7aebd8..000000000 --- a/packages/create-builderbot/rollup.config.js +++ /dev/null @@ -1,50 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -import { copy } from 'fs-extra' -import path from 'path' - -function copyPlugin(options = {}) { - const { source, destination } = options - - return { - name: 'copy-plugin', - - async buildStart() { - if (!source || !destination) { - throw new Error('Debe proporcionar tanto la ruta de origen como la de destino.') - } - - const sourcePath = path.resolve(source) - const destinationPath = path.resolve(destination) - - const options = { overwrite: true } - await copy(source, destinationPath, options) - - console.log(`Archivos copiados de "${sourcePath}" a "${destinationPath}".`) - }, - } -} - -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/@builderbot\/cli|sharp/i.test(module), - }), - copyPlugin({ - source: '../../starters/apps', - destination: 'dist/starters/apps', - }), - typescript(), - ], -} diff --git a/packages/create-builderbot/src/index.ts b/packages/create-builderbot/src/index.ts deleted file mode 100644 index e78771227..000000000 --- a/packages/create-builderbot/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { start } from '@builderbot/cli' -/** - * Voy a llamar directo a CLI - * Temporalmente luego mejoro esta - * parte - * @returns - */ -export const main = () => start() diff --git a/packages/create-builderbot/tsconfig.json b/packages/create-builderbot/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/create-builderbot/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/database-json/LICENSE.md b/packages/database-json/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/database-json/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/database-json/README.md b/packages/database-json/README.md deleted file mode 100644 index 102134b1e..000000000 --- a/packages/database-json/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/database-json

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/database-json/__tests__/debounce.test.ts b/packages/database-json/__tests__/debounce.test.ts deleted file mode 100644 index bb5550ee0..000000000 --- a/packages/database-json/__tests__/debounce.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { promises as fsPromises } from 'fs' -import { join } from 'path' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { JsonFileDB } from '../src' -import type { HistoryEntry } from '../src/types' - -const TEST_DIR = process.cwd() - -const createEntry = (from: string, keyword: string = 'test'): HistoryEntry => ({ - ref: `ref-${Date.now()}-${Math.random()}`, - keyword, - answer: `answer-${Date.now()}`, - refSerialize: `serialize-${Date.now()}`, - from, - options: { timestamp: Date.now() }, -}) - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -// ============================================ -// DEBOUNCE TESTS -// ============================================ - -test('[DEBOUNCE] Con debounce activado agrupa escrituras', async () => { - const filename = 'test-debounce-grouped.json' - const db = new JsonFileDB({ filename, debounceTime: 50 }) // 50ms debounce - const pathFile = join(TEST_DIR, filename) - - const NUM_WRITES = 20 - const startTime = Date.now() - - // Disparar muchas escrituras rápidas - const promises: Promise[] = [] - for (let i = 0; i < NUM_WRITES; i++) { - promises.push(db.save(createEntry(`debounce-${i}`, `kw-${i}`))) - } - - // Esperar todas - await Promise.all(promises) - await delay(100) // Esperar que debounce termine - - const endTime = Date.now() - - // Verificar que todas se guardaron - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - console.log(` [DEBOUNCE] ${NUM_WRITES} writes con debounce=50ms: ${endTime - startTime}ms`) - - assert.is(savedData.length, NUM_WRITES, `Se esperaban ${NUM_WRITES} entradas`) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[DEBOUNCE] Sin debounce es más lento pero igualmente seguro', async () => { - const filename = 'test-no-debounce.json' - const db = new JsonFileDB({ filename, debounceTime: 0 }) - const pathFile = join(TEST_DIR, filename) - - const NUM_WRITES = 20 - const startTime = Date.now() - - const promises: Promise[] = [] - for (let i = 0; i < NUM_WRITES; i++) { - promises.push(db.save(createEntry(`no-debounce-${i}`, `kw-${i}`))) - } - - await Promise.all(promises) - await delay(50) - - const endTime = Date.now() - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - console.log(` [DEBOUNCE] ${NUM_WRITES} writes sin debounce: ${endTime - startTime}ms`) - - assert.is(savedData.length, NUM_WRITES) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[DEBOUNCE] Comparación de performance con y sin debounce', async () => { - const NUM_WRITES = 50 - - // Test sin debounce - const filenameNoDebounce = 'test-perf-no-debounce.json' - const dbNoDebounce = new JsonFileDB({ filename: filenameNoDebounce, debounceTime: 0 }) - const pathNoDebounce = join(TEST_DIR, filenameNoDebounce) - - const startNoDebounce = Date.now() - const promisesNo: Promise[] = [] - for (let i = 0; i < NUM_WRITES; i++) { - promisesNo.push(dbNoDebounce.save(createEntry(`perf-${i}`, `kw-${i}`))) - } - await Promise.all(promisesNo) - await delay(50) - const endNoDebounce = Date.now() - - // Test con debounce - const filenameDebounce = 'test-perf-debounce.json' - const dbDebounce = new JsonFileDB({ filename: filenameDebounce, debounceTime: 30 }) - const pathDebounce = join(TEST_DIR, filenameDebounce) - - const startDebounce = Date.now() - const promisesYes: Promise[] = [] - for (let i = 0; i < NUM_WRITES; i++) { - promisesYes.push(dbDebounce.save(createEntry(`perf-${i}`, `kw-${i}`))) - } - await Promise.all(promisesYes) - await delay(100) - const endDebounce = Date.now() - - const timeNoDebounce = endNoDebounce - startNoDebounce - const timeDebounce = endDebounce - startDebounce - - console.log(` [DEBOUNCE] Comparación ${NUM_WRITES} writes:`) - console.log(` Sin debounce: ${timeNoDebounce}ms`) - console.log(` Con debounce (30ms): ${timeDebounce}ms`) - - // Verificar integridad - const dataNo = JSON.parse(await fsPromises.readFile(pathNoDebounce, 'utf-8')) - const dataYes = JSON.parse(await fsPromises.readFile(pathDebounce, 'utf-8')) - - assert.is(dataNo.length, NUM_WRITES) - assert.is(dataYes.length, NUM_WRITES) - - // Cleanup - await fsPromises.unlink(pathNoDebounce) - await fsPromises.unlink(pathDebounce) -}) - -test('[DEBOUNCE] Lectura funciona durante debounce pendiente', async () => { - const filename = 'test-read-during-debounce.json' - const db = new JsonFileDB({ filename, debounceTime: 100 }) - const pathFile = join(TEST_DIR, filename) - - // Guardar entrada - await db.save(createEntry('read-user', 'read-kw')) - - // Inmediatamente leer (debounce aún pendiente) - const result = await db.getPrevByNumber('read-user') - - // Debería encontrar desde memoria aunque el archivo no esté actualizado aún - assert.ok(result) - assert.is(result?.keyword, 'read-kw') - - await delay(150) // Esperar debounce - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[DEBOUNCE] Múltiples debounce consecutivos no pierden datos', async () => { - const filename = 'test-multiple-debounce.json' - const db = new JsonFileDB({ filename, debounceTime: 20 }) - const pathFile = join(TEST_DIR, filename) - - // Ronda 1 - for (let i = 0; i < 5; i++) { - await db.save(createEntry(`round1-${i}`, `r1-${i}`)) - } - await delay(30) - - // Ronda 2 - for (let i = 0; i < 5; i++) { - await db.save(createEntry(`round2-${i}`, `r2-${i}`)) - } - await delay(30) - - // Ronda 3 - for (let i = 0; i < 5; i++) { - await db.save(createEntry(`round3-${i}`, `r3-${i}`)) - } - await delay(50) - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData.length, 15, 'Deberían haber 15 entradas (5 x 3 rondas)') - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test.run() diff --git a/packages/database-json/__tests__/exhaustive.test.ts b/packages/database-json/__tests__/exhaustive.test.ts deleted file mode 100644 index 7b33535c0..000000000 --- a/packages/database-json/__tests__/exhaustive.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { promises as fsPromises } from 'fs' -import { existsSync } from 'fs' -import { join } from 'path' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { JsonFileDB } from '../src' -import type { HistoryEntry } from '../src/types' - -const TEST_DIR = process.cwd() - -// Helper para crear entradas de prueba -const createEntry = (from: string, keyword: string = 'test'): HistoryEntry => ({ - ref: `ref-${Date.now()}-${Math.random()}`, - keyword, - answer: `answer-${Date.now()}`, - refSerialize: `serialize-${Date.now()}`, - from, - options: { timestamp: Date.now() }, -}) - -// Helper para esperar -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -// ============================================ -// POV 1: CONCURRENCIA - Race Conditions -// ============================================ - -test('[CONCURRENCIA] Múltiples saves simultáneos no deben perder datos', async () => { - const filename = 'test-concurrent-saves.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - const NUM_CONCURRENT = 50 - const entries: HistoryEntry[] = [] - - // Crear entradas únicas - for (let i = 0; i < NUM_CONCURRENT; i++) { - entries.push(createEntry(`user-${i}`, `keyword-${i}`)) - } - - // Guardar todas simultáneamente - await Promise.all(entries.map((entry) => db.save(entry))) - - // Esperar a que se escriban - await delay(100) - - // Verificar que todas se guardaron - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData.length, NUM_CONCURRENT, `Deberían haber ${NUM_CONCURRENT} entradas, hay ${savedData.length}`) - - // Verificar que cada entrada está presente - for (let i = 0; i < NUM_CONCURRENT; i++) { - const found = savedData.find((e: HistoryEntry) => e.from === `user-${i}`) - assert.ok(found, `Entrada user-${i} no encontrada`) - } - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[CONCURRENCIA] Lecturas y escrituras simultáneas', async () => { - const filename = 'test-concurrent-rw.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - // Guardar algunas entradas iniciales - for (let i = 0; i < 10; i++) { - await db.save(createEntry(`initial-${i}`, 'initial')) - } - - // Ejecutar lecturas y escrituras simultáneamente - const operations: Promise[] = [] - - for (let i = 0; i < 20; i++) { - // Escrituras - operations.push(db.save(createEntry(`concurrent-${i}`, 'concurrent'))) - // Lecturas intercaladas - operations.push(db.getPrevByNumber(`initial-${i % 10}`)) - } - - await Promise.all(operations) - await delay(100) - - // Verificar integridad - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData.length, 30, 'Deberían haber 30 entradas (10 iniciales + 20 concurrentes)') - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[CONCURRENCIA] Múltiples instancias del mismo archivo', async () => { - const filename = 'test-multi-instance.json' - const pathFile = join(TEST_DIR, filename) - - // Crear archivo inicial vacío - await fsPromises.writeFile(pathFile, '[]', 'utf-8') - - const db1 = new JsonFileDB({ filename }) - const db2 = new JsonFileDB({ filename }) - - // Esperar inicialización - await delay(50) - - // Guardar desde ambas instancias - await db1.save(createEntry('from-db1', 'db1')) - await db2.save(createEntry('from-db2', 'db2')) - - await delay(100) - - // Nota: Con la implementación actual, cada instancia tiene su propio listHistory - // Esto es un problema conocido - múltiples instancias no están sincronizadas - // Este test documenta el comportamiento actual - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - // El último en escribir "gana" - esto es un edge case conocido - assert.ok(savedData.length >= 1, 'Al menos una entrada debería existir') - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -// ============================================ -// POV 2: INTEGRIDAD DE DATOS -// ============================================ - -test('[INTEGRIDAD] Persistencia entre reinicios', async () => { - const filename = 'test-persistence.json' - const pathFile = join(TEST_DIR, filename) - - // Primera instancia - guardar datos - const db1 = new JsonFileDB({ filename }) - await db1.save(createEntry('persist-user', 'persist-keyword')) - await delay(50) - - // Segunda instancia - debería cargar datos existentes - const db2 = new JsonFileDB({ filename }) - await delay(50) - - const result = await db2.getPrevByNumber('persist-user') - assert.ok(result, 'Debería encontrar la entrada persistida') - assert.is(result?.keyword, 'persist-keyword') - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[INTEGRIDAD] Recuperación de archivo corrupto', async () => { - const filename = 'test-corrupt.json' - const pathFile = join(TEST_DIR, filename) - - // Crear archivo corrupto - await fsPromises.writeFile(pathFile, 'esto no es JSON válido {{{', 'utf-8') - - // Debería inicializarse sin errores - const db = new JsonFileDB({ filename }) - await delay(50) - - // Debería poder guardar normalmente - await db.save(createEntry('recovery-user', 'recovery')) - await delay(50) - - // Verificar que el archivo ahora es válido - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData.length, 1) - assert.is(savedData[0].from, 'recovery-user') - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[INTEGRIDAD] Recuperación de archivo con objeto en vez de array', async () => { - const filename = 'test-object-not-array.json' - const pathFile = join(TEST_DIR, filename) - - // Crear archivo con objeto (no array) - await fsPromises.writeFile(pathFile, '{"key": "value"}', 'utf-8') - - const db = new JsonFileDB({ filename }) - await delay(50) - - // Debería poder guardar - await db.save(createEntry('object-user', 'object')) - await delay(50) - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.ok(Array.isArray(savedData), 'Debería ser un array') - assert.is(savedData.length, 1) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[INTEGRIDAD] Archivo vacío', async () => { - const filename = 'test-empty-file.json' - const pathFile = join(TEST_DIR, filename) - - // Crear archivo vacío - await fsPromises.writeFile(pathFile, '', 'utf-8') - - const db = new JsonFileDB({ filename }) - await delay(50) - - await db.save(createEntry('empty-user', 'empty')) - await delay(50) - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData.length, 1) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[INTEGRIDAD] getPrevByNumber con múltiples entradas del mismo usuario', async () => { - const filename = 'test-multiple-same-user.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - // Guardar múltiples entradas del mismo usuario - await db.save(createEntry('same-user', 'first')) - await db.save(createEntry('same-user', 'second')) - await db.save(createEntry('same-user', 'third')) - await delay(50) - - // Debería retornar la última (más reciente) - const result = await db.getPrevByNumber('same-user') - assert.is(result?.keyword, 'third') - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[INTEGRIDAD] getPrevByNumber ignora entradas sin keyword', async () => { - const filename = 'test-no-keyword.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - // Entrada con keyword - await db.save(createEntry('user-kw', 'has-keyword')) - - // Entrada sin keyword (simula mensaje intermedio) - const noKeywordEntry = createEntry('user-kw', '') - noKeywordEntry.keyword = '' - await db.save(noKeywordEntry) - - await delay(50) - - const result = await db.getPrevByNumber('user-kw') - assert.is(result?.keyword, 'has-keyword', 'Debería retornar la entrada con keyword') - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -// ============================================ -// POV 3: PERFORMANCE Y CARGA -// ============================================ - -test('[PERFORMANCE] Carga alta - 500 entradas secuenciales', async () => { - const filename = 'test-high-load-sequential.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - const NUM_ENTRIES = 500 - const startTime = Date.now() - - for (let i = 0; i < NUM_ENTRIES; i++) { - await db.save(createEntry(`load-user-${i}`, `load-${i}`)) - } - - const endTime = Date.now() - const duration = endTime - startTime - - console.log( - ` [PERF] ${NUM_ENTRIES} saves secuenciales: ${duration}ms (${(duration / NUM_ENTRIES).toFixed(2)}ms/op)` - ) - - // Verificar integridad - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData.length, NUM_ENTRIES) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[PERFORMANCE] Carga alta - 200 entradas paralelas', async () => { - const filename = 'test-high-load-parallel.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - const NUM_ENTRIES = 200 - const startTime = Date.now() - - const promises = [] - for (let i = 0; i < NUM_ENTRIES; i++) { - promises.push(db.save(createEntry(`parallel-user-${i}`, `parallel-${i}`))) - } - - await Promise.all(promises) - await delay(200) - - const endTime = Date.now() - const duration = endTime - startTime - - console.log(` [PERF] ${NUM_ENTRIES} saves paralelos: ${duration}ms`) - - // Verificar integridad - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData.length, NUM_ENTRIES, `Esperados ${NUM_ENTRIES}, encontrados ${savedData.length}`) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[PERFORMANCE] Búsqueda en historial grande', async () => { - const filename = 'test-search-large.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - const NUM_ENTRIES = 1000 - - // Crear entradas - for (let i = 0; i < NUM_ENTRIES; i++) { - await db.save(createEntry(`search-user-${i}`, `search-${i}`)) - } - - // Buscar usuario al final - const startTime = Date.now() - const result = await db.getPrevByNumber(`search-user-${NUM_ENTRIES - 1}`) - const endTime = Date.now() - - console.log(` [PERF] Búsqueda en ${NUM_ENTRIES} entradas: ${endTime - startTime}ms`) - - assert.ok(result) - assert.is(result?.keyword, `search-${NUM_ENTRIES - 1}`) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[PERFORMANCE] Inicialización con archivo grande existente', async () => { - const filename = 'test-init-large.json' - const pathFile = join(TEST_DIR, filename) - - // Crear archivo grande - const largeData: HistoryEntry[] = [] - for (let i = 0; i < 5000; i++) { - largeData.push(createEntry(`init-user-${i}`, `init-${i}`)) - } - await fsPromises.writeFile(pathFile, JSON.stringify(largeData, null, 2), 'utf-8') - - // Medir tiempo de inicialización - const startTime = Date.now() - const db = new JsonFileDB({ filename }) - await delay(100) // Esperar init - const endTime = Date.now() - - console.log(` [PERF] Inicialización con 5000 entradas: ${endTime - startTime}ms`) - - // Verificar que cargó correctamente - const result = await db.getPrevByNumber('init-user-4999') - assert.ok(result) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -// ============================================ -// EDGE CASES ADICIONALES -// ============================================ - -test('[EDGE] Usuario no existente retorna undefined', async () => { - const filename = 'test-user-not-found.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - await db.save(createEntry('existing-user', 'exists')) - await delay(50) - - const result = await db.getPrevByNumber('non-existing-user') - assert.is(result, undefined) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[EDGE] Operaciones antes de que init termine', async () => { - const filename = 'test-pre-init.json' - const pathFile = join(TEST_DIR, filename) - - // Crear db y hacer operación inmediatamente - const db = new JsonFileDB({ filename }) - - // Esto debería esperar a que init termine gracias a waitForInit - await db.save(createEntry('pre-init-user', 'pre-init')) - - await delay(50) - - const result = await db.getPrevByNumber('pre-init-user') - assert.ok(result, 'Debería encontrar la entrada aunque se guardó antes de init completo') - - // Cleanup - if (existsSync(pathFile)) { - await fsPromises.unlink(pathFile) - } -}) - -test('[EDGE] Caracteres especiales en datos', async () => { - const filename = 'test-special-chars.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - const specialEntry = createEntry('user-special', 'keyword-special') - specialEntry.answer = 'Texto con "comillas", \\barras\\, \nnewlines\n y émojis 🚀' - specialEntry.options = { nested: { 'key with spaces': 'value' } } - - await db.save(specialEntry) - await delay(50) - - // Verificar que se guardó correctamente - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData[0].answer, specialEntry.answer) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test.run() diff --git a/packages/database-json/__tests__/jsonAdapter.test.ts b/packages/database-json/__tests__/jsonAdapter.test.ts deleted file mode 100644 index dffd6f1ca..000000000 --- a/packages/database-json/__tests__/jsonAdapter.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { promises as fsPromises } from 'fs' -import { join } from 'path' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { JsonFileDB } from '../src' -import type { HistoryEntry, JsonFileAdapterOptions } from '../src/types' - -const entryMock: HistoryEntry = { - ref: 'mockRef', - keyword: 'mockKeyword', - answer: 'mockAnswer', - refSerialize: 'mockRefSerialize', - from: '123456', - options: { - mockOption: 'value', - }, -} - -const jsonFileAdapterOptions: JsonFileAdapterOptions = { filename: 'test-db.json' } -const jsonFileAdapter = new JsonFileDB(jsonFileAdapterOptions) - -async function fileExists(hasFile: boolean): Promise { - return hasFile -} - -test.before(async () => { - await jsonFileAdapter.save(entryMock) -}) - -test.after(async () => { - const pathFile = join(process.cwd(), jsonFileAdapterOptions.filename) - await fsPromises.unlink(pathFile) -}) - -test('[JsonFileAdapter] - instantiation', () => { - assert.instance(jsonFileAdapter, JsonFileDB) - assert.equal(jsonFileAdapter['options'].filename, jsonFileAdapterOptions.filename) -}) - -test('#init - creates a file if it does not exist', async () => { - const filename = 'test.json' - const testFilePath = join(process.cwd(), filename) - const jsonFileAdapter = new JsonFileDB({ filename }) - const fileExistsBeforeInit = await fileExists(false) - assert.is(fileExistsBeforeInit, false) - - await jsonFileAdapter['init']() - - const fileExistsAfterInit = await fileExists(true) - assert.is(fileExistsAfterInit, true) - await fsPromises.unlink(testFilePath) -}) - -test('validateJson - returns parsed array for valid input', () => { - const validJsonString = '[{"key": "value"}]' - const result = jsonFileAdapter['validateJson'](validJsonString) - assert.equal(result, [{ key: 'value' }]) -}) - -test('validateJson - returns an empty array for invalid input', () => { - const invalidJsonString = 'this is not valid JSON' - const result = jsonFileAdapter['validateJson'](invalidJsonString) - assert.equal(result, []) -}) - -test('validateJson - returns an empty array for non-array JSON', () => { - const objectJsonString = '{"key": "value"}' - const result = jsonFileAdapter['validateJson'](objectJsonString) - assert.equal(result, []) -}) - -test('readFileAndParse - returns parsed JSON for valid file content', async () => { - const result = await jsonFileAdapter['readFileAndParse']() - assert.equal(result.length, 1) - assert.equal(result[0].keyword, entryMock.keyword) -}) - -test('getPrevByNumber - returns the correct entry for valid history', async () => { - const result = await jsonFileAdapter.getPrevByNumber(entryMock.from) - assert.equal(result?.keyword, entryMock.keyword) -}) - -test('getPrevByNumber - returns undefined for empty history', async () => { - jsonFileAdapter['readFileAndParse'] = async () => [] - const result = await jsonFileAdapter.getPrevByNumber('system') - assert.equal(result, undefined) -}) - -test('init should return is existsSync false', async () => { - jsonFileAdapter['pathFile'] = 'nonExistingFilePath' - const existsSync = () => false - jsonFileAdapter['existsSync'] = existsSync - const result = await jsonFileAdapter['init']() - assert.equal(result, undefined) -}) - -test.run() diff --git a/packages/database-json/__tests__/stress.test.ts b/packages/database-json/__tests__/stress.test.ts deleted file mode 100644 index e798dea98..000000000 --- a/packages/database-json/__tests__/stress.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { promises as fsPromises } from 'fs' -import { existsSync } from 'fs' -import { join } from 'path' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { JsonFileDB } from '../src' -import type { HistoryEntry } from '../src/types' - -const TEST_DIR = process.cwd() - -const createEntry = (from: string, keyword: string = 'test'): HistoryEntry => ({ - ref: `ref-${Date.now()}-${Math.random()}`, - keyword, - answer: `answer-${Date.now()}`, - refSerialize: `serialize-${Date.now()}`, - from, - options: { timestamp: Date.now() }, -}) - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -// ============================================ -// STRESS TESTS - Casos extremos -// ============================================ - -test('[STRESS] 100 operaciones paralelas mezcladas (save + getPrevByNumber)', async () => { - const filename = 'test-stress-mixed.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - // Primero guardar algunas entradas base - for (let i = 0; i < 20; i++) { - await db.save(createEntry(`base-${i}`, `base-kw-${i}`)) - } - - // Ahora bombardear con operaciones mixtas - const operations: Promise[] = [] - const results: any[] = [] - - for (let i = 0; i < 100; i++) { - if (i % 3 === 0) { - // Lectura - operations.push( - db.getPrevByNumber(`base-${i % 20}`).then((r) => { - results.push({ type: 'read', i, found: !!r }) - return r - }) - ) - } else { - // Escritura - operations.push( - db.save(createEntry(`stress-${i}`, `stress-kw-${i}`)).then(() => { - results.push({ type: 'write', i }) - }) - ) - } - } - - await Promise.all(operations) - await delay(200) - - // Verificar - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - // 20 base + ~66 stress writes (100 - ~33 reads) - const expectedWrites = 100 - Math.floor(100 / 3) - const totalExpected = 20 + expectedWrites - - console.log(` [STRESS] Operaciones completadas: ${results.length}`) - console.log(` [STRESS] Entradas guardadas: ${savedData.length} (esperadas: ~${totalExpected})`) - - assert.ok(savedData.length >= totalExpected - 5, `Se perdieron demasiadas escrituras`) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[STRESS] Escrituras rápidas consecutivas sin await', async () => { - const filename = 'test-stress-rapid.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - const NUM = 50 - const promises: Promise[] = [] - - // Disparar escrituras sin esperar - for (let i = 0; i < NUM; i++) { - promises.push(db.save(createEntry(`rapid-${i}`, `rapid-kw-${i}`))) - } - - // Ahora esperar todas - await Promise.all(promises) - await delay(300) - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - console.log(` [STRESS] Rapid writes: ${savedData.length}/${NUM}`) - - assert.is(savedData.length, NUM, `Se esperaban ${NUM} entradas, se encontraron ${savedData.length}`) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[STRESS] Verificar orden de escritura se preserva', async () => { - const filename = 'test-stress-order.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - const NUM = 30 - - // Guardar secuencialmente - for (let i = 0; i < NUM; i++) { - await db.save(createEntry(`order-user`, `order-${i}`)) - } - - await delay(100) - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - // Verificar que el orden se preservó - for (let i = 0; i < NUM; i++) { - assert.is(savedData[i].keyword, `order-${i}`, `Orden incorrecto en posición ${i}`) - } - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[STRESS] Simular múltiples usuarios enviando mensajes', async () => { - const filename = 'test-stress-users.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - const NUM_USERS = 20 - const MESSAGES_PER_USER = 10 - - const promises: Promise[] = [] - - // Simular mensajes de múltiples usuarios - for (let msg = 0; msg < MESSAGES_PER_USER; msg++) { - for (let user = 0; user < NUM_USERS; user++) { - promises.push(db.save(createEntry(`user-${user}`, `msg-${msg}`))) - } - } - - await Promise.all(promises) - await delay(300) - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - const expected = NUM_USERS * MESSAGES_PER_USER - console.log(` [STRESS] Multi-user: ${savedData.length}/${expected}`) - - assert.is(savedData.length, expected) - - // Verificar que cada usuario tiene sus mensajes - for (let user = 0; user < NUM_USERS; user++) { - const userEntries = savedData.filter((e: HistoryEntry) => e.from === `user-${user}`) - assert.is(userEntries.length, MESSAGES_PER_USER, `Usuario ${user} tiene ${userEntries.length} entradas`) - } - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[STRESS] getPrevByNumber debe retornar última entrada con keyword', async () => { - const filename = 'test-stress-lastentry.json' - const db = new JsonFileDB({ filename }) - const pathFile = join(TEST_DIR, filename) - - // Guardar secuencia: keyword, sin keyword, keyword, sin keyword, keyword - await db.save({ ...createEntry('test-user', 'first'), keyword: 'first' }) - await db.save({ ...createEntry('test-user', ''), keyword: '' }) - await db.save({ ...createEntry('test-user', 'second'), keyword: 'second' }) - await db.save({ ...createEntry('test-user', ''), keyword: '' }) - await db.save({ ...createEntry('test-user', 'third'), keyword: 'third' }) - - await delay(50) - - const result = await db.getPrevByNumber('test-user') - - assert.is(result?.keyword, 'third', 'Debería retornar "third" que es la última con keyword') - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[STRESS] Recuperación después de escritura fallida (simular)', async () => { - const filename = 'test-stress-recovery.json' - const pathFile = join(TEST_DIR, filename) - - // Crear archivo inicial - await fsPromises.writeFile(pathFile, '[]', 'utf-8') - - const db = new JsonFileDB({ filename }) - await delay(50) - - // Guardar algunas entradas - await db.save(createEntry('recovery-1', 'r1')) - await db.save(createEntry('recovery-2', 'r2')) - await delay(50) - - // Simular corrupción externa escribiendo directamente al archivo - // (como si otro proceso lo hubiera corrompido) - // No hacemos esto porque rompería el test - pero documentamos el edge case - - // Guardar más entradas - await db.save(createEntry('recovery-3', 'r3')) - await delay(50) - - const fileContent = await fsPromises.readFile(pathFile, 'utf-8') - const savedData = JSON.parse(fileContent) - - assert.is(savedData.length, 3) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test('[STRESS] Archivos muy grandes (10,000 entradas)', async () => { - const filename = 'test-stress-huge.json' - const pathFile = join(TEST_DIR, filename) - - // Pre-crear archivo grande - const hugeData: HistoryEntry[] = [] - for (let i = 0; i < 10000; i++) { - hugeData.push(createEntry(`huge-${i}`, `huge-kw-${i}`)) - } - await fsPromises.writeFile(pathFile, JSON.stringify(hugeData), 'utf-8') - - const startInit = Date.now() - const db = new JsonFileDB({ filename }) - await delay(200) // Esperar init - const endInit = Date.now() - - console.log(` [STRESS] Init con 10,000 entradas: ${endInit - startInit}ms`) - - // Añadir una entrada más - const startSave = Date.now() - await db.save(createEntry('huge-new', 'new')) - await delay(100) - const endSave = Date.now() - - console.log(` [STRESS] Save en archivo con 10,000 entradas: ${endSave - startSave}ms`) - - // Búsqueda - const startSearch = Date.now() - const result = await db.getPrevByNumber('huge-9999') - const endSearch = Date.now() - - console.log(` [STRESS] Search en 10,000 entradas: ${endSearch - startSearch}ms`) - - assert.ok(result) - - // Cleanup - await fsPromises.unlink(pathFile) -}) - -test.run() diff --git a/packages/database-json/package.json b/packages/database-json/package.json deleted file mode 100644 index 2359a3727..000000000 --- a/packages/database-json/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@builderbot/database-json", - "version": "1.4.2-alpha.11", - "description": "Esto es el conector a json", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": " npx uvu -r tsm ./__tests__ .test.ts", - "test:debug": " npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts", - "test:coverage": "npx c8 npm run test" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "dependencies": { - "@builderbot/bot": "workspace:^" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/node": "^24.10.2", - "kleur": "^4.1.5", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "tslib": "^2.6.2", - "tsm": "^2.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/database-json/rollup.config.js b/packages/database-json/rollup.config.js deleted file mode 100644 index 4a9dd95b7..000000000 --- a/packages/database-json/rollup.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/@builderbot\/bot/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/database-json/src/index.ts b/packages/database-json/src/index.ts deleted file mode 100644 index 1b32a8fb2..000000000 --- a/packages/database-json/src/index.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { MemoryDB } from '@builderbot/bot' -import { existsSync, promises as fsPromises } from 'fs' -import { join } from 'path' - -import type { HistoryEntry, JsonFileAdapterOptions } from './types' - -class JsonFileDB extends MemoryDB { - private pathFile: string - private tempPath: string - listHistory: HistoryEntry[] = [] - private options: JsonFileAdapterOptions = { filename: 'db.json', debounceTime: 0 } - private initPromise: Promise | null = null - private writeQueue: Promise = Promise.resolve() - private debounceTimer: ReturnType | null = null - private pendingWrite: Promise | null = null - private pendingWriteResolvers: Array<() => void> = [] - - constructor( - options: JsonFileAdapterOptions = { - filename: 'db.json', - } - ) { - super() - this.options = { ...this.options, ...options } - this.pathFile = join(process.cwd(), this.options.filename) - this.tempPath = `${this.pathFile}.tmp` - this.initPromise = this.init() - } - - /** - * Revisamos si existe o no el archivo JSON y cargamos el historial - */ - private async init(): Promise { - try { - // Limpiar archivo temporal si existe (de crash anterior) - if (existsSync(this.tempPath)) { - try { - await fsPromises.unlink(this.tempPath) - } catch { - // Ignorar error al limpiar temp - } - } - - if (!existsSync(this.pathFile)) { - const parseData = JSON.stringify([], null, 2) - await fsPromises.writeFile(this.pathFile, parseData, 'utf-8') - this.listHistory = [] - } else { - // Cargar historial existente del archivo - const data = await fsPromises.readFile(this.pathFile, 'utf-8') - this.listHistory = this.validateJson(data) - } - } catch (e) { - console.error('[JsonFileDB] Error initializing database:', e.message) - this.listHistory = [] - } - } - - /** - * Esperar a que la inicialización termine - */ - private async waitForInit(): Promise { - if (this.initPromise) { - await this.initPromise - } - } - - /** - * Validar JSON - retorna array vacío si el JSON es inválido - * @param raw - */ - private validateJson(raw: string): HistoryEntry[] { - try { - const parsed = JSON.parse(raw) - // Asegurar que sea un array - if (Array.isArray(parsed)) { - return parsed - } - console.warn('[JsonFileDB] Database file contains invalid data (not an array), starting fresh') - return [] - } catch (e) { - console.warn('[JsonFileDB] Database file corrupted, starting fresh:', e.message) - return [] - } - } - - /** - * Leer archivo y parsear (siempre desde memoria después de init) - */ - private async readFileAndParse(): Promise { - await this.waitForInit() - return this.listHistory - } - - /** - * Escribir al archivo de forma atómica (write to temp, then rename) - * Esto previene corrupción si el proceso se interrumpe durante la escritura - */ - private async atomicWrite(): Promise { - try { - const parseData = JSON.stringify(this.listHistory, null, 2) - // Escribir a archivo temporal - await fsPromises.writeFile(this.tempPath, parseData, 'utf-8') - // Renombrar atómicamente (esto es una operación atómica en la mayoría de sistemas) - await fsPromises.rename(this.tempPath, this.pathFile) - } catch (e) { - console.error('[JsonFileDB] Error writing to database:', e.message) - // Intentar limpiar archivo temporal - try { - if (existsSync(this.tempPath)) { - await fsPromises.unlink(this.tempPath) - } - } catch { - // Ignorar error de limpieza - } - } - } - - /** - * Escribir al archivo de forma segura con cola y debounce opcional - */ - private async safeWrite(): Promise { - const debounceTime = this.options.debounceTime || 0 - - if (debounceTime > 0) { - // Con debounce: agrupar múltiples escrituras - return new Promise((resolve) => { - this.pendingWriteResolvers.push(resolve) - - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) - } - - this.debounceTimer = setTimeout(async () => { - this.debounceTimer = null - const resolvers = [...this.pendingWriteResolvers] - this.pendingWriteResolvers = [] - - this.writeQueue = this.writeQueue.then(async () => { - await this.atomicWrite() - resolvers.forEach((r) => r()) - }) - - await this.writeQueue - }, debounceTime) - }) - } else { - // Sin debounce: escritura inmediata en cola - this.writeQueue = this.writeQueue.then(async () => { - await this.atomicWrite() - }) - return this.writeQueue - } - } - - /** - * Buscar el último mensaje por número - * @param from - */ - async getPrevByNumber(from: string): Promise { - const history = await this.readFileAndParse() - if (!history.length) { - return undefined - } - - const result = history - .slice() - .reverse() - .filter((i) => !!i.keyword) - return result.find((a) => a.from === from) - } - - /** - * Guardar dato - * @param ctx - */ - async save(ctx: HistoryEntry): Promise { - await this.waitForInit() - this.listHistory.push(ctx) - await this.safeWrite() - } -} - -export { JsonFileDB } diff --git a/packages/database-json/src/types.ts b/packages/database-json/src/types.ts deleted file mode 100644 index 67d1adbca..000000000 --- a/packages/database-json/src/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface JsonFileAdapterOptions { - filename: string - /** - * Tiempo en ms para agrupar escrituras (debounce). - * Mejora performance cuando hay muchas escrituras simultáneas. - * Default: 0 (sin debounce, escritura inmediata) - */ - debounceTime?: number -} - -export interface HistoryEntry { - ref: string - keyword: string - answer: any - refSerialize: string - from: string - options: any -} diff --git a/packages/database-json/tsconfig.json b/packages/database-json/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/database-json/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/database-mongo/LICENSE.md b/packages/database-mongo/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/database-mongo/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/database-mongo/README.md b/packages/database-mongo/README.md deleted file mode 100644 index 775d40894..000000000 --- a/packages/database-mongo/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/database-mongo

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/database-mongo/__tests__/mongoAdapter.test.ts b/packages/database-mongo/__tests__/mongoAdapter.test.ts deleted file mode 100644 index bc44c4215..000000000 --- a/packages/database-mongo/__tests__/mongoAdapter.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { MongoMemoryServer } from 'mongodb-memory-server' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { MongoAdapter } from '../src/index' - -export const delay = (milliseconds: number): Promise => { - return new Promise((resolve) => setTimeout(resolve, milliseconds)) -} - -let mongoServer: MongoMemoryServer -let mongoAdapter: MongoAdapter -let invalidAdapter: MongoAdapter | null = null - -test.before(async () => { - mongoServer = await MongoMemoryServer.create() - const uri = mongoServer.getUri() - mongoAdapter = new MongoAdapter({ - dbUri: uri, - dbName: 'testDB', - }) -}) - -test('[MongoAdapter] - instantiation', () => { - assert.instance(mongoAdapter, MongoAdapter) -}) - -test('[MongoAdapter] - init', async () => { - const initialized = await mongoAdapter.init() - assert.ok(initialized, 'Initialization should be successful') - assert.ok(mongoAdapter.db, 'Database connection should be established') -}) - -test('[MongoAdapter] - init with invalid URI should handle error', async () => { - invalidAdapter = new MongoAdapter({ - dbUri: 'mongodb://invalid:27017', - dbName: 'testDB', - }) - // Wait a bit for the constructor's init to fail - await delay(100) - const result = await invalidAdapter.init() - // Should return undefined on error (falsy) - assert.not.ok(result, 'Init should return falsy on error') -}) - -test('[MongoAdapter] - save', async () => { - const ctx = { - from: '12345', - body: 'Hello Word!', - keyword: ['greeting'], - } - await mongoAdapter.save(ctx) - assert.equal(mongoAdapter.listHistory.length, 1) -}) - -test('[MongoAdapter] - save multiple documents', async () => { - const initialLength = mongoAdapter.listHistory.length - const ctx1 = { - from: '67890', - body: 'First message', - keyword: ['test'], - } - const ctx2 = { - from: '67890', - body: 'Second message', - keyword: ['test'], - } - await mongoAdapter.save(ctx1) - await mongoAdapter.save(ctx2) - assert.equal(mongoAdapter.listHistory.length, initialLength + 2) -}) - -test('[MongoAdapter] - getPrevByNumber', async () => { - const from = '12345' - const prevDocument = await mongoAdapter.getPrevByNumber(from) - assert.ok(prevDocument) - assert.equal(prevDocument.from, from) -}) - -test('[MongoAdapter] - getPrevByNumber returns latest document', async () => { - const from = '67890' - const prevDocument = await mongoAdapter.getPrevByNumber(from) - assert.ok(prevDocument) - assert.equal(prevDocument.from, from) - assert.equal(prevDocument.body, 'Second message') -}) - -test('[MongoAdapter] - getPrevByNumber returns undefined for non-existent number', async () => { - const from = 'nonexistent99999' - const prevDocument = await mongoAdapter.getPrevByNumber(from) - assert.not.ok(prevDocument, 'Should return undefined for non-existent number') -}) - -test('[MongoAdapter] - saved document should have date field', async () => { - const from = '12345' - const prevDocument = await mongoAdapter.getPrevByNumber(from) - assert.ok(prevDocument.date, 'Document should have date field') - assert.instance(prevDocument.date, Date) -}) - -test('[MongoAdapter] - credentials should be stored', () => { - assert.ok(mongoAdapter.credentials.dbUri, 'dbUri should be stored') - assert.equal(mongoAdapter.credentials.dbName, 'testDB', 'dbName should be stored') -}) - -test.after(async () => { - if (invalidAdapter) { - await invalidAdapter.close() - } - await mongoAdapter.close() - await mongoServer.stop() -}) - -test.run() diff --git a/packages/database-mongo/__tests__/mongoEdgeCases.test.ts b/packages/database-mongo/__tests__/mongoEdgeCases.test.ts deleted file mode 100644 index 84fd4770c..000000000 --- a/packages/database-mongo/__tests__/mongoEdgeCases.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { MongoMemoryServer } from 'mongodb-memory-server' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { MongoAdapter } from '../src/index' - -const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -let mongoServer: MongoMemoryServer -let mongoAdapter: MongoAdapter - -test.before(async () => { - mongoServer = await MongoMemoryServer.create() - const uri = mongoServer.getUri() - mongoAdapter = new MongoAdapter({ - dbUri: uri, - dbName: 'testEdgeCases', - }) - await mongoAdapter.init() -}) - -// ===== Concurrent saves ===== - -test('[MongoAdapter] - concurrent saves should not lose data', async () => { - const initialLen = mongoAdapter.listHistory.length - const promises = [] - for (let i = 0; i < 10; i++) { - promises.push( - mongoAdapter.save({ - from: 'concurrent_user', - body: `Message ${i}`, - keyword: ['test'], - }) - ) - } - await Promise.all(promises) - assert.equal(mongoAdapter.listHistory.length, initialLen + 10) -}) - -test('[MongoAdapter] - concurrent saves getPrevByNumber returns latest', async () => { - const lastDoc = await mongoAdapter.getPrevByNumber('concurrent_user') - assert.ok(lastDoc, 'Should find the latest concurrent save') - assert.equal(lastDoc.from, 'concurrent_user') -}) - -// ===== Edge case: special characters ===== - -test('[MongoAdapter] - save with special characters in body', async () => { - const ctx = { - from: 'special_char_user', - body: 'Hello 🚀 "quotes" & ampersand', - keyword: ['special'], - } - await mongoAdapter.save(ctx) - const prev = await mongoAdapter.getPrevByNumber('special_char_user') - assert.equal(prev.body, ctx.body, 'Special characters should be preserved') -}) - -test('[MongoAdapter] - save with unicode phone numbers', async () => { - const ctx = { - from: '+5491155551234', - body: 'Hola mundo', - keyword: ['intl'], - } - await mongoAdapter.save(ctx) - const prev = await mongoAdapter.getPrevByNumber('+5491155551234') - assert.ok(prev, 'Should find by international phone format') -}) - -// ===== Edge case: empty and null fields ===== - -test('[MongoAdapter] - save with empty body', async () => { - const ctx = { - from: 'empty_body_user', - body: '', - keyword: [], - } - await mongoAdapter.save(ctx) - const prev = await mongoAdapter.getPrevByNumber('empty_body_user') - assert.equal(prev.body, '') -}) - -test('[MongoAdapter] - save with null keyword', async () => { - const ctx = { - from: 'null_keyword_user', - body: 'test', - keyword: null, - } - await mongoAdapter.save(ctx) - const prev = await mongoAdapter.getPrevByNumber('null_keyword_user') - assert.equal(prev.keyword, null) -}) - -// ===== Edge case: very long data ===== - -test('[MongoAdapter] - save with very long body', async () => { - const longBody = 'A'.repeat(10000) - const ctx = { - from: 'long_body_user', - body: longBody, - keyword: ['long'], - } - await mongoAdapter.save(ctx) - const prev = await mongoAdapter.getPrevByNumber('long_body_user') - assert.equal(prev.body.length, 10000) -}) - -// ===== Edge case: multiple messages from same user ===== - -test('[MongoAdapter] - getPrevByNumber returns most recent entry', async () => { - await mongoAdapter.save({ from: 'multi_user', body: 'First', keyword: ['a'] }) - await delay(50) - await mongoAdapter.save({ from: 'multi_user', body: 'Second', keyword: ['b'] }) - await delay(50) - await mongoAdapter.save({ from: 'multi_user', body: 'Third', keyword: ['c'] }) - - const prev = await mongoAdapter.getPrevByNumber('multi_user') - assert.equal(prev.body, 'Third', 'Should return the most recent message') -}) - -// ===== Edge case: object with extra fields ===== - -test('[MongoAdapter] - save preserves extra context fields', async () => { - const ctx = { - from: 'extra_fields_user', - body: 'test', - keyword: ['test'], - ref: 'some_ref', - refSerialize: 'some_serialize', - options: { capture: true, delay: 100 }, - } - await mongoAdapter.save(ctx) - const prev = await mongoAdapter.getPrevByNumber('extra_fields_user') - assert.equal(prev.ref, 'some_ref') - assert.equal(prev.refSerialize, 'some_serialize') - assert.ok(prev.options, 'Options should be preserved') -}) - -// ===== Edge case: date field validation ===== - -test('[MongoAdapter] - saved documents have valid Date objects', async () => { - const before = new Date() - await mongoAdapter.save({ from: 'date_test_user', body: 'test', keyword: ['dt'] }) - const after = new Date() - - const prev = await mongoAdapter.getPrevByNumber('date_test_user') - assert.instance(prev.date, Date) - - const savedDate = new Date(prev.date) - assert.ok(savedDate >= before, 'Date should be after test start') - assert.ok(savedDate <= after, 'Date should be before test end') -}) - -// ===== Edge case: listHistory accumulation ===== - -test('[MongoAdapter] - listHistory should accumulate all saved items', () => { - assert.ok(mongoAdapter.listHistory.length > 0, 'listHistory should have accumulated items') -}) - -test.after(async () => { - await mongoAdapter.close() - await mongoServer.stop() -}) - -test.run() diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json deleted file mode 100644 index ba738fb45..000000000 --- a/packages/database-mongo/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@builderbot/database-mongo", - "version": "1.4.2-alpha.11", - "description": "Esto es el conector a mongo DB", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": " npx uvu -r tsm ./__tests__ .test.ts", - "test:coverage": "npx c8 npm run test", - "test:debug": " npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "dependencies": { - "@builderbot/bot": "workspace:^", - "mongodb": "^7.0.0" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/node": "^24.10.2", - "@types/sinon": "^17.0.3", - "kleur": "^4.1.5", - "mongodb-memory-server": "^9.1.8", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "tslib": "^2.6.2", - "tsm": "^2.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/database-mongo/rollup.config.js b/packages/database-mongo/rollup.config.js deleted file mode 100644 index b10352725..000000000 --- a/packages/database-mongo/rollup.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/mongodb|@builderbot\/bot/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/database-mongo/src/index.ts b/packages/database-mongo/src/index.ts deleted file mode 100644 index aa86a1ef2..000000000 --- a/packages/database-mongo/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './mongoAdapter' diff --git a/packages/database-mongo/src/mongoAdapter.ts b/packages/database-mongo/src/mongoAdapter.ts deleted file mode 100644 index 7923b5fe4..000000000 --- a/packages/database-mongo/src/mongoAdapter.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { MemoryDB } from '@builderbot/bot' -import type { Db } from 'mongodb' -import { MongoClient } from 'mongodb' - -import type { History, MongoAdapterCredentials } from './types' - -class MongoAdapter extends MemoryDB { - client: MongoClient | null = null - db: Db | null = null - listHistory: History[] = [] - credentials: MongoAdapterCredentials = { dbUri: null, dbName: null } - - constructor(_credentials: MongoAdapterCredentials) { - super() - this.credentials = _credentials - this.init().then() - } - - init = async (): Promise => { - try { - if (!this.client) { - this.client = new MongoClient(this.credentials.dbUri, {}) - } - - await this.client.connect() - - console.log(`🆗 Connection successfully established`) - const db = this.client.db(this.credentials.dbName) - this.db = db - return true - } catch (e) { - console.log('Error', e) - return false - } - } - - close = async (): Promise => { - if (!this.client) { - return - } - - await this.client.close() - this.client = null - this.db = null - } - - getPrevByNumber = async (from: string): Promise => { - const result = await this.db.collection('history').find({ from }).sort({ _id: -1 }).limit(1).toArray() - return result[0] - } - - save = async (ctx: History): Promise => { - this.listHistory.push(ctx) - const ctxWithDate = { - ...ctx, - date: new Date(), - } - await this.db.collection('history').insertOne(ctxWithDate) - } -} - -export { MongoAdapter } diff --git a/packages/database-mongo/src/types.ts b/packages/database-mongo/src/types.ts deleted file mode 100644 index fbf09f4c5..000000000 --- a/packages/database-mongo/src/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ObjectId } from 'mongodb' - -export interface MongoAdapterCredentials { - dbUri: string - dbName: string -} - -export interface History { - from: string - body: any - keyword: string[] - _id?: ObjectId - date?: Date -} diff --git a/packages/database-mongo/tsconfig.json b/packages/database-mongo/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/database-mongo/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/database-mysql/LICENSE.md b/packages/database-mysql/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/database-mysql/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/database-mysql/README.md b/packages/database-mysql/README.md deleted file mode 100644 index c8d6356c6..000000000 --- a/packages/database-mysql/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/database-mysql

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/database-mysql/__tests__/mysqlAdapter.test.ts b/packages/database-mysql/__tests__/mysqlAdapter.test.ts deleted file mode 100644 index 79f97ae9b..000000000 --- a/packages/database-mysql/__tests__/mysqlAdapter.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import mysql2 from 'mysql2' -import { spy, stub } from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { MysqlAdapter } from '../src/' - -const mockCredentials: any = { - host: 'localhost', - user: 'test', - database: 'test', - password: 'test3', -} -const phone = '12335' - -const mockHistoryRow = { - phone, - id: 1, - ref: 'testRef', - keyword: 'testKeyword', - answer: 'testAnswer', - refSerialize: 'testRefSerialize', - options: '{"key": "value"}', - created_at: '2022-01-11T00:00:00Z', -} - -const mockConnection = { - connect: async (callback) => { - await callback(null) - }, - query: (sql, callback) => { - callback(null, []) - }, -} - -const messageError = 'Error!' - -class MockMysqlAdapter extends MysqlAdapter { - queryResult: any - db: any - constructor(credentials) { - super(credentials) - this.db = mockConnection - this.init().then() - } - - mockQueryResult(result) { - this.queryResult = result - } - - async init(): Promise {} - - insert() { - this.db = { - query: (_sql: string, values: any[], callback: (err: any) => void) => { - callback(null) - }, - } - } - - error(message: string) { - this.db = { - query: (_sql: string, callback: (err: any) => void) => { - callback(new Error(message)) - }, - } - } - - errorInsert(message: string) { - this.db = { - query: (_sql: string, values: any[], callback: (err: any) => void) => { - callback(new Error(message)) - }, - } - } -} - -const mockMysqlAdapter = new MockMysqlAdapter(mockCredentials) - -const createTableSpy = spy(mockMysqlAdapter, 'createTable') -const checkTableExistsSpy = spy(mockMysqlAdapter, 'checkTableExists') - -test.after.each(() => { - createTableSpy.resetHistory() - checkTableExistsSpy.resetHistory() -}) - -test('init - You should connect to the database', async () => { - const createConnectionStub = stub(mysql2 as any, 'createConnection').returns({ - connect: stub().resolves(null), - query: stub().callsFake(() => null), - }) - const databaseManager = new MysqlAdapter(mockCredentials) - databaseManager.db.connect = stub().callsFake((callback) => callback(null)) - const consoleLogSpy = spy(console, 'log') - const checkTableExistsSutb = stub().resolves() - databaseManager.checkTableExists = checkTableExistsSutb - await databaseManager.init() - assert.equal(createConnectionStub.called, true) - assert.equal(consoleLogSpy.called, true) - assert.equal(checkTableExistsSutb.called, true) - assert.equal(consoleLogSpy.args[0][0], 'Successful database connection request') - consoleLogSpy.restore() -}) - -test('[MysqlAdapter] - instantiation', () => { - assert.instance(mockMysqlAdapter, MockMysqlAdapter) - assert.equal(mockMysqlAdapter.credentials, mockCredentials) -}) - -test('[MysqlAdapter] - init', async () => { - assert.equal(mockMysqlAdapter.db, mockConnection) -}) - -test('[MysqlAdapter] - createTable ', async () => { - const result = await mockMysqlAdapter.createTable() - assert.equal(result, true) -}) - -test('[MysqlAdapter] - createTable error ', async () => { - try { - mockMysqlAdapter.error(messageError) - await mockMysqlAdapter.createTable() - } catch (error) { - assert.equal(error.message, messageError) - } -}) - -test('[MysqlAdapter] - save ', async () => { - const ctx = { - ref: 'example_ref', - keyword: 'example_keyword', - answer: 'example_answer', - refSerialize: 'example_ref_serialize', - from: 'example_from', - options: { example_option: 'value' }, - } - try { - mockMysqlAdapter.insert() - await mockMysqlAdapter.save(ctx) - assert.ok(true) - } catch (error) { - assert.unreachable('No debería lanzar un error') - } -}) - -test('[MysqlAdapter] - save error ', async () => { - try { - mockMysqlAdapter.errorInsert(messageError) - const ctx = { - ref: 'example_ref', - keyword: 'example_keyword', - answer: 'example_answer', - refSerialize: 'example_ref_serialize', - from: 'example_from', - options: { example_option: 'value' }, - } - await mockMysqlAdapter.save(ctx) - } catch (error) { - assert.equal(error.message, messageError) - } -}) - -test('[MysqlAdapter] - getPrevByNumber ', async () => { - const mockQueryResult = [mockHistoryRow] - - mockMysqlAdapter.db.query = (sql: string, callback: Function) => { - if (sql.startsWith('SELECT')) { - callback({}, mockQueryResult) - } - } - - const result = await mockMysqlAdapter.getPrevByNumber(phone) - assert.equal(result, mockHistoryRow) -}) - -test('[MysqlAdapter] - getPrevByNumber - null ', async () => { - const phone = '33333' - - const mockQueryResult = [] - - mockMysqlAdapter.db.query = (sql: string, callback: Function) => { - if (sql.startsWith('SELECT')) { - callback(null, mockQueryResult) - } - } - - const result = await mockMysqlAdapter.getPrevByNumber(phone) - assert.equal(result, null) -}) - -test('[MysqlAdapter] - getPrevByNumber - error ', async () => { - try { - mockMysqlAdapter.error(messageError) - await mockMysqlAdapter.getPrevByNumber(phone) - } catch (error) { - assert.equal(error.message, messageError) - } -}) - -test('checkTableExists - should return true if table exists', async () => { - mockMysqlAdapter.db = { - query: (__, callback: Function) => { - callback(null, [{ table: 'history' }]) - }, - } - const result = await mockMysqlAdapter.checkTableExists() - console.log(result) - assert.is(result, true) - assert.is(createTableSpy.notCalled, true) -}) - -test('You must call the createTable method if the table does not exist', async () => { - mockMysqlAdapter.db = { - query: (__, callback: Function) => { - callback(null, []) - }, - } - const result = await mockMysqlAdapter.checkTableExists() - assert.is(result, false) -}) - -test('should throw error when query fails', async () => { - mockMysqlAdapter.db = { - query: (__, callback: Function) => { - callback(new Error('Error executing SQL query')) - }, - } - - try { - await mockMysqlAdapter.checkTableExists() - assert.unreachable('Expected an error but none was thrown') - } catch (error) { - assert.instance(error, Error) - assert.is(error.message, 'Error executing SQL query') - } -}) - -test('should initialize successfully and check table existence', async () => { - const mysqlAdapter = new MockMysqlAdapter(mockCredentials) - mysqlAdapter.db = { - connect: (callback: Function) => { - callback(null) - }, - } - - await mysqlAdapter.init() - assert.is(checkTableExistsSpy.call.length, 1) -}) - -test.run() diff --git a/packages/database-mysql/__tests__/mysqlEdgeCases.test.ts b/packages/database-mysql/__tests__/mysqlEdgeCases.test.ts deleted file mode 100644 index ece3ae79c..000000000 --- a/packages/database-mysql/__tests__/mysqlEdgeCases.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { MysqlAdapter } from '../src/' - -const mockCredentials: any = { - host: 'localhost', - user: 'test', - database: 'test', - password: 'test', - port: 3306, -} - -/** - * Extended mock for edge case testing - */ -class EdgeCaseMysqlAdapter extends MysqlAdapter { - db: any - - constructor(credentials: any) { - super(credentials) - this.db = { - connect: async (callback: Function) => callback(null), - query: (_sql: string, callback: Function) => callback(null, []), - } - } - - async init(): Promise {} - - setQueryHandler(handler: Function) { - this.db = { - ...this.db, - query: handler, - } - } -} - -let adapter: EdgeCaseMysqlAdapter - -test.before.each(() => { - adapter = new EdgeCaseMysqlAdapter(mockCredentials) -}) - -// ===== Constructor and credentials ===== - -test('[MysqlAdapter Edge] - should store credentials correctly', () => { - assert.equal(adapter.credentials.host, 'localhost') - assert.equal(adapter.credentials.port, 3306) - assert.equal(adapter.credentials.user, 'test') - assert.equal(adapter.credentials.database, 'test') -}) - -test('[MysqlAdapter Edge] - listHistory should start empty', () => { - assert.equal(adapter.listHistory.length, 0) -}) - -// ===== getPrevByNumber edge cases ===== - -test('[MysqlAdapter Edge] - getPrevByNumber should parse JSON options', async () => { - const mockRow = { - phone: '12345', - id: 1, - ref: 'ref1', - keyword: 'kw', - answer: 'ans', - refSerialize: 'ser', - options: '{"capture":true,"delay":500}', - created_at: '2024-01-01', - } - - adapter.setQueryHandler((sql: string, callback: Function) => { - callback(null, [mockRow]) - }) - - const result = await adapter.getPrevByNumber('12345') - assert.equal(result.options.capture, true) - assert.equal(result.options.delay, 500) -}) - -test('[MysqlAdapter Edge] - getPrevByNumber should return empty object for no results', async () => { - adapter.setQueryHandler((sql: string, callback: Function) => { - callback(null, []) - }) - - const result = await adapter.getPrevByNumber('nonexistent') - assert.equal(JSON.stringify(result), '{}') -}) - -test('[MysqlAdapter Edge] - getPrevByNumber should reject on error', async () => { - adapter.setQueryHandler((sql: string, callback: Function) => { - callback(new Error('DB connection lost')) - }) - - try { - await adapter.getPrevByNumber('12345') - assert.unreachable('Should have thrown') - } catch (error) { - assert.instance(error, Error) - assert.equal(error.message, 'DB connection lost') - } -}) - -// ===== save edge cases ===== - -test('[MysqlAdapter Edge] - save should handle special characters in answer', async () => { - adapter.setQueryHandler((_sql: string, _values: any[], callback: Function) => { - callback(null) - }) - - const ctx = { - ref: 'ref1', - keyword: 'test', - answer: 'He said "hello" & goodbye
', - refSerialize: 'ser1', - from: '12345', - options: {}, - } - - try { - await adapter.save(ctx) - assert.ok(true, 'Should save without error') - } catch { - assert.unreachable('Should not throw for special characters') - } -}) - -test('[MysqlAdapter Edge] - save should throw on insert error', async () => { - adapter.setQueryHandler((_sql: string, _values: any[], callback: Function) => { - callback(new Error('Duplicate entry')) - }) - - try { - await adapter.save({ - ref: 'ref1', - keyword: 'kw', - answer: 'ans', - refSerialize: 'ser1', - from: '12345', - options: {}, - }) - assert.unreachable('Should have thrown') - } catch (error) { - assert.instance(error, Error) - } -}) - -test('[MysqlAdapter Edge] - save should serialize nested options to JSON', async () => { - let savedValues: any = null - // MysqlAdapter.save calls: db.query(sql, [values], callback) - // where values is [[ref, keyword, answer, refSerialize, from, JSON.stringify(options)]] - adapter.db = { - ...adapter.db, - query: (_sql: string, values: any, callback: Function) => { - savedValues = values - callback(null) - }, - } - - const ctx = { - ref: 'ref1', - keyword: 'kw', - answer: 'ans', - refSerialize: 'ser1', - from: '12345', - options: { capture: true, buttons: [{ body: 'yes' }, { body: 'no' }] }, - } - - await adapter.save(ctx) - - assert.ok(savedValues, 'Values should have been passed') - // savedValues is [[ref, keyword, answer, refSerialize, from, JSON.stringify(options)]] - const optionsStr = savedValues[0][0][5] // unwrap the double-wrapping - assert.type(optionsStr, 'string') - const parsed = JSON.parse(optionsStr) - assert.equal(parsed.capture, true) - assert.equal(parsed.buttons.length, 2) -}) - -// ===== createTable edge cases ===== - -test('[MysqlAdapter Edge] - createTable should resolve true on success', async () => { - adapter.setQueryHandler((_sql: string, callback: Function) => { - callback(null) - }) - - const result = await adapter.createTable() - assert.equal(result, true) -}) - -test('[MysqlAdapter Edge] - createTable should throw on error', async () => { - adapter.setQueryHandler((_sql: string, callback: Function) => { - callback(new Error('Permission denied')) - }) - - try { - await adapter.createTable() - assert.unreachable('Should have thrown') - } catch (error) { - assert.instance(error, Error) - assert.equal(error.message, 'Permission denied') - } -}) - -// ===== checkTableExists edge cases ===== - -test('[MysqlAdapter Edge] - checkTableExists should return true when table exists', async () => { - adapter.setQueryHandler((_sql: string, callback: Function) => { - callback(null, [{ Tables_in_test: 'history' }]) - }) - - const result = await adapter.checkTableExists() - assert.equal(result, true) -}) - -test('[MysqlAdapter Edge] - checkTableExists should return false and call createTable when no table', async () => { - adapter.setQueryHandler((_sql: string, callback: Function) => { - callback(null, []) - }) - - const result = await adapter.checkTableExists() - assert.equal(result, false) -}) - -test('[MysqlAdapter Edge] - checkTableExists should throw on query error', async () => { - adapter.setQueryHandler((_sql: string, callback: Function) => { - callback(new Error('Query failed')) - }) - - try { - await adapter.checkTableExists() - assert.unreachable('Should have thrown') - } catch (error) { - assert.instance(error, Error) - assert.equal(error.message, 'Query failed') - } -}) - -// ===== Connection failure scenarios ===== - -test('[MysqlAdapter Edge] - init should handle connection error', async () => { - const failAdapter = new EdgeCaseMysqlAdapter(mockCredentials) - failAdapter.db = { - connect: (callback: Function) => { - callback(new Error('Connection refused')) - }, - query: () => {}, - } as any - - const consoleSpy: string[] = [] - const originalLog = console.log - console.log = (...args: any[]) => consoleSpy.push(args.join(' ')) - - // Call the parent's init which has the connect logic - // EdgeCaseMysqlAdapter overrides init, so we access the connection handler directly - failAdapter.db.connect((error: any) => { - if (error) { - console.log(`Failed connection request ${error.stack}`) - } - }) - - console.log = originalLog - assert.ok( - consoleSpy.some((msg: string) => msg.includes('Failed connection request')), - 'Should log connection failure' - ) -}) - -// ===== Multiple sequential operations ===== - -test('[MysqlAdapter Edge] - multiple saves should accumulate listHistory', async () => { - adapter.setQueryHandler((_sql: string, _values: any[], callback: Function) => { - callback(null) - }) - - const baseCtx = { - ref: 'ref', - keyword: 'kw', - answer: 'ans', - refSerialize: 'ser', - from: '12345', - options: {}, - } - - const initialLen = adapter.listHistory.length - // Note: MysqlAdapter.save doesn't push to listHistory (only Postgres does) - // This test validates the actual behavior - await adapter.save(baseCtx) - await adapter.save({ ...baseCtx, ref: 'ref2' }) - await adapter.save({ ...baseCtx, ref: 'ref3' }) - - // MysqlAdapter doesn't maintain listHistory in save(), so length stays the same - // This documents the current behavior - assert.ok(true, 'Multiple saves should not throw') -}) - -test.run() diff --git a/packages/database-mysql/package.json b/packages/database-mysql/package.json deleted file mode 100644 index 2f1619f5d..000000000 --- a/packages/database-mysql/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@builderbot/database-mysql", - "version": "1.4.2-alpha.11", - "description": "Esto es el conector a Mysql", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": " npx uvu -r tsm ./__tests__ .test.ts", - "test:debug": " npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts", - "test:coverage": "npx c8 npm run test" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "dependencies": { - "@builderbot/bot": "workspace:^", - "mysql2": "^3.15.3" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/node": "^24.10.2", - "@types/sinon": "^17.0.3", - "kleur": "^4.1.5", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "tslib": "^2.6.2", - "tsm": "^2.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/database-mysql/rollup.config.js b/packages/database-mysql/rollup.config.js deleted file mode 100644 index f32d4ff7b..000000000 --- a/packages/database-mysql/rollup.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/mysql2|@builderbot\/bot/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/database-mysql/src/index.ts b/packages/database-mysql/src/index.ts deleted file mode 100644 index 25f8201aa..000000000 --- a/packages/database-mysql/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './mysqlAdapter' diff --git a/packages/database-mysql/src/mysqlAdapter.ts b/packages/database-mysql/src/mysqlAdapter.ts deleted file mode 100644 index b29f95171..000000000 --- a/packages/database-mysql/src/mysqlAdapter.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { MemoryDB } from '@builderbot/bot' -import type { Connection, OkPacket, RowDataPacket } from 'mysql2' -import mysql from 'mysql2' - -import type { HistoryRow, MysqlAdapterCredentials } from './types' - -class MysqlAdapter extends MemoryDB { - db: Connection - listHistory = [] - credentials: MysqlAdapterCredentials = { - host: null, - user: null, - database: null, - password: null, - port: 3306, - } - - constructor(_credentials: MysqlAdapterCredentials) { - super() - this.credentials = _credentials - this.init().then() - } - - async init(): Promise { - this.db = mysql.createConnection(this.credentials) - - this.db.connect(async (error: any) => { - if (!error) { - console.log(`Successful database connection request`) - await this.checkTableExists() - } - - if (error) { - console.log(`Failed connection request ${error.stack}`) - } - }) - } - - getPrevByNumber = async (from: any): Promise => { - return await new Promise((resolve, reject) => { - const sql = `SELECT * FROM history WHERE phone='${from}' ORDER BY id DESC` - this.db.query(sql, (error: any, rows: any[]) => { - if (error) { - reject(error) - } - if (rows.length) { - const [row] = rows - row.options = JSON.parse(row.options) - resolve(row) - } - if (!rows.length) { - resolve({} as HistoryRow) - } - }) - }) - } - - save = async (ctx: { - ref: string - keyword: string - answer: any - refSerialize: string - from: string - options: any - }): Promise => { - const values = [[ctx.ref, ctx.keyword, ctx.answer, ctx.refSerialize, ctx.from, JSON.stringify(ctx.options)]] - const sql = 'INSERT INTO history (ref, keyword, answer, refSerialize, phone, options) values ?' - - this.db.query(sql, [values], (err: any) => { - if (err) throw err - }) - } - - createTable = (): Promise => - new Promise((resolve) => { - const tableName = 'history' - - const sql = `CREATE TABLE ${tableName} - (id INT AUTO_INCREMENT PRIMARY KEY, - ref varchar(255) DEFAULT NULL, - keyword varchar(255) NULL, - answer longtext NULL, - refSerialize varchar(255) NULL, - phone varchar(255) NOT NULL, - options longtext NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) - CHARACTER SET utf8mb4 COLLATE utf8mb4_General_ci` - - this.db.query(sql, (err: any) => { - if (err) throw err - console.log(`Table ${tableName} created successfully`) - resolve(true) - }) - }) - - checkTableExists = (): Promise => - new Promise((resolve) => { - const sql = "SHOW TABLES LIKE 'history'" - this.db.query(sql, (err: any, rows: string | any[]) => { - if (err) throw err - - if (!rows.length) { - this.createTable() - } - - resolve(!!rows.length) - }) - }) -} - -export { MysqlAdapter } diff --git a/packages/database-mysql/src/types.ts b/packages/database-mysql/src/types.ts deleted file mode 100644 index 8968494b9..000000000 --- a/packages/database-mysql/src/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RowDataPacket } from 'mysql2' - -export interface MysqlAdapterCredentials { - host: string - user: string - database: string - password: string - port: number -} - -export interface HistoryRow extends RowDataPacket { - id: number - ref: string - keyword: string | null - answer: string - refSerialize: string - phone: string - options: string - created_at: Date -} diff --git a/packages/database-mysql/tsconfig.json b/packages/database-mysql/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/database-mysql/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/database-postgres/LICENSE.md b/packages/database-postgres/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/database-postgres/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/database-postgres/README.md b/packages/database-postgres/README.md deleted file mode 100644 index 58c7082ff..000000000 --- a/packages/database-postgres/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/database-postgres

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/database-postgres/__tests__/ postgresAdapter.test.ts b/packages/database-postgres/__tests__/ postgresAdapter.test.ts deleted file mode 100644 index 13026fe1c..000000000 --- a/packages/database-postgres/__tests__/ postgresAdapter.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { Pool } from 'pg' -import { spy, stub } from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { PostgreSQLAdapter } from '../src/postgresAdapter' -import type { Contact, HistoryEntry } from '../src/types' - -const credentials = { host: 'localhost', user: '', database: '', password: null, port: 5432 } - -const historyMock: HistoryEntry = { - ref: 'exampleRef', - keyword: 'exampleKeyword', - answer: 'exampleAnswer', - refSerialize: 'exampleRefSerialize', - from: '123456789', - options: { - option1: 'value1', - option2: 42, - }, -} - -const contactMock: Contact = { - id: 1, - phone: '5551234', - created_at: '2024-01-17T12:30:00Z', - updated_in: '2024-01-18T09:45:00Z', - last_interaction: '2024-01-18T10:15:00Z', - values: { - name: 'John Doe', - email: 'john.doe@example.com', - age: 30, - }, -} - -class MockPool { - async connect(): Promise { - return Promise.resolve({ - async query() { - return { rows: [] } - }, - }) - } -} - -test.before(() => { - Pool.prototype.connect = async function () { - return new MockPool().connect() - } -}) - -test('init() debería establecer correctamente la conexión y llamar a checkTableExistsAndSP', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - - assert.is(postgreSQLAdapter.db, undefined) - const checkTableExistsAndSPSpy = spy(postgreSQLAdapter, 'checkTableExistsAndSP') - - await postgreSQLAdapter.init() - assert.ok(postgreSQLAdapter.db) - assert.ok(checkTableExistsAndSPSpy) -}) - -test('getPrevByNumber - It should return undefined', async () => { - const from = '123456789' - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [] } - }, - } - - const result = await postgreSQLAdapter.getPrevByNumber(from) - assert.is(result, undefined) -}) - -test('getPrevByNumber - getPrevByNumber returns the previous history entry', async () => { - const from = '123456789' - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [{ ...historyMock }] } - }, - } - - const result = await postgreSQLAdapter.getPrevByNumber(from) - assert.is(result?.from, historyMock.from) - assert.is(result?.ref, historyMock.ref) - assert.is(result?.refSerialize, undefined) -}) - -test('getPrevByNumber - It should return error', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - throw new Error('Error!!') - }, - } - try { - const from = '123456789' - await postgreSQLAdapter.getPrevByNumber(from) - assert.unreachable('An error was expected, but it did not occur') - } catch (error) { - assert.is(error instanceof Error, true) - assert.is(error.message, 'Error!!') - } -}) - -test('getContact - It should return undefined', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [] } - }, - } - - const result = await postgreSQLAdapter.getContact(historyMock) - assert.is(result, undefined) -}) - -test('getContact - It I should return a contact', async () => { - const mock = { - ...historyMock, - phone: contactMock.phone, - } - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [{ ...contactMock }] } - }, - } - - const result = await postgreSQLAdapter.getContact(mock) - assert.is(result?.phone, contactMock.phone) - assert.is(result?.id, contactMock.id) - assert.is(result?.values, contactMock.values) -}) - -test('getContact - It should return error', async () => { - const mock = { - ...historyMock, - phone: contactMock.phone, - } - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - throw new Error('Error!!') - }, - } - try { - await postgreSQLAdapter.getContact(mock) - assert.unreachable('An error was expected, but it did not occur') - } catch (error) { - assert.is(error instanceof Error, true) - assert.is(error.message, 'Error!!') - } -}) - -test('save method saves history entry', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [], rowCount: 1 } - }, - } - const querySpy = spy(postgreSQLAdapter['db'], 'query') - await postgreSQLAdapter.save(historyMock) - assert.is(postgreSQLAdapter.listHistory.length, 1) - assert.ok(querySpy) -}) - -test('checkTableExistsAndSP - creates or checks tables and stored procedures', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [], rowCount: 1 } - }, - } - const querySpy = spy(postgreSQLAdapter['db'], 'query') - await postgreSQLAdapter.checkTableExistsAndSP() - assert.ok(querySpy) -}) - -test('createSP - creates or checks tables and stored procedures', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [], rowCount: 1 } - }, - } - const querySpy = spy(postgreSQLAdapter['db'], 'query') - await postgreSQLAdapter.createSP() - assert.ok(querySpy) -}) - -test('saveContact - I should save a contact', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [], rowCount: 1 } - }, - } - const getContactStub = stub().resolves(contactMock) - postgreSQLAdapter.getContact = getContactStub - const querySpy = spy(postgreSQLAdapter['db'], 'query') - await postgreSQLAdapter.saveContact(historyMock) - assert.equal(getContactStub.args[0][0], historyMock) - assert.ok(querySpy) -}) - -test('saveContact - deberia guardar un contacto', async () => { - const dataMock = { - ...historyMock, - action: 'B', - } - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - return { rows: [], rowCount: 1 } - }, - } - const getContactStub = stub().resolves(contactMock) - postgreSQLAdapter.getContact = getContactStub - const querySpy = spy(postgreSQLAdapter['db'], 'query') - await postgreSQLAdapter.saveContact(dataMock) - assert.equal(getContactStub.args[0][0], dataMock) - assert.ok(querySpy) -}) - -test('saveContact - It should return error', async () => { - const mock = { - ...historyMock, - phone: contactMock.phone, - } - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - throw new Error('Error!!') - }, - } - try { - const getContactStub = stub().resolves(contactMock) - postgreSQLAdapter.getContact = getContactStub - await postgreSQLAdapter.saveContact(mock) - assert.unreachable('An error was expected, but it did not occur') - } catch (error) { - assert.is(error instanceof Error, true) - assert.is(error.message, 'Error!!') - } -}) - -test('save - It should return error', async () => { - const mock = { - ...historyMock, - phone: contactMock.phone, - } - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - throw new Error('Error!!') - }, - } - try { - await postgreSQLAdapter.save(mock) - assert.unreachable('An error was expected, but it did not occur') - } catch (error) { - assert.is(error instanceof Error, true) - assert.is(error.message, 'Error!!') - } -}) - -test('checkTableExistsAndSP - It should return error', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - throw new Error('Error!!') - }, - } - try { - await postgreSQLAdapter.checkTableExistsAndSP() - assert.unreachable('An error was expected, but it did not occur') - } catch (error) { - assert.is(error instanceof Error, true) - assert.is(error.message, 'Error!!') - } -}) - -test('createSP - It should return error', async () => { - const postgreSQLAdapter = new PostgreSQLAdapter(credentials) - postgreSQLAdapter['db'] = { - query: async () => { - throw new Error('Error!!') - }, - } - try { - await postgreSQLAdapter.createSP() - assert.unreachable('An error was expected, but it did not occur') - } catch (error) { - assert.is(error instanceof Error, true) - assert.is(error.message, 'Error!!') - } -}) - -test.run() diff --git a/packages/database-postgres/__tests__/postgresEdgeCases.test.ts b/packages/database-postgres/__tests__/postgresEdgeCases.test.ts deleted file mode 100644 index 7d7c14f12..000000000 --- a/packages/database-postgres/__tests__/postgresEdgeCases.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { Pool } from 'pg' -import { stub } from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { PostgreSQLAdapter } from '../src/postgresAdapter' -import type { HistoryEntry } from '../src/types' - -const credentials = { host: 'localhost', user: '', database: '', password: null, port: 5432 } - -class MockPool { - async connect(): Promise { - return Promise.resolve({ - async query() { - return { rows: [] } - }, - }) - } -} - -test.before(() => { - Pool.prototype.connect = async function () { - return new MockPool().connect() - } -}) - -const createAdapter = (queryHandler?: Function): PostgreSQLAdapter => { - const adapter = new PostgreSQLAdapter(credentials) - adapter['db'] = { - query: queryHandler || (async () => ({ rows: [], rowCount: 0 })), - } - return adapter -} - -// ===== Concurrent save operations ===== - -test('[PostgreSQL Edge] - concurrent saves should not throw', async () => { - const queryCalls: any[] = [] - const adapter = createAdapter(async (...args: any[]) => { - queryCalls.push(args) - return { rows: [], rowCount: 1 } - }) - - const historyBase: HistoryEntry = { - ref: 'ref', - keyword: 'kw', - answer: 'ans', - refSerialize: 'ser', - from: '12345', - options: {}, - } - - const promises = Array.from({ length: 10 }, (_, i) => - adapter.save({ ...historyBase, ref: `ref_${i}` }) - ) - - await Promise.all(promises) - assert.equal(adapter.listHistory.length, 10, 'All 10 saves should be in listHistory') -}) - -// ===== getPrevByNumber with refserialize mapping ===== - -test('[PostgreSQL Edge] - getPrevByNumber should map refserialize to refSerialize', async () => { - const adapter = createAdapter(async () => ({ - rows: [ - { - ref: 'ref1', - keyword: 'kw', - answer: 'ans', - refserialize: 'mapped_serialize', - phone: '12345', - options: {}, - }, - ], - })) - - const result = await adapter.getPrevByNumber('12345') - assert.equal(result?.refSerialize, 'mapped_serialize', 'refserialize should be mapped to refSerialize') - assert.equal((result as any)?.refserialize, undefined, 'lowercase refserialize should be deleted') -}) - -test('[PostgreSQL Edge] - getPrevByNumber should handle null row gracefully', async () => { - const adapter = createAdapter(async () => ({ - rows: [undefined], - })) - - const result = await adapter.getPrevByNumber('12345') - assert.equal(result, undefined) -}) - -// ===== saveContact edge cases ===== - -test('[PostgreSQL Edge] - saveContact with action "a" should merge values', async () => { - let savedQuery: any = null - const adapter = createAdapter(async (...args: any[]) => { - savedQuery = args - return { rows: [], rowCount: 1 } - }) - - const existingContact = { - id: 1, - phone: '12345', - created_at: '', - updated_in: '', - last_interaction: '', - values: { name: 'John', age: 30 }, - } - - adapter.getContact = stub().resolves(existingContact) as any - - await adapter.saveContact({ - from: '12345', - action: 'a', - values: { email: 'john@test.com' }, - }) - - // The query values should contain merged JSON - const jsonValues = savedQuery[1][1] - const parsed = JSON.parse(jsonValues) - assert.equal(parsed.name, 'John', 'Should preserve existing name') - assert.equal(parsed.age, 30, 'Should preserve existing age') - assert.equal(parsed.email, 'john@test.com', 'Should add new email') -}) - -test('[PostgreSQL Edge] - saveContact with action "B" should replace values', async () => { - let savedQuery: any = null - const adapter = createAdapter(async (...args: any[]) => { - savedQuery = args - return { rows: [], rowCount: 1 } - }) - - const existingContact = { - id: 1, - phone: '12345', - created_at: '', - updated_in: '', - last_interaction: '', - values: { name: 'John', age: 30 }, - } - - adapter.getContact = stub().resolves(existingContact) as any - - await adapter.saveContact({ - from: '12345', - action: 'B', - values: { email: 'new@test.com' }, - }) - - const jsonValues = savedQuery[1][1] - const parsed = JSON.parse(jsonValues) - assert.not.ok(parsed.name, 'Should NOT preserve old name with action B') - assert.equal(parsed.email, 'new@test.com', 'Should only have new values') -}) - -test('[PostgreSQL Edge] - saveContact without action defaults to "a" (append)', async () => { - let savedQuery: any = null - const adapter = createAdapter(async (...args: any[]) => { - savedQuery = args - return { rows: [], rowCount: 1 } - }) - - adapter.getContact = stub().resolves({ - values: { existing: true }, - }) as any - - await adapter.saveContact({ - from: '12345', - values: { newField: 'value' }, - }) - - const parsed = JSON.parse(savedQuery[1][1]) - assert.equal(parsed.existing, true, 'Should preserve existing values when no action specified') - assert.equal(parsed.newField, 'value', 'Should add new field') -}) - -test('[PostgreSQL Edge] - saveContact with null values should default to empty object', async () => { - let savedQuery: any = null - const adapter = createAdapter(async (...args: any[]) => { - savedQuery = args - return { rows: [], rowCount: 1 } - }) - - adapter.getContact = stub().resolves(null) as any - - await adapter.saveContact({ - from: '12345', - values: null, - }) - - const parsed = JSON.parse(savedQuery[1][1]) - assert.equal(JSON.stringify(parsed), '{}', 'Should default to empty object') -}) - -// ===== save with complex options ===== - -test('[PostgreSQL Edge] - save should handle nested options correctly', async () => { - let savedArgs: any = null - const adapter = createAdapter(async (...args: any[]) => { - savedArgs = args - return { rows: [], rowCount: 1 } - }) - - await adapter.save({ - ref: 'ref1', - keyword: 'kw', - answer: 'ans', - refSerialize: 'ser', - from: '12345', - options: { - capture: true, - buttons: [{ body: 'yes' }, { body: 'no' }], - nested: [{ refSerialize: 'child_ser' }], - delay: 1000, - }, - }) - - const optionsStr = savedArgs[1][5] - const parsed = JSON.parse(optionsStr) - assert.equal(parsed.capture, true) - assert.equal(parsed.buttons.length, 2) - assert.equal(parsed.nested.length, 1) - assert.equal(parsed.delay, 1000) -}) - -// ===== Error propagation ===== - -test('[PostgreSQL Edge] - save error should propagate and not add to listHistory', async () => { - const adapter = createAdapter(async () => { - throw new Error('INSERT failed') - }) - - try { - await adapter.save({ - ref: 'ref1', - keyword: 'kw', - answer: 'ans', - refSerialize: 'ser', - from: '12345', - options: {}, - }) - assert.unreachable('Should have thrown') - } catch (error) { - assert.equal(error.message, 'INSERT failed') - assert.equal(adapter.listHistory.length, 0, 'listHistory should not be modified on error') - } -}) - -// ===== checkTableExistsAndSP ===== - -test('[PostgreSQL Edge] - checkTableExistsAndSP should run all queries in sequence', async () => { - const queryCount = { value: 0 } - const adapter = createAdapter(async () => { - queryCount.value++ - return { rows: [], rowCount: 1 } - }) - - await adapter.checkTableExistsAndSP() - // 2 CREATE TABLE + 2 CREATE FUNCTION = 4 queries minimum - assert.ok(queryCount.value >= 4, `Should run at least 4 queries, got ${queryCount.value}`) -}) - -// ===== Credentials ===== - -test('[PostgreSQL Edge] - should store default credentials', () => { - const adapter = new PostgreSQLAdapter(credentials) - assert.equal(adapter.credentials.host, 'localhost') - assert.equal(adapter.credentials.port, 5432) -}) - -test('[PostgreSQL Edge] - listHistory should start empty', () => { - const adapter = new PostgreSQLAdapter(credentials) - assert.equal(adapter.listHistory.length, 0) -}) - -test.run() diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json deleted file mode 100644 index f35bc6b29..000000000 --- a/packages/database-postgres/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@builderbot/database-postgres", - "version": "1.4.2-alpha.11", - "description": "> TODO: description", - "author": "vicente1992 ", - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": " npx uvu -r tsm ./__tests__ .test.ts", - "test:debug": " npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts", - "test:coverage": "npx c8 npm run test" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "dependencies": { - "@builderbot/bot": "workspace:^", - "pg": "^8.11.5" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/node": "^24.10.2", - "@types/pg": "^8.11.4", - "@types/sinon": "^17.0.3", - "kleur": "^4.1.5", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "tslib": "^2.6.2", - "tsm": "^2.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/database-postgres/rollup.config.js b/packages/database-postgres/rollup.config.js deleted file mode 100644 index e52237859..000000000 --- a/packages/database-postgres/rollup.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import terser from '@rollup/plugin-terser' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/pg|@builderbot\/bot/i.test(module), - }), - typescript(), - terser(), - ], -} diff --git a/packages/database-postgres/src/index.ts b/packages/database-postgres/src/index.ts deleted file mode 100644 index e3b768cc9..000000000 --- a/packages/database-postgres/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './postgresAdapter' diff --git a/packages/database-postgres/src/postgresAdapter.ts b/packages/database-postgres/src/postgresAdapter.ts deleted file mode 100644 index 3cae990ce..000000000 --- a/packages/database-postgres/src/postgresAdapter.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { MemoryDB } from '@builderbot/bot' -import { Pool } from 'pg' - -import type { Contact, Credential, HistoryEntry } from './types' - -class PostgreSQLAdapter extends MemoryDB { - db: any - listHistory: HistoryEntry[] = [] - credentials: Credential = { host: 'localhost', user: '', database: '', password: null, port: 5432 } - - constructor(_credentials: Credential) { - super() - this.credentials = _credentials - this.init().then() - } - - async init(): Promise { - try { - const pool = new Pool(this.credentials) - const db = await pool.connect() - this.db = db - console.log('🆗 Successful DB Connection') - this.checkTableExistsAndSP() - return true - } catch (error) { - console.log('Error', error) - throw error - } - } - - async getPrevByNumber(from: string): Promise { - const query = `SELECT * FROM public.history WHERE phone = $1 ORDER BY created_at DESC LIMIT 1` - try { - const result = await this.db.query(query, [from]) - const row = result.rows[0] - - if (row) { - row['refSerialize'] = row.refserialize - delete row.refserialize - } - - return row - } catch (error) { - console.error('Error getting previous entry by number:', error) - throw error - } - } - - async save(ctx: HistoryEntry): Promise { - const values = [ctx.ref, ctx.keyword, ctx.answer, ctx.refSerialize, ctx.from, JSON.stringify(ctx.options)] - const query = `SELECT save_or_update_history_and_contact($1, $2, $3, $4, $5, $6)` - - try { - await this.db.query(query, values) - } catch (error) { - console.error('Error registering history entry:', error) - throw error - } - this.listHistory.push(ctx) - } - - async getContact(ctx: HistoryEntry): Promise { - const from = ctx.from - const query = `SELECT * FROM public.contact WHERE phone = $1 LIMIT 1` - try { - const result = await this.db.query(query, [from]) - return result.rows[0] - } catch (error) { - console.error('Error getting contact by number:', error.message) - throw error - } - } - - async saveContact(ctx): Promise { - // action: u (Actualiza el valor de ctx.values), a (Agrega). Agrega por defecto. - const _contact = await this.getContact(ctx) - let jsValues = {} - - if ((ctx?.action ?? 'a') === 'a') { - jsValues = { ..._contact?.values, ...(ctx?.values ?? {}) } - } else { - jsValues = ctx?.values ?? {} - } - - const values = [ctx.from, JSON.stringify(jsValues)] - const query = `SELECT save_or_update_contact($1, $2)` - - try { - await this.db.query(query, values) - } catch (error) { - console.error('🚫 Error saving or updating contact:', error) - throw error - } - } - - async checkTableExistsAndSP(): Promise { - const contact = ` - CREATE TABLE IF NOT EXISTS contact ( - id SERIAL PRIMARY KEY, - phone VARCHAR(255) DEFAULT NULL, - created_at TIMESTAMP DEFAULT current_timestamp, - updated_in TIMESTAMP, - last_interaction TIMESTAMP, - values JSONB - )` - try { - await this.db.query(contact) - } catch (error) { - console.error('🚫 Error creating the contact table:', error) - throw error - } - - const history = ` - CREATE TABLE IF NOT EXISTS history ( - id SERIAL PRIMARY KEY, - ref VARCHAR(255) NOT NULL, - keyword VARCHAR(255), - answer TEXT NOT NULL, - refSerialize TEXT NOT NULL, - phone VARCHAR(255) DEFAULT NULL, - options JSONB, - created_at TIMESTAMP DEFAULT current_timestamp, - updated_in TIMESTAMP, - contact_id INTEGER REFERENCES contact(id) - )` - try { - await this.db.query(history) - } catch (error) { - console.error('🚫 Error creating the history table:', error) - throw error - } - - await this.createSP() - } - - async createSP(): Promise { - const sp_suc = ` - CREATE OR REPLACE FUNCTION save_or_update_contact( - in_phone VARCHAR(255), - in_values JSONB - ) - RETURNS VOID AS - $$ - DECLARE - contact_cursor refcursor := 'cur_contact'; - contact_id INT; - BEGIN - SELECT id INTO contact_id FROM contact WHERE phone = in_phone; - - IF contact_id IS NULL THEN - INSERT INTO contact (phone, "values") - VALUES (in_phone, in_values); - ELSE - UPDATE contact SET "values" = in_values, updated_in = current_timestamp - WHERE id = contact_id; - END IF; - END; - $$ LANGUAGE plpgsql;` - - try { - await this.db.query(sp_suc) - } catch (error) { - console.error('🚫 Error creating the stored procedure for contact:', error) - throw error - } - - const sp_suhc = ` - CREATE OR REPLACE FUNCTION save_or_update_history_and_contact( - in_ref VARCHAR(255), - in_keyword VARCHAR(255), - in_answer TEXT, - in_refserialize TEXT, - in_phone VARCHAR(255), - in_options JSONB - ) - RETURNS VOID AS - $$ - DECLARE - _contact_id INT; - BEGIN - SELECT id INTO _contact_id FROM contact WHERE phone = in_phone; - - IF _contact_id IS NULL THEN - INSERT INTO contact (phone) - VALUES (in_phone) - RETURNING id INTO _contact_id; - ELSE - UPDATE contact SET last_interaction = current_timestamp WHERE id = _contact_id; - END IF; - - INSERT INTO history (ref, keyword, answer, refserialize, phone, options, contact_id, created_at) - VALUES (in_ref, in_keyword, in_answer, in_refserialize, in_phone, in_options, _contact_id, current_timestamp); - - END; - $$ LANGUAGE plpgsql;` - - try { - await this.db.query(sp_suhc) - } catch (error) { - console.error('🚫 Error creating the stored procedure for history:', error) - throw error - } - } -} - -export { PostgreSQLAdapter } diff --git a/packages/database-postgres/src/types.ts b/packages/database-postgres/src/types.ts deleted file mode 100644 index 492c16089..000000000 --- a/packages/database-postgres/src/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type HistoryEntry = { - ref: string - keyword?: string - answer: string - refSerialize: string - from: string - options?: Record -} - -export interface Credential { - host: string - user: string - database: string - password: any - port: number -} - -export interface Contact { - id: number - phone: string - created_at: string - updated_in?: string | null - last_interaction?: string | null - values: Record -} diff --git a/packages/database-postgres/tsconfig.json b/packages/database-postgres/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/database-postgres/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/eslint-plugin-builderbot/LICENSE.md b/packages/eslint-plugin-builderbot/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/eslint-plugin-builderbot/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/eslint-plugin-builderbot/README.md b/packages/eslint-plugin-builderbot/README.md deleted file mode 100644 index b803b4fac..000000000 --- a/packages/eslint-plugin-builderbot/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

eslint-plugin-builderbot

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/eslint-plugin-builderbot/__tests__/isInsideAddActionOrAddAnswer.test.ts b/packages/eslint-plugin-builderbot/__tests__/isInsideAddActionOrAddAnswer.test.ts deleted file mode 100644 index 3eb58bebf..000000000 --- a/packages/eslint-plugin-builderbot/__tests__/isInsideAddActionOrAddAnswer.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import type { INode } from '../src/types' -import { isInsideAddActionOrAddAnswer } from '../src/utils' - -const createMockNode = (type: string, calleeName?: string): INode => ({ - type, - callee: { - property: { - name: calleeName, - }, - }, - parent: null as any, -}) - -test('isInsideAddActionOrAddAnswer - should return true if inside addAction or addAnswer', () => { - const node = createMockNode('CallExpression', 'addAction') - const result = isInsideAddActionOrAddAnswer(node) - - assert.is(result, true) -}) - -test('isInsideAddActionOrAddAnswer - should return false if not inside addAction or addAnswer', () => { - const node = createMockNode('CallExpression', 'someOtherFunction') - const result = isInsideAddActionOrAddAnswer(node) - assert.is(result, false) -}) - -test.run() diff --git a/packages/eslint-plugin-builderbot/__tests__/processDynamicFlowAwait.test.ts b/packages/eslint-plugin-builderbot/__tests__/processDynamicFlowAwait.test.ts deleted file mode 100644 index 3c3e62a47..000000000 --- a/packages/eslint-plugin-builderbot/__tests__/processDynamicFlowAwait.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as sinon from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { processDynamicFlowAwait } from '../src/rules/processDynamicFlowAwait' -import type { Context, INode } from '../src/types' - -const createMockNode = (type: string, calleeName?: string): INode => ({ - type, - callee: { - property: { - name: calleeName, - }, - } as any, - parent: null as any, -}) - -test('processDynamicFlowAwait - should report an error if endFlow is not inside a ReturnStatement', () => { - const node = createMockNode('CallExpression', 'addAction') - const parentNode = createMockNode('SomeOtherType') - const context: Context = { - report: sinon.fake(), - } - node.parent = parentNode as any - - processDynamicFlowAwait(context)['CallExpression[callee.name="flowDynamic"]'](node) - sinon.assert.calledOnceWithExactly(context.report as sinon.SinonSpy, { - node, - message: 'Please use "await" before "flowDynamic" function', - fix: sinon.match.func, - }) -}) - -test('processDynamicFlowAwait - return', () => { - const node = createMockNode('CallExpression', 'someOtherFunction') - const parentNode = createMockNode('SomeOtherType') - - const context: Context = { - report: sinon.fake(), - } - node.parent = parentNode as any - - processDynamicFlowAwait(context)['CallExpression[callee.name="flowDynamic"]'](node) - sinon.assert.notCalled(context.report as sinon.SinonSpy) -}) - -test('processDynamicFlowAwait should not report if "state.update" is not accessed', () => { - const insertTextBeforeStub = sinon.stub() - const reportStub = sinon.stub().callsFake((options: any) => { - const fixer = { - insertTextBefore: insertTextBeforeStub, - } - options.fix(fixer) - }) - const mockContext: any = { - report: reportStub, - } - const mockNode: any = { - type: 'CallExpression', - object: { - name: 'state', - }, - parent: { - type: 'OtherExpression', - }, - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - processDynamicFlowAwait(mockContext)['CallExpression[callee.name="flowDynamic"]'](mockNode) - assert.equal(mockContext.report.called, true) - assert.equal(reportStub.called, true) -}) - -test.run() diff --git a/packages/eslint-plugin-builderbot/__tests__/processEndFlowReturn.test.ts b/packages/eslint-plugin-builderbot/__tests__/processEndFlowReturn.test.ts deleted file mode 100644 index 2df960c8c..000000000 --- a/packages/eslint-plugin-builderbot/__tests__/processEndFlowReturn.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as sinon from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { processEndFlowReturn } from '../src/rules/processEndFlowReturn' -import type { INode, Context } from '../src/types' - -const createMockNode = (type: string, calleeName?: string): INode => ({ - type, - callee: { - property: { - name: calleeName, - }, - } as any, - parent: null as any, -}) - -test('processEndFlowReturn - should report an error if endFlow is not inside a ReturnStatement', () => { - const node = createMockNode('CallExpression', 'addAction') - const parentNode = createMockNode('SomeOtherType') - const context: Context = { - report: sinon.fake(), - } - node.parent = parentNode as any - - processEndFlowReturn(context)['CallExpression[callee.name="endFlow"]'](node) - sinon.assert.calledOnceWithExactly(context.report as sinon.SinonSpy, { - node, - message: 'Please ensure "endFlow" function is returned', - fix: sinon.match.func, - }) -}) - -test('processEndFlowReturn - return', () => { - const node = createMockNode('CallExpression', 'someOtherFunction') - const parentNode = createMockNode('SomeOtherType') - const context: Context = { - report: sinon.fake(), - } - node.parent = parentNode as any - - processEndFlowReturn(context)['CallExpression[callee.name="endFlow"]'](node) - sinon.assert.notCalled(context.report as sinon.SinonSpy) -}) - -test('processEndFlowReturn should not report if "state.update" is not accessed', () => { - const insertTextBeforeStub = sinon.stub() - const reportStub = sinon.stub().callsFake((options: any) => { - const fixer = { - insertTextBefore: insertTextBeforeStub, - } - options.fix(fixer) - }) - const mockContext: any = { - report: reportStub, - } - const mockNode: any = { - type: 'CallExpression', - object: { - name: 'state', - }, - parent: { - type: 'OtherExpression', - }, - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - processEndFlowReturn(mockContext)['CallExpression[callee.name="endFlow"]'](mockNode) - assert.equal(mockContext.report.called, true) - assert.equal(reportStub.called, true) -}) - -test.run() diff --git a/packages/eslint-plugin-builderbot/__tests__/processEndFlowWithFlowDynamic.test.ts b/packages/eslint-plugin-builderbot/__tests__/processEndFlowWithFlowDynamic.test.ts deleted file mode 100644 index ab9edca95..000000000 --- a/packages/eslint-plugin-builderbot/__tests__/processEndFlowWithFlowDynamic.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import sinon from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { processEndFlowWithFlowDynamic } from '../src/rules' - -test('processStateUpdateAwait should not report if "state.update" is not accessed', () => { - const mockContext: any = { - report: sinon.spy(), - } - const mockNode: any = { - type: 'MemberExpression', - object: { - name: 'someOtherObject', - }, - parent: { - type: 'AwaitExpression', - }, - range: [10, 20], - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - processEndFlowWithFlowDynamic(mockContext)['CallExpression[callee.name="endFlow"]'](mockNode) - - assert.is(mockContext.report.notCalled, true) -}) - -test('Debería llamar a context.report con el mensaje correcto si se detecta endFlow dentro del mismo contexto que flowDynamic', () => { - const getAncestorsStub = sinon - .stub() - .returns([ - { type: 'BlockStatement', body: [{ expression: { argument: { callee: { name: 'flowDynamic' } } } }] }, - ]) - const reportStub = sinon.stub() - - const contextMock: any = { - getAncestors: getAncestorsStub, - report: reportStub, - } - - const mockNode: any = { - type: 'CallExpression', - - parent: { - type: 'OtherExpression', - }, - - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - const processEndFlow = processEndFlowWithFlowDynamic(contextMock) - processEndFlow['CallExpression[callee.name="endFlow"]'](mockNode) - - assert.ok(reportStub.called) - assert.is(reportStub.firstCall.args[0].node, mockNode) - assert.is(reportStub.firstCall.args[0].message, 'Do not use endFlow in the same execution context as flowDynamic.') -}) - -test.run() diff --git a/packages/eslint-plugin-builderbot/__tests__/processFallBackReturn.test.ts b/packages/eslint-plugin-builderbot/__tests__/processFallBackReturn.test.ts deleted file mode 100644 index 159c1f3ad..000000000 --- a/packages/eslint-plugin-builderbot/__tests__/processFallBackReturn.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as sinon from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { processFallBackReturn } from '../src/rules' -import type { INode, Context } from '../src/types' - -const createMockNode = (type: string, calleeName?: string): INode => ({ - type, - callee: { - property: { - name: calleeName, - }, - } as any, - parent: null as any, -}) - -test('processFallBackReturn - should report an error if fallBack is not inside a ReturnStatement', () => { - const node = createMockNode('CallExpression', 'addAction') - const parentNode = createMockNode('SomeOtherType') - const context: Context = { - report: sinon.fake(), - } - node.parent = parentNode as any - - processFallBackReturn(context)['CallExpression[callee.name="fallBack"]'](node) - sinon.assert.calledOnceWithExactly(context.report as sinon.SinonSpy, { - node, - message: 'Please ensure "fallBack" function is returned', - fix: sinon.match.func, - }) -}) - -test('processFallBackReturn - return', () => { - const node = createMockNode('CallExpression', 'someOtherFunction') - const parentNode = createMockNode('SomeOtherType') - const context: Context = { - report: sinon.fake(), - } - node.parent = parentNode as any - - processFallBackReturn(context)['CallExpression[callee.name="fallBack"]'](node) - sinon.assert.notCalled(context.report as sinon.SinonSpy) -}) - -test('processFallBackReturn should not report if "state.update" is not accessed', () => { - const insertTextBeforeStub = sinon.stub() - const reportStub = sinon.stub().callsFake((options: any) => { - const fixer = { - insertTextBefore: insertTextBeforeStub, - } - options.fix(fixer) - }) - const mockContext: any = { - report: reportStub, - } - const mockNode: any = { - type: 'CallExpression', - object: { - name: 'state', - }, - parent: { - type: 'OtherExpression', - }, - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - processFallBackReturn(mockContext)['CallExpression[callee.name="fallBack"]'](mockNode) - assert.equal(mockContext.report.called, true) - assert.equal(reportStub.called, true) -}) - -test.run() diff --git a/packages/eslint-plugin-builderbot/__tests__/processGotoFlowReturn.test.ts b/packages/eslint-plugin-builderbot/__tests__/processGotoFlowReturn.test.ts deleted file mode 100644 index 7736e2a97..000000000 --- a/packages/eslint-plugin-builderbot/__tests__/processGotoFlowReturn.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as sinon from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { processGotoFlowReturn } from '../src/rules' -import type { INode, Context } from '../src/types' - -const createMockNode = (type: string, calleeName?: string): INode => ({ - type, - callee: { - property: { - name: calleeName, - }, - } as any, - parent: null as any, -}) - -test('processGotoFlowReturn - should report an error if gotoFlow is not inside a ReturnStatement', () => { - const node = createMockNode('CallExpression', 'addAction') - const parentNode = createMockNode('SomeOtherType') - const context: Context = { - report: sinon.fake(), - } - node.parent = parentNode as any - - processGotoFlowReturn(context)['CallExpression[callee.name="gotoFlow"]'](node) - sinon.assert.calledOnceWithExactly(context.report as sinon.SinonSpy, { - node, - message: 'Please ensure "gotoFlow" function is returned', - fix: sinon.match.func, - }) -}) - -test('processGotoFlowReturn - return', () => { - const node = createMockNode('CallExpression', 'someOtherFunction') - const parentNode = createMockNode('SomeOtherType') - const context: Context = { - report: sinon.fake(), - } - node.parent = parentNode as any - - processGotoFlowReturn(context)['CallExpression[callee.name="gotoFlow"]'](node) - sinon.assert.notCalled(context.report as sinon.SinonSpy) -}) - -test('processGotoFlowReturn should not report if "state.update" is not accessed', () => { - const insertTextBeforeStub = sinon.stub() - const reportStub = sinon.stub().callsFake((options: any) => { - const fixer = { - insertTextBefore: insertTextBeforeStub, - } - options.fix(fixer) - }) - const mockContext: any = { - report: reportStub, - } - const mockNode: any = { - type: 'CallExpression', - object: { - name: 'state', - }, - parent: { - type: 'OtherExpression', - }, - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - processGotoFlowReturn(mockContext)['CallExpression[callee.name="gotoFlow"]'](mockNode) - assert.equal(mockContext.report.called, true) - assert.equal(reportStub.called, true) -}) - -test.run() diff --git a/packages/eslint-plugin-builderbot/__tests__/processStateUpdateAwait.test.ts b/packages/eslint-plugin-builderbot/__tests__/processStateUpdateAwait.test.ts deleted file mode 100644 index 37ffc79c7..000000000 --- a/packages/eslint-plugin-builderbot/__tests__/processStateUpdateAwait.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import sinon from 'sinon' -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { processStateUpdateAwait } from '../src/rules' - -test('processStateUpdateAwait should not report if "state.update" is not accessed', () => { - const mockContext: any = { - getSourceCode: sinon.stub().returns({ - getText: sinon.stub().returns('await someOtherObject.someOtherMethod'), - }), - report: sinon.spy(), - } - const mockNode: any = { - type: 'MemberExpression', - object: { - name: 'someOtherObject', - }, - parent: { - type: 'AwaitExpression', - }, - range: [10, 20], - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - processStateUpdateAwait(mockContext)['MemberExpression[property.name="update"]'](mockNode) - - assert.is(mockContext.report.notCalled, true) -}) - -test('processStateUpdateAwait should not report if "state.update" is not accessed', () => { - const mockContext: any = { - getSourceCode: sinon.stub().returns({ - getText: sinon.stub().returns('await '), - }), - report: sinon.spy(), - } - const mockNode: any = { - type: 'MemberExpression', - object: { - name: 'state', - }, - parent: { - type: 'AwaitExpression', - }, - range: [6, 10], - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - processStateUpdateAwait(mockContext)['MemberExpression[property.name="update"]'](mockNode) - - assert.is(mockContext.report.notCalled, true) -}) - -test('processStateUpdateAwait should not report if "state.update" is not accessed', () => { - const mockContext: any = { - getSourceCode: sinon.stub().returns({ - getText: sinon.stub().returns('await '), - }), - report: sinon.spy(), - } - const mockNode: any = { - type: 'MemberExpression', - object: { - name: 'state', - }, - parent: { - type: 'AwaitExpression', - }, - range: [0, 10], - callee: { - property: { - name: 'other', - }, - }, - } - - processStateUpdateAwait(mockContext)['MemberExpression[property.name="update"]'](mockNode) - - assert.is(mockContext.report.notCalled, true) -}) - -test('processStateUpdateAwait should not report if "state.update" is not accessed', () => { - const insertTextBeforeStub = sinon.stub() - const reportStub = sinon.stub().callsFake((options: any) => { - const fixer = { - insertTextBefore: insertTextBeforeStub, - } - options.fix(fixer) - }) - const mockContext: any = { - getSourceCode: sinon.stub().returns({ - getText: sinon.stub().returns('await someOtherObject.someOtherMethod'), - }), - report: reportStub, - } - const mockNode: any = { - type: 'CallExpression', - object: { - name: 'state', - }, - parent: { - type: 'OtherExpression', - }, - range: [10, 20], - callee: { - property: { - name: 'addAnswer', - }, - }, - } - - processStateUpdateAwait(mockContext)['MemberExpression[property.name="update"]'](mockNode) - - assert.equal(mockContext.report.called, true) - assert.equal(reportStub.called, true) -}) - -test.run() diff --git a/packages/eslint-plugin-builderbot/package.json b/packages/eslint-plugin-builderbot/package.json deleted file mode 100644 index b4db3cd7d..000000000 --- a/packages/eslint-plugin-builderbot/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "eslint-plugin-builderbot", - "version": "1.4.2-alpha.11", - "description": "> TODO: description", - "author": "vicente1992 ", - "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/vicente1992/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": " npx uvu -r tsm ./__tests__ .test.ts", - "test:coverage": "npx c8 npm run test", - "test:debug": " npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts" - }, - "bugs": { - "url": "https://github.com/vicente1992/bot-whatsapp/issues" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/eslint": "^9.6.1", - "@types/node": "^24.10.2", - "@types/sinon": "^17.0.3", - "kleur": "^4.1.5", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "tslib": "^2.6.2", - "tsm": "^2.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/eslint-plugin-builderbot/rollup.config.js b/packages/eslint-plugin-builderbot/rollup.config.js deleted file mode 100644 index 3cce86e2e..000000000 --- a/packages/eslint-plugin-builderbot/rollup.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [nodeResolve(), typescript()], -} diff --git a/packages/eslint-plugin-builderbot/src/configs/recommended.ts b/packages/eslint-plugin-builderbot/src/configs/recommended.ts deleted file mode 100644 index 16fb4d5ae..000000000 --- a/packages/eslint-plugin-builderbot/src/configs/recommended.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const rulesRecommended = { - 'builderbot/func-prefix-goto-flow-return': 2, - 'builderbot/func-prefix-end-flow-return': 2, - 'builderbot/func-prefix-dynamic-flow-await': 2, - 'builderbot/func-prefix-state-update-await': 2, - 'builderbot/func-prefix-fall-back-return': 2, - 'builderbot/func-prefix-endflow-flowdynamic': 2, -} diff --git a/packages/eslint-plugin-builderbot/src/index.ts b/packages/eslint-plugin-builderbot/src/index.ts deleted file mode 100644 index 144750d46..000000000 --- a/packages/eslint-plugin-builderbot/src/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { rulesRecommended } from './configs/recommended' -import { - processDynamicFlowAwait, - processEndFlowReturn, - processEndFlowWithFlowDynamic, - processFallBackReturn, - processGotoFlowReturn, - processStateUpdateAwait, -} from './rules' - -const configs = { - recommended: { - rules: rulesRecommended, - }, -} -const rules = { - 'func-prefix-goto-flow-return': { - meta: { - fixable: 'code', - }, - create: processGotoFlowReturn, - }, - 'func-prefix-fall-back-return': { - meta: { - fixable: 'code', - }, - create: processFallBackReturn, - }, - 'func-prefix-end-flow-return': { - meta: { - fixable: 'code', - }, - create: processEndFlowReturn, - }, - 'func-prefix-dynamic-flow-await': { - meta: { - fixable: 'code', - }, - create: processDynamicFlowAwait, - }, - 'func-prefix-state-update-await': { - meta: { - fixable: 'code', - }, - create: processStateUpdateAwait, - }, - 'func-prefix-endflow-flowdynamic': { - meta: { - fixable: 'code', - }, - create: processEndFlowWithFlowDynamic, - }, -} - -export { rules, configs } diff --git a/packages/eslint-plugin-builderbot/src/rules/index.ts b/packages/eslint-plugin-builderbot/src/rules/index.ts deleted file mode 100644 index d858d5401..000000000 --- a/packages/eslint-plugin-builderbot/src/rules/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { processDynamicFlowAwait } from './processDynamicFlowAwait' -export { processEndFlowReturn } from './processEndFlowReturn' -export { processFallBackReturn } from './processFallBackReturn' -export { processGotoFlowReturn } from './processGotoFlowReturn' -export { processStateUpdateAwait } from './processStateUpdateAwait' -export { processEndFlowWithFlowDynamic } from './processEndFlowWithFlowDynamic' diff --git a/packages/eslint-plugin-builderbot/src/rules/processDynamicFlowAwait.ts b/packages/eslint-plugin-builderbot/src/rules/processDynamicFlowAwait.ts deleted file mode 100644 index fa72e7d91..000000000 --- a/packages/eslint-plugin-builderbot/src/rules/processDynamicFlowAwait.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Context, INode } from '../types' -import { isInsideAddActionOrAddAnswer } from '../utils' - -const processDynamicFlowAwait = (context: Context) => { - return { - 'CallExpression[callee.name="flowDynamic"]'(node: INode) { - const parentNode = node.parent - - // Verificar si estamos dentro de un 'addAction' o 'addAnswer' - if (!isInsideAddActionOrAddAnswer(node)) { - return - } - - // Verificar si el nodo padre es 'AwaitExpression', de lo contrario se reporta - if (parentNode.type !== 'AwaitExpression') { - context.report({ - node, - message: 'Please use "await" before "flowDynamic" function', - fix: function (fixer) { - return fixer.insertTextBefore(node, 'await ') - }, - }) - } - }, - } -} - -export { processDynamicFlowAwait } diff --git a/packages/eslint-plugin-builderbot/src/rules/processEndFlowReturn.ts b/packages/eslint-plugin-builderbot/src/rules/processEndFlowReturn.ts deleted file mode 100644 index f2ec50d33..000000000 --- a/packages/eslint-plugin-builderbot/src/rules/processEndFlowReturn.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { INode, Context } from '../types' -import { isInsideAddActionOrAddAnswer } from '../utils' - -const processEndFlowReturn = (context: Context) => { - return { - 'CallExpression[callee.name="endFlow"]'(node: INode) { - const parentNode = node.parent - - // Verificar si estamos dentro de un 'addAction' o 'addAnswer' - if (!isInsideAddActionOrAddAnswer(node)) { - return - } - - // Verificar si nodo padre es de tipo ReturnStatement, si no lo es, reportar - if (parentNode.type !== 'ReturnStatement') { - context.report({ - node, - message: 'Please ensure "endFlow" function is returned', - fix: function (fixer) { - return fixer.insertTextBefore(node, 'return ') - }, - }) - } - }, - } -} - -export { processEndFlowReturn } diff --git a/packages/eslint-plugin-builderbot/src/rules/processEndFlowWithFlowDynamic.ts b/packages/eslint-plugin-builderbot/src/rules/processEndFlowWithFlowDynamic.ts deleted file mode 100644 index 1106696ea..000000000 --- a/packages/eslint-plugin-builderbot/src/rules/processEndFlowWithFlowDynamic.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { INode, Context } from '../types' -import { isInsideAddActionOrAddAnswer } from '../utils' - -const processEndFlowWithFlowDynamic = (context: Context) => { - return { - 'CallExpression[callee.name="endFlow"]'(node: INode) { - if (!isInsideAddActionOrAddAnswer(node)) { - return - } - - const blockStatement = context - .getAncestors() - .find((ancestor: { type: string }) => ancestor.type === 'BlockStatement') - if (blockStatement) { - const calleInsideCtx = blockStatement.body.map( - (j: { expression: { argument: { callee: { name: any } } } }) => - j?.expression?.argument?.callee?.name - ) - if (calleInsideCtx.includes('flowDynamic')) { - context.report({ - node, - message: 'Do not use endFlow in the same execution context as flowDynamic.', - }) - } - } - }, - } -} - -export { processEndFlowWithFlowDynamic } diff --git a/packages/eslint-plugin-builderbot/src/rules/processFallBackReturn.ts b/packages/eslint-plugin-builderbot/src/rules/processFallBackReturn.ts deleted file mode 100644 index 2d0cc4484..000000000 --- a/packages/eslint-plugin-builderbot/src/rules/processFallBackReturn.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { INode, Context } from '../types' -import { isInsideAddActionOrAddAnswer } from '../utils' - -const processFallBackReturn = (context: Context) => { - return { - 'CallExpression[callee.name="fallBack"]'(node: INode) { - const parentNode = node.parent - - // Verificar si estamos dentro de un 'addAction' o 'addAnswer' - if (!isInsideAddActionOrAddAnswer(node)) { - return - } - - // Verificar si nodo padre es de tipo ReturnStatement, si no lo es, reportar - if (parentNode.type !== 'ReturnStatement') { - context.report({ - node, - message: 'Please ensure "fallBack" function is returned', - fix: function (fixer) { - return fixer.insertTextBefore(node, 'return ') - }, - }) - } - }, - } -} - -export { processFallBackReturn } diff --git a/packages/eslint-plugin-builderbot/src/rules/processGotoFlowReturn.ts b/packages/eslint-plugin-builderbot/src/rules/processGotoFlowReturn.ts deleted file mode 100644 index fac651ac0..000000000 --- a/packages/eslint-plugin-builderbot/src/rules/processGotoFlowReturn.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { INode, Context } from '../types' -import { isInsideAddActionOrAddAnswer } from '../utils' - -const processGotoFlowReturn = (context: Context) => { - return { - 'CallExpression[callee.name="gotoFlow"]'(node: INode) { - const parentNode = node.parent - - // Verificar si estamos dentro de un 'addAction' o 'addAnswer' - if (!isInsideAddActionOrAddAnswer(node)) { - return - } - - // Verificar si nodo padre es de tipo ReturnStatement, si no lo es, reportar - if (parentNode.type !== 'ReturnStatement') { - context.report({ - node, - message: 'Please ensure "gotoFlow" function is returned', - fix: function (fixer) { - return fixer.insertTextBefore(node, 'return ') - }, - }) - } - }, - } -} - -export { processGotoFlowReturn } diff --git a/packages/eslint-plugin-builderbot/src/rules/processStateUpdateAwait.ts b/packages/eslint-plugin-builderbot/src/rules/processStateUpdateAwait.ts deleted file mode 100644 index 0f000586a..000000000 --- a/packages/eslint-plugin-builderbot/src/rules/processStateUpdateAwait.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { isInsideAddActionOrAddAnswer } from '../utils' - -const processStateUpdateAwait = (context: any) => { - return { - 'MemberExpression[property.name="update"]'(node: any) { - // Verificar si el objeto es 'state' - if (node.object.name !== 'state') { - return - } - - if (node.object.name === 'state') { - const sourceCode = context.getSourceCode() - const rangeStart = node.range[0] - 6 // Longitud de "await " - const rangeEnd = node.range[0] - const parentNodeText = sourceCode.getText().substring(rangeStart, rangeEnd) - if (parentNodeText.includes('await')) { - return - } - } - - const parentNode = node.parent - // Verificar si estamos dentro de un 'addAction' o 'addAnswer' - if (!isInsideAddActionOrAddAnswer(node)) { - return - } - // Verificar si el nodo padre es 'AwaitExpression', de lo contrario se reporta - if (parentNode.type !== 'AwaitExpression') { - context.report({ - node, - message: 'Please use "await" before "state.update"', - fix: function (fixer) { - // Comprueba si existe un await antes de state.update - const sourceCode = context.getSourceCode() - const rangeStart = node.range[0] - 7 // Longitud de "await " - const rangeEnd = node.range[0] - const parentNodeText = sourceCode.getText().substring(rangeStart, rangeEnd) - - if (parentNodeText.trim() !== 'await') { - return fixer.insertTextBefore(node, 'await ') - } - }, - }) - } - }, - } -} - -export { processStateUpdateAwait } diff --git a/packages/eslint-plugin-builderbot/src/types.ts b/packages/eslint-plugin-builderbot/src/types.ts deleted file mode 100644 index 65cb118fc..000000000 --- a/packages/eslint-plugin-builderbot/src/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface INode { - type: string - callee?: { - name: string - property?: { - name?: string - } - } - arguments?: any - parent?: Parent -} - -export interface Parent { - arguments: any - type: string - callee?: { - property?: { - name?: string - } - } -} - -export interface ReportOptions { - node: INode - message: string - fix?: (fixer: any) => void -} - -export interface Context { - getAncestors?: () => any - report: (options: ReportOptions) => void -} diff --git a/packages/eslint-plugin-builderbot/src/utils/index.ts b/packages/eslint-plugin-builderbot/src/utils/index.ts deleted file mode 100644 index 7a6bacf93..000000000 --- a/packages/eslint-plugin-builderbot/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './isInsideAddActionOrAddAnswer' diff --git a/packages/eslint-plugin-builderbot/src/utils/isInsideAddActionOrAddAnswer.ts b/packages/eslint-plugin-builderbot/src/utils/isInsideAddActionOrAddAnswer.ts deleted file mode 100644 index d61346267..000000000 --- a/packages/eslint-plugin-builderbot/src/utils/isInsideAddActionOrAddAnswer.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { INode } from '../types' - -function isInsideAddActionOrAddAnswer(node: INode) { - let currentNode = node - while (currentNode) { - if ( - currentNode.type === 'CallExpression' && - currentNode.callee && - currentNode.callee.property && - (currentNode.callee.property.name === 'addAnswer' || currentNode.callee.property.name === 'addAction') - ) { - return true - } - currentNode = currentNode.parent as any - } - return false -} - -export { isInsideAddActionOrAddAnswer } diff --git a/packages/eslint-plugin-builderbot/tsconfig.json b/packages/eslint-plugin-builderbot/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/eslint-plugin-builderbot/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/manager/package.json b/packages/manager/package.json index ffe03f7ee..9ace9c427 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,72 +1,72 @@ { - "name": "@builderbot/manager", - "version": "1.4.2-alpha.11", - "description": "Multi-tenant bot manager for BuilderBot", - "author": "Leifer Mendez ", - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "npx uvu -r tsm ./__tests__", - "test:coverage": "npx c8 npm run test" + "name": "@japcon-bot/manager", + "version": "1.4.2-alpha.11", + "description": "Multi-tenant bot manager for BuilderBot", + "author": "Leifer Mendez ", + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "npx uvu -r tsm ./__tests__", + "test:coverage": "npx c8 npm run test" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "keywords": [ + "builderbot", + "multi-tenant", + "whatsapp", + "bot-manager" + ], + "devDependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/node": "^24.10.2", + "@types/polka": "^0.5.8", + "c8": "^10.1.3", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "tslib": "^2.8.1", + "tsm": "^2.3.0", + "uvu": "^0.5.6" + }, + "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "fluent-ffmpeg": "^2.1.3", + "polka": "^0.5.2", + "zod": "^4.1.13" + }, + "peerDependencies": { + "@japcon-bot/bot": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@builderbot/provider-baileys": { + "optional": true }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src" + "@builderbot/provider-meta": { + "optional": true }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + "@builderbot/provider-twilio": { + "optional": true }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "keywords": [ - "builderbot", - "multi-tenant", - "whatsapp", - "bot-manager" - ], - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.8", - "c8": "^10.1.3", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "tslib": "^2.8.1", - "tsm": "^2.3.0", - "uvu": "^0.5.6" - }, - "dependencies": { - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "fluent-ffmpeg": "^2.1.3", - "polka": "^0.5.2", - "zod": "^4.1.13" - }, - "peerDependencies": { - "@builderbot/bot": ">=1.0.0" - }, - "peerDependenciesMeta": { - "@builderbot/provider-baileys": { - "optional": true - }, - "@builderbot/provider-meta": { - "optional": true - }, - "@builderbot/provider-twilio": { - "optional": true - }, - "@builderbot/provider-venom": { - "optional": true - } - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" + "@builderbot/provider-venom": { + "optional": true + } + }, + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/plugins/.gitkeep b/packages/plugins/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/plugins/chatwoot/README.md b/packages/plugins/chatwoot/README.md deleted file mode 100644 index 3081cafa4..000000000 --- a/packages/plugins/chatwoot/README.md +++ /dev/null @@ -1,330 +0,0 @@ -

- -

@builderbot/plugin-chatwoot

- -

- -

- Syncs every WhatsApp conversation to Chatwoot automatically.
- Agents can reply directly from Chatwoot and their messages are forwarded to WhatsApp in real time. -

- ---- - -## What it does - -| Feature | Details | -|---|---| -| **Inbox auto-creation** | Creates an API-channel inbox on first run and reuses it on subsequent starts | -| **Contact sync** | Finds or creates the Chatwoot contact for every phone number | -| **Conversation sync** | Finds or creates the open conversation and caches it in memory | -| **Media attachments** | Images, audio, video and documents are sent as attachments in both directions | -| **Media-only messages** | WhatsApp media-only messages (no caption) are synced with a readable label (`[image]`, `[audio]`, `[file]`, …) | -| **Multiple attachments** | When an agent sends several files from Chatwoot, each one is forwarded to WhatsApp | -| **Bidirectional messages** | Bot outgoing messages → Chatwoot (outgoing) · User messages → Chatwoot (incoming) | -| **Agent replies** | Chatwoot agent messages → WhatsApp via webhook | -| **Blacklist integration** | When an agent takes a conversation, the user is added to the bot blacklist so the bot stops responding | -| **Webhook auto-registration** | Registers the webhook URL in Chatwoot and the HTTP route on the provider server automatically | -| **Group message filter** | `@g.us` group messages are silently ignored | -| **Startup validation** | Credentials are verified before anything runs; the plugin self-disables on failure | -| **Serialized API calls** | An internal queue ensures no race conditions against the Chatwoot API | - ---- - -## Installation - -```bash -pnpm add @builderbot/plugin-chatwoot -``` - ---- - -## Quick start - -```ts -import { createBot, createProvider, createFlow, MemoryDB } from '@builderbot/bot' -import { BaileysProvider } from '@builderbot/provider-baileys' -import { createChatwootPlugin } from '@builderbot/plugin-chatwoot' - -const chatwoot = createChatwootPlugin({ - token: 'YOUR_CHATWOOT_USER_TOKEN', - url: 'https://app.chatwoot.com', - accountId: 1, - // Optional but recommended: enables agent → WhatsApp replies - webhookUrl: 'https://your-bot.example.com/v1/chatwoot', -}) - -const bot = await createBot({ - flow: createFlow([...]), - provider: createProvider(BaileysProvider), - database: new MemoryDB(), -}) - -// One call wires everything up — including the webhook HTTP route -await chatwoot.attach(bot) -``` - -That's it. Every message exchanged through your bot is now mirrored in Chatwoot, and agent replies are forwarded back to WhatsApp automatically. - ---- - -## Configuration - -```ts -const chatwoot = createChatwootPlugin({ - /** User or Agent API access token from Chatwoot → Profile → Access Token */ - token: 'xxxxxxxxxxxxxxxxxxxxxxxx', - - /** Base URL of your Chatwoot instance */ - url: 'https://app.chatwoot.com', - - /** Numeric account ID visible in the Chatwoot URL */ - accountId: 1, - - /** Optional: custom inbox name (default: 'BuilderBot Inbox') */ - inboxName: 'WhatsApp Bot', - - /** - * Optional: public URL where Chatwoot will POST webhook events. - * When provided, the plugin registers (or reuses) the webhook on startup. - * Required for agent replies to reach WhatsApp. - */ - webhookUrl: 'https://your-bot.example.com/v1/chatwoot', -}) -``` - -### How to get your token - -1. Open Chatwoot → click your avatar (bottom-left) → **Profile Settings** -2. Scroll down to **Access Token** and copy it - -### How to find your accountId - -It is the number in the URL after `/app/accounts/`: -``` -https://app.chatwoot.com/app/accounts/42/conversations - ↑ - accountId = 42 -``` - ---- - -## Receiving agent replies (webhook) - -When a Chatwoot agent sends a message, Chatwoot fires a webhook to your bot. As long as you set `webhookUrl` in the config, `attach()` handles everything automatically — it registers the webhook in Chatwoot **and** wires the HTTP route on the provider's server. - -```ts -import { createBot, createFlow, addKeyword } from '@builderbot/bot' -import { BaileysProvider } from '@builderbot/provider-baileys' -import { createChatwootPlugin } from '@builderbot/plugin-chatwoot' - -const chatwoot = createChatwootPlugin({ - token: 'YOUR_TOKEN', - url: 'https://app.chatwoot.com', - accountId: 1, - webhookUrl: 'https://your-bot.example.com/v1/chatwoot', -}) - -const bot = await createBot({ - flow: createFlow([...]), - provider: createProvider(BaileysProvider, { name: 'bot' }), - database: new MemoryDB(), -}) - -// Registers the Chatwoot account webhook AND the /v1/chatwoot HTTP route automatically -await chatwoot.attach(bot) -``` - -> **Note:** The URL you pass as `webhookUrl` must be reachable by your Chatwoot server. -> For local development use [ngrok](https://ngrok.com/) or a similar tunnel. - -### Advanced: manual route registration - -If you use a custom HTTP server outside of BuilderBot's provider, you can still call `handleWebhook` directly: - -```ts -myServer.post('/v1/chatwoot', async (req, res) => { - await chatwoot.handleWebhook(bot, req.body) - res.end(JSON.stringify({ status: 'ok' })) -}) -``` - -### What `handleWebhook` handles - -| Chatwoot event | Plugin action | -|---|---| -| `conversation_updated` — agent **assigned** | Adds the user's phone to the bot blacklist (bot stops responding) | -| `conversation_updated` — agent **unassigned** | Removes the phone from the blacklist (bot resumes) | -| `message_created` — outgoing on API channel | Forwards the agent's message (text + optional media) to WhatsApp | -| `message_created` — private note | Ignored — private notes are not forwarded | -| Event for a different inbox | Ignored — only events for the plugin's inbox are processed | - ---- - -## How agent takeover works - -``` -Agent assigned to conversation - │ - ▼ - phone added to blacklist ──► bot stops responding to that user - │ - Agent types in Chatwoot - │ - ▼ - handleWebhook receives message_created - │ - ▼ - Message forwarded to WhatsApp - -Agent unassigns from conversation - │ - ▼ - phone removed from blacklist ──► bot resumes normally -``` - ---- - -## Advanced usage - -### Accessing the Chatwoot API directly - -```ts -const api = chatwoot.getApi() - -// Create a contact manually -const contact = await api.findOrCreateContact('+5215511223344', 'John Doe') - -// Send a message to an existing conversation -await api.sendMessage(conversationId, 'Hello from the API!', 'outgoing') - -// Send a message with a media attachment -await api.sendMessage(conversationId, 'See attached', 'outgoing', 'https://example.com/image.png') -// Or from a local file: -await api.sendMessage(conversationId, 'See attached', 'outgoing', '/tmp/photo.jpg') -``` - -### Inspecting the active inbox - -```ts -const inbox = chatwoot.getInbox() -console.log(inbox?.id, inbox?.name) -``` - -### Checking plugin health - -```ts -if (!chatwoot.status) { - console.warn('Chatwoot plugin is disabled — check your credentials') -} -``` - ---- - -## Environment variables (recommended) - -Store sensitive values in a `.env` file instead of hardcoding them: - -```env -CHATWOOT_TOKEN=your-access-token -CHATWOOT_URL=https://app.chatwoot.com -CHATWOOT_ACCOUNT_ID=1 -CHATWOOT_WEBHOOK_URL=https://your-bot.example.com/v1/chatwoot -``` - -```ts -const chatwoot = createChatwootPlugin({ - token: process.env.CHATWOOT_TOKEN!, - url: process.env.CHATWOOT_URL!, - accountId: Number(process.env.CHATWOOT_ACCOUNT_ID), - webhookUrl: process.env.CHATWOOT_WEBHOOK_URL, -}) -``` - ---- - -## Supported media types - -The plugin handles media in both directions. - -### WhatsApp → Chatwoot - -When an incoming WhatsApp message carries media, the file is attached to the Chatwoot message. The plugin resolves the media source through two strategies, tried in order: - -1. **`options.media` URL** — used when the provider already exposes a public media URL in `payload.options.media`. -2. **`provider.saveFile` fallback** — used when the provider carries the raw message context (e.g. Baileys) but does not populate `options.media`. The plugin calls `bot.provider.saveFile(payload)` to download the file to a temporary path, uploads it to Chatwoot, then cleans up the temp file automatically. If the download fails the message is still forwarded with the readable label as caption. - -If the message body is a provider event string, it is also converted to a human-readable caption that appears alongside the attachment: - -| WhatsApp event | Label shown in Chatwoot | -|---|---| -| `_event_media_` | `[image]` | -| `_event_voice_note_` | `[audio]` | -| `_event_document_` | `[file]` | -| `_event_video_` | `[video]` | -| `_event_location_` | `[location]` | -| `_event_sticker_` | `[sticker]` | -| `_event_order_` | `[order]` | - -### Chatwoot → WhatsApp - -When an agent uploads files in Chatwoot, each attachment is forwarded as a separate WhatsApp message. The plugin detects the MIME type from the file extension when uploading local files: - -| Extension | MIME type | -|---|---| -| `.jpg` / `.jpeg` | `image/jpeg` | -| `.png` | `image/png` | -| `.gif` | `image/gif` | -| `.webp` | `image/webp` | -| `.mp4` | `video/mp4` | -| `.pdf` | `application/pdf` | -| `.mp3` | `audio/mpeg` | -| `.ogg` / `.opus` | `audio/ogg` | -| `.wav` | `audio/wav` | -| `.svg` | `image/svg+xml` | -| other | `application/octet-stream` | - -For remote URLs, the MIME type is taken from the HTTP `Content-Type` response header automatically. - ---- - -## API reference - -### `createChatwootPlugin(config)` - -Creates the plugin instance. Returns a `ChatwootPlugin`. - -### `chatwoot.attach(bot)` - -Wires the plugin into the bot. Must be called once after `createBot`. - -- Validates Chatwoot credentials (`checkAccount`) -- Finds or creates the API-channel inbox -- Registers the account webhook in Chatwoot if `webhookUrl` is configured -- Auto-registers the HTTP route on `provider.server` if `webhookUrl` is configured -- Listens to `send_message` and `provider.message` events - -### `chatwoot.handleWebhook(bot, body)` - -Processes a raw webhook body sent by Chatwoot. Call this from your HTTP route handler. - -### `chatwoot.getApi()` - -Returns the underlying `ChatwootApi` instance for direct API calls. - -### `chatwoot.getInbox()` - -Returns the `ChatwootInbox` object (`{ id, name }`) or `null` before `attach()`. - -### `chatwoot.status` - -`true` while the plugin is operational. Set to `false` automatically if credentials fail. - ---- - -## Links - -- [BuilderBot documentation](https://builderbot.app/) -- [Chatwoot documentation](https://www.chatwoot.com/docs/) -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) diff --git a/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts b/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts deleted file mode 100644 index 37cb4bc09..000000000 --- a/packages/plugins/chatwoot/__tests__/chatwootPlugin.test.ts +++ /dev/null @@ -1,801 +0,0 @@ -import { test } from 'uvu' -import * as assert from 'uvu/assert' - -import { ChatwootApi } from '../src/chatwootApi' -import { ChatwootPlugin, createChatwootPlugin } from '../src/chatwootPlugin' - -// ─── config ─────────────────────────────────────────────────────────────────── - -const MOCK_CONFIG = { - token: 'test-token-123', - url: 'https://chatwoot.example.com', - accountId: 1, -} - -const MOCK_INBOX = { id: 42, name: 'BuilderBot Inbox' } - -// ─── fetch mock ─────────────────────────────────────────────────────────────── - -type MockFn = (url: string, opts?: RequestInit) => Promise - -/** - * Smart fetch mock: matches requests by method + URL substring. - * More specific entries (longer path strings) win over shorter ones. - */ -const makeSmartFetch = (overrides: Record = {}): { mock: MockFn; calls: string[] } => { - const calls: string[] = [] - - const defaults: Record = { - 'GET /api/v1/accounts/1/': { id: 1 }, - 'GET /inboxes': { payload: [MOCK_INBOX] }, - 'POST /inboxes': MOCK_INBOX, - 'GET /webhooks': { payload: { webhooks: [] } }, - 'POST /webhooks': { id: 1, url: '' }, - 'GET /contacts/search': { payload: [] }, - 'GET /conversations': { payload: [] }, - 'POST /contacts': { payload: { contact: { id: 10 } } }, - 'POST /conversations': { id: 99, inbox_id: 42, contact_id: 10 }, - 'POST /messages': { id: 1, content: 'ok', message_type: 'outgoing' }, - ...overrides, - } - - const mock: MockFn = async (url, opts) => { - const method = (opts?.method ?? 'GET').toUpperCase() - calls.push(`${method} ${String(url)}`) - - const key = Object.keys(defaults) - .sort((a, b) => b.length - a.length) - .find((k) => { - const space = k.indexOf(' ') - const kMethod = k.slice(0, space) - const kPath = k.slice(space + 1) - return kMethod === method && String(url).includes(kPath) - }) - - const body = key !== undefined ? defaults[key] : {} - return { - ok: true, - json: async () => body, - text: async () => JSON.stringify(body), - headers: new Headers({ 'content-type': 'application/json' }), - arrayBuffer: async () => new ArrayBuffer(0), - } as unknown as Response - } - - return { mock, calls } -} - -/** Temporarily replace global.fetch, restore after fn resolves. */ -const withFetch = async (mockFn: MockFn, fn: () => Promise): Promise => { - const original = (global as any).fetch - ;(global as any).fetch = mockFn - try { - return await fn() - } finally { - ;(global as any).fetch = original - } -} - -/** Wait for the internal SimpleQueue to drain. */ -const drainQueue = () => new Promise((r) => setTimeout(r, 60)) - -// ─── mock bot ───────────────────────────────────────────────────────────────── - -const makeMockBot = (saveFileImpl?: (ctx: unknown, opts?: unknown) => Promise) => { - const botHandlers: Record unknown>> = {} - const provHandlers: Record unknown>> = {} - const serverRoutes: Record Promise> = {} - const serverGetRoutes: Record void> = {} - return { - on(ev: string, h: (...a: unknown[]) => unknown) { - botHandlers[ev] = [...(botHandlers[ev] ?? []), h] - }, - emit: (ev: string, payload: unknown) => Promise.all((botHandlers[ev] ?? []).map((h) => h(payload))), - provider: { - on(ev: string, h: (...a: unknown[]) => unknown) { - provHandlers[ev] = [...(provHandlers[ev] ?? []), h] - }, - emit: (ev: string, payload: unknown) => Promise.all((provHandlers[ev] ?? []).map((h) => h(payload))), - server: { - routes: serverRoutes, - getRoutes: serverGetRoutes, - post(path: string, handler: (req: any, res: any) => Promise) { - serverRoutes[path] = handler - }, - get(path: string, handler: (req: any, res: any) => void) { - serverGetRoutes[path] = handler - }, - }, - ...(saveFileImpl ? { saveFile: saveFileImpl } : {}), - }, - blacklist: { - items: new Set(), - add(p: string) { - this.items.add(p) - }, - remove(p: string) { - this.items.delete(p) - }, - checkIf(p: string) { - return this.items.has(p) - }, - }, - sent: [] as Array<{ number: string; content: string; options?: unknown }>, - async sendMessage(number: string, content: string, options?: unknown) { - this.sent.push({ number, content, options }) - }, - } -} - -// ─── existing construction tests ───────────────────────────────────────────── - -test('createChatwootPlugin returns a ChatwootPlugin instance', () => { - assert.instance(createChatwootPlugin(MOCK_CONFIG), ChatwootPlugin) -}) - -test('ChatwootPlugin exposes getApi()', () => { - assert.instance(createChatwootPlugin(MOCK_CONFIG).getApi(), ChatwootApi) -}) - -test('ChatwootPlugin getInbox() returns null before attach', () => { - assert.is(createChatwootPlugin(MOCK_CONFIG).getInbox(), null) -}) - -test('createChatwootPlugin uses default inbox name', () => { - assert.instance(createChatwootPlugin(MOCK_CONFIG), ChatwootPlugin) -}) - -test('createChatwootPlugin accepts custom inbox name', () => { - assert.instance(createChatwootPlugin({ ...MOCK_CONFIG, inboxName: 'Custom Inbox' }), ChatwootPlugin) -}) - -test('ChatwootApi constructs with correct base URL', () => { - assert.instance(new ChatwootApi(MOCK_CONFIG), ChatwootApi) -}) - -test('ChatwootApi trims trailing slash from URL', () => { - assert.instance(new ChatwootApi({ ...MOCK_CONFIG, url: 'https://chatwoot.example.com/' }), ChatwootApi) -}) - -// ─── status flag ───────────────────────────────────────────────────────────── - -test('plugin status is true by default', () => { - assert.is(createChatwootPlugin(MOCK_CONFIG).status, true) -}) - -test('attach() sets status=false and skips inbox when credentials are invalid', async () => { - const { mock } = makeSmartFetch({ 'GET /api/v1/accounts/1/': { error: 'unauthorized' } }) - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - assert.is(plugin.status, false) - assert.is(plugin.getInbox(), null) -}) - -test('attach() sets inbox and keeps status=true when credentials are valid', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - assert.is(plugin.status, true) - assert.is(plugin.getInbox()?.id, MOCK_INBOX.id) -}) - -// ─── webhook auto-creation ──────────────────────────────────────────────────── - -test('attach() registers webhook when webhookUrl is configured', async () => { - const { mock, calls } = makeSmartFetch() - const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/chatwoot' }) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - assert.ok( - calls.some((c) => c.startsWith('POST') && c.includes('/webhooks')), - 'POST /webhooks was called to register the webhook' - ) -}) - -test('attach() skips webhook registration when webhookUrl is not configured', async () => { - const { mock, calls } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - assert.not.ok( - calls.some((c) => c.startsWith('POST') && c.includes('/webhooks')), - 'POST /webhooks should not be called without webhookUrl' - ) -}) - -// ─── group filter ───────────────────────────────────────────────────────────── - -test('send_message handler skips @g.us group messages', async () => { - const { mock, calls } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - const countAfterAttach = calls.length - - await withFetch(mock, async () => { - await bot.emit('send_message', { from: '1234567890@g.us', answer: 'Hello group' }) - await drainQueue() - }) - assert.is(calls.length, countAfterAttach, 'no fetch calls for group send_message') -}) - -test('provider message handler skips @g.us group messages', async () => { - const { mock, calls } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - const countAfterAttach = calls.length - - await withFetch(mock, async () => { - await bot.provider.emit('message', { from: '9876543210@g.us', body: 'Group message' }) - await drainQueue() - }) - assert.is(calls.length, countAfterAttach, 'no fetch calls for group provider message') -}) - -// ─── handleWebhook ──────────────────────────────────────────────────────────── - -test('handleWebhook returns early when inbox ID does not match', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - - await plugin.handleWebhook(bot as any, { - event: 'message_created', - message_type: 'outgoing', - private: false, - content: 'Should be ignored', - conversation: { inbox_id: 9999, channel: 'Channel::Api', meta: { sender: { phone_number: '+1234' } } }, - }) - assert.is(bot.sent.length, 0, 'sendMessage not called for mismatched inbox') -}) - -test('handleWebhook adds phone to blacklist when agent is assigned', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - - await plugin.handleWebhook(bot as any, { - event: 'conversation_updated', - changed_attributes: [{ assignee_id: { current_value: 7 } }], - meta: { sender: { phone_number: '+5215511223344' } }, - conversation: { inbox_id: MOCK_INBOX.id }, - }) - assert.ok(bot.blacklist.items.has('5215511223344'), 'phone added to blacklist') -}) - -test('handleWebhook removes phone from blacklist when agent is unassigned', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - - bot.blacklist.items.add('5215511223344') - - await plugin.handleWebhook(bot as any, { - event: 'conversation_updated', - changed_attributes: [{ assignee_id: { current_value: null } }], - meta: { sender: { phone_number: '+5215511223344' } }, - conversation: { inbox_id: MOCK_INBOX.id }, - }) - assert.not.ok(bot.blacklist.items.has('5215511223344'), 'phone removed from blacklist') -}) - -test('handleWebhook forwards outgoing API channel message to WhatsApp', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - - await plugin.handleWebhook(bot as any, { - event: 'message_created', - message_type: 'outgoing', - private: false, - content: 'Hello from agent', - conversation: { - inbox_id: MOCK_INBOX.id, - channel: 'Channel::Api', - meta: { sender: { phone_number: '+5215511223344' } }, - }, - }) - assert.is(bot.sent.length, 1, 'sendMessage called once') - assert.is(bot.sent[0].number, '5215511223344') - assert.is(bot.sent[0].content, 'Hello from agent') -}) - -test('handleWebhook does not forward private messages to WhatsApp', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - - await plugin.handleWebhook(bot as any, { - event: 'message_created', - message_type: 'outgoing', - private: true, - content: 'Internal agent note', - conversation: { - inbox_id: MOCK_INBOX.id, - channel: 'Channel::Api', - meta: { sender: { phone_number: '+5215511223344' } }, - }, - }) - assert.is(bot.sent.length, 0, 'private message must not be forwarded') -}) - -test('handleWebhook is a no-op before attach()', async () => { - const bot = makeMockBot() - const plugin = createChatwootPlugin(MOCK_CONFIG) - await plugin.handleWebhook(bot as any, { - event: 'message_created', - message_type: 'outgoing', - private: false, - content: 'Should be ignored', - conversation: { inbox_id: 42, channel: 'Channel::Api' }, - }) - assert.is(bot.sent.length, 0, 'no action before attach') -}) - -// ─── media support ──────────────────────────────────────────────────────────── - -test('ChatwootApi.sendMessage sends JSON when no media is provided', async () => { - const { mock, calls } = makeSmartFetch() - const api = new ChatwootApi(MOCK_CONFIG) - await withFetch(mock, () => api.sendMessage(1, 'hello', 'outgoing')) - assert.ok( - calls.some((c) => c.startsWith('POST') && c.includes('/messages')), - 'POST /messages called for text message' - ) -}) - -test('ChatwootApi.sendMessage sends FormData when mediaSource is provided', async () => { - const bodies: unknown[] = [] - const captureFetch: MockFn = async (_url, opts) => { - bodies.push(opts?.body) - return { - ok: true, - json: async () => ({ id: 1, content: 'ok', message_type: 'outgoing' }), - text: async () => '{}', - headers: new Headers({ 'content-type': 'application/json' }), - // arrayBuffer is needed for the media download step - arrayBuffer: async () => new ArrayBuffer(8), - } as unknown as Response - } - const api = new ChatwootApi(MOCK_CONFIG) - await withFetch(captureFetch, () => api.sendMessage(1, 'with media', 'outgoing', 'https://example.com/image.png')) - const formDataBody = bodies.find((b) => b instanceof FormData) - assert.instance(formDataBody, FormData, 'a POST body should be FormData when media is provided') -}) - -// ─── SimpleQueue serialization ──────────────────────────────────────────────── - -// ─── media / _event_* normalization ────────────────────────────────────────── - -/** Extracts { content, message_type } from either a JSON string body or FormData. */ -const extractMessageBody = (body: unknown): { content: string; message_type: string } | null => { - if (body instanceof FormData) { - return { content: String(body.get('content') ?? ''), message_type: String(body.get('message_type') ?? '') } - } - if (typeof body === 'string') { - try { - return JSON.parse(body) - } catch { - return null - } - } - return null -} - -test('provider message: _event_media_ with options.media sends empty content (attachment speaks for itself)', async () => { - const bodies: ReturnType[] = [] - const { mock: baseMock } = makeSmartFetch() - const captureFetch: MockFn = async (url, opts) => { - if (String(url).includes('/messages') && opts?.method === 'POST') { - bodies.push(extractMessageBody(opts?.body)) - } - return baseMock(url, opts) - } - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(baseMock, () => plugin.attach(bot as any)) - - await withFetch(captureFetch, async () => { - await bot.provider.emit('message', { - from: '5215511223344', - body: '_event_media_', - options: { media: 'https://example.com/photo.jpg' }, - }) - await drainQueue() - }) - - const msg = bodies.find((b) => b?.message_type === 'incoming') - assert.ok(msg, 'a message was sent to Chatwoot') - assert.is(msg?.content, '', 'media with no caption → empty content, no redundant [image] label') -}) - -test('provider message: _event_voice_note_ with options.media sends empty content', async () => { - const bodies: ReturnType[] = [] - const { mock: baseMock } = makeSmartFetch() - const captureFetch: MockFn = async (url, opts) => { - if (String(url).includes('/messages') && opts?.method === 'POST') { - bodies.push(extractMessageBody(opts?.body)) - } - return baseMock(url, opts) - } - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(baseMock, () => plugin.attach(bot as any)) - - await withFetch(captureFetch, async () => { - await bot.provider.emit('message', { - from: '5215511223344', - body: '_event_voice_note_', - options: { media: 'https://example.com/audio.ogg' }, - }) - await drainQueue() - }) - - const msg = bodies.find((b) => b?.message_type === 'incoming') - assert.ok(msg, 'a message was sent to Chatwoot') - assert.is(msg?.content, '', 'audio with media URL → empty content, no redundant [audio] label') -}) - -test('provider message: media-only message with empty body is still synced to Chatwoot', async () => { - const { mock, calls } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - const countAfterAttach = calls.length - - await withFetch(mock, async () => { - await bot.provider.emit('message', { - from: '5215511223344', - body: '', - options: { media: 'https://example.com/photo.jpg' }, - }) - await drainQueue() - }) - - assert.ok(calls.length > countAfterAttach, 'fetch calls were made for media-only incoming message') -}) - -test('send_message: media-only bot message (empty content) is synced to Chatwoot', async () => { - const { mock, calls } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - const countAfterAttach = calls.length - - await withFetch(mock, async () => { - await bot.emit('send_message', { - from: '5215511223344', - answer: '', - options: { media: 'https://example.com/image.png' }, - }) - await drainQueue() - }) - - assert.ok(calls.length > countAfterAttach, 'fetch calls were made for media-only outgoing message') -}) - -test('handleWebhook forwards all attachments when agent sends multiple files', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - - await plugin.handleWebhook(bot as any, { - event: 'message_created', - message_type: 'outgoing', - private: false, - content: 'See attached files', - attachments: [ - { data_url: 'https://example.com/file1.pdf' }, - { data_url: 'https://example.com/file2.png' }, - { data_url: 'https://example.com/file3.mp4' }, - ], - conversation: { - inbox_id: MOCK_INBOX.id, - channel: 'Channel::Api', - meta: { sender: { phone_number: '+5215511223344' } }, - }, - }) - - assert.is(bot.sent.length, 3, 'one sendMessage call per attachment') - assert.is((bot.sent[0].options as any)?.media, 'https://example.com/file1.pdf', 'first file with content') - assert.is(bot.sent[0].content, 'See attached files', 'text goes with first file') - assert.is((bot.sent[1].options as any)?.media, 'https://example.com/file2.png', 'second file separate') - assert.is((bot.sent[2].options as any)?.media, 'https://example.com/file3.mp4', 'third file separate') -}) - -// ─── auto HTTP route registration ──────────────────────────────────────────── - -test('attach() auto-registers POST route on provider.server when webhookUrl is set', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - assert.ok( - bot.provider.server.routes['/v1/chatwoot'] !== undefined, - 'POST route /v1/chatwoot should be registered on provider.server' - ) -}) - -test('attach() does not register any route when webhookUrl is not set', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - assert.is(Object.keys(bot.provider.server.routes).length, 0, 'no routes should be registered without webhookUrl') -}) - -test('auto-registered route forwards agent message to WhatsApp', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - - const handler = bot.provider.server.routes['/v1/chatwoot'] - assert.ok(handler, 'route handler must exist') - - const mockRes = { - headers: {} as Record, - body: '', - writeHead(_code: number, headers: Record) { - this.headers = { ...this.headers, ...headers } - }, - end(data: string) { - this.body = data - }, - } - - await handler( - { - body: { - event: 'message_created', - message_type: 'outgoing', - private: false, - content: 'Hello from agent via route', - conversation: { - inbox_id: MOCK_INBOX.id, - channel: 'Channel::Api', - meta: { sender: { phone_number: '+5215511223344' } }, - }, - }, - }, - mockRes - ) - - assert.is(bot.sent.length, 1, 'sendMessage should be called once') - assert.is(bot.sent[0].number, '5215511223344', 'message sent to correct phone') - assert.is(bot.sent[0].content, 'Hello from agent via route', 'message content matches') - assert.is(mockRes.body, JSON.stringify({ status: 'ok' }), 'response body is { status: ok }') -}) - -// ─── SimpleQueue serialization ──────────────────────────────────────────────── - -test('enqueued messages are processed without dropping', async () => { - const { mock, calls } = makeSmartFetch() - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - const countAfterAttach = calls.length - - await withFetch(mock, async () => { - await bot.emit('send_message', { from: '5215511223344', answer: 'msg1' }) - await bot.emit('send_message', { from: '5215511223344', answer: 'msg2' }) - await drainQueue() - }) - assert.ok(calls.length > countAfterAttach, 'fetch calls were made for enqueued messages') -}) - -// ─── saveFile fallback + public URL (WA → Chatwoot, no options.media) ──────── - -test('attach() registers GET /media/:filename route when webhookUrl and server.get are present', async () => { - const { mock } = makeSmartFetch() - const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) - const bot = makeMockBot() - await withFetch(mock, () => plugin.attach(bot as any)) - assert.ok( - bot.provider.server.getRoutes['/media/:filename'] !== undefined, - 'GET /media/:filename route should be registered' - ) -}) - -test('provider message: _event_media_ without options.media uses saveFile + public URL', async () => { - const SAVED_PATH = '/tmp/chatwoot-media/file-12345.jpg' - const bodies: ReturnType[] = [] - const downloadedUrls: string[] = [] - - const saveFileMock = async (_ctx: unknown, _opts?: unknown): Promise => SAVED_PATH - - const { mock: baseMock } = makeSmartFetch() - const captureFetch: MockFn = async (url, opts) => { - if (String(url).includes('/messages') && opts?.method === 'POST') { - bodies.push(extractMessageBody(opts?.body)) - } - // Capture media download attempts - if (String(url).includes('/media/')) { - downloadedUrls.push(String(url)) - } - return baseMock(url, opts) - } - - const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) - const bot = makeMockBot(saveFileMock) - await withFetch(baseMock, () => plugin.attach(bot as any)) - - await withFetch(captureFetch, async () => { - await bot.provider.emit('message', { - from: '5215511223344', - body: '_event_media_', - message: { imageMessage: { mimetype: 'image/jpeg' } }, - }) - await drainQueue() - }) - - const incoming = bodies.find((b) => b?.message_type === 'incoming') - assert.ok(incoming, 'a message was sent to Chatwoot') - assert.is(incoming?.content, '', 'image with no caption + media URL → empty content') - // The media was fetched from the public URL - assert.ok( - downloadedUrls.some((u) => u.includes('https://bot.example.com/media/file-12345.jpg')), - 'plugin fetched media from the public /media URL' - ) -}) - -test('provider message: saveFile failure falls back to label-only message', async () => { - const bodies: ReturnType[] = [] - - const failingSaveFile = async (_ctx: unknown, _opts?: unknown): Promise => { - throw new Error('download failed') - } - - const { mock: baseMock } = makeSmartFetch() - const captureFetch: MockFn = async (url, opts) => { - if (String(url).includes('/messages') && opts?.method === 'POST') { - bodies.push(extractMessageBody(opts?.body)) - } - return baseMock(url, opts) - } - - const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) - const bot = makeMockBot(failingSaveFile) - await withFetch(baseMock, () => plugin.attach(bot as any)) - - await withFetch(captureFetch, async () => { - await bot.provider.emit('message', { - from: '5215511223344', - body: '_event_media_', - message: { imageMessage: { mimetype: 'image/jpeg' } }, - }) - await drainQueue() - }) - - const incoming = bodies.find((b) => b?.message_type === 'incoming') - assert.ok(incoming, 'message still sent to Chatwoot even when saveFile throws') - assert.is(incoming?.content, '[image]', 'body still normalized to [image]') -}) - -test('provider message: image with caption sends real caption text instead of [image]', async () => { - const bodies: ReturnType[] = [] - - const { mock: baseMock } = makeSmartFetch() - const captureFetch: MockFn = async (url, opts) => { - if (String(url).includes('/messages') && opts?.method === 'POST') { - bodies.push(extractMessageBody(opts?.body)) - } - return baseMock(url, opts) - } - - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(baseMock, () => plugin.attach(bot as any)) - - await withFetch(captureFetch, async () => { - await bot.provider.emit('message', { - from: '5215511223344', - body: '_event_media_', - // Baileys raw WAMessage with a caption - message: { imageMessage: { mimetype: 'image/jpeg', caption: 'Check this out!' } }, - }) - await drainQueue() - }) - - const incoming = bodies.find((b) => b?.message_type === 'incoming') - assert.ok(incoming, 'a message was sent to Chatwoot') - assert.is(incoming?.content, 'Check this out!', 'real caption replaces [image] label') -}) - -test('provider message: image without caption sends empty content (no redundant [image] label)', async () => { - const bodies: ReturnType[] = [] - - const SAVED_PATH = '/tmp/chatwoot-media/file-99.jpg' - const saveFileMock = async (_ctx: unknown, _opts?: unknown): Promise => SAVED_PATH - - const { mock: baseMock } = makeSmartFetch() - const captureFetch: MockFn = async (url, opts) => { - if (String(url).includes('/messages') && opts?.method === 'POST') { - bodies.push(extractMessageBody(opts?.body)) - } - return baseMock(url, opts) - } - - const plugin = createChatwootPlugin({ ...MOCK_CONFIG, webhookUrl: 'https://bot.example.com/v1/chatwoot' }) - const bot = makeMockBot(saveFileMock) - await withFetch(baseMock, () => plugin.attach(bot as any)) - - await withFetch(captureFetch, async () => { - await bot.provider.emit('message', { - from: '5215511223344', - body: '_event_media_', - message: { imageMessage: { mimetype: 'image/jpeg', caption: '' } }, - }) - await drainQueue() - }) - - const incoming = bodies.find((b) => b?.message_type === 'incoming') - assert.ok(incoming, 'a message was sent to Chatwoot') - assert.is(incoming?.content, '', 'no caption + media URL → empty content, no redundant label') -}) - -test('provider message: video with caption sends real caption text', async () => { - const bodies: ReturnType[] = [] - - const { mock: baseMock } = makeSmartFetch() - const captureFetch: MockFn = async (url, opts) => { - if (String(url).includes('/messages') && opts?.method === 'POST') { - bodies.push(extractMessageBody(opts?.body)) - } - return baseMock(url, opts) - } - - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(baseMock, () => plugin.attach(bot as any)) - - await withFetch(captureFetch, async () => { - await bot.provider.emit('message', { - from: '5215511223344', - body: '_event_media_', - message: { videoMessage: { mimetype: 'video/mp4', caption: 'Watch this video' } }, - }) - await drainQueue() - }) - - const incoming = bodies.find((b) => b?.message_type === 'incoming') - assert.ok(incoming, 'a message was sent to Chatwoot') - assert.is(incoming?.content, 'Watch this video', 'video caption is used as content') -}) - -test('provider message: no saveFile on provider sends [image] label as last-resort fallback', async () => { - const bodies: ReturnType[] = [] - - const { mock: baseMock } = makeSmartFetch() - const captureFetch: MockFn = async (url, opts) => { - if (String(url).includes('/messages') && opts?.method === 'POST') { - bodies.push(extractMessageBody(opts?.body)) - } - return baseMock(url, opts) - } - - // No saveFile, no options.media → no mediaUrl → falls back to normalizeBody label - const plugin = createChatwootPlugin(MOCK_CONFIG) - const bot = makeMockBot() - await withFetch(baseMock, () => plugin.attach(bot as any)) - - await withFetch(captureFetch, async () => { - await bot.provider.emit('message', { from: '5215511223344', body: '_event_media_' }) - await drainQueue() - }) - - const incoming = bodies.find((b) => b?.message_type === 'incoming') - assert.ok(incoming, 'message sent to Chatwoot without saveFile') - assert.is(incoming?.content, '[image]', 'no media URL → [image] label used as fallback') -}) - -test.run() diff --git a/packages/plugins/chatwoot/package.json b/packages/plugins/chatwoot/package.json deleted file mode 100644 index 6f449bf61..000000000 --- a/packages/plugins/chatwoot/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@builderbot/plugin-chatwoot", - "version": "1.4.2-alpha.11", - "description": "Plugin de Chatwoot para BuilderBot - Crea inbox, guarda contactos y sincroniza conversaciones automáticamente", - "keywords": [ - "chatwoot", - "builderbot", - "plugin", - "crm", - "customer-support" - ], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" - } - }, - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "npx uvu -r tsm ./__tests__ .test.ts", - "test:coverage": "npx c8 pnpm test", - "test:debug": "npx tsm --inspect-brk ../../node_modules/uvu/bin.js ./__tests__ .test.ts" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "dependencies": { - "@builderbot/bot": "workspace:^" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/node": "^24.10.2", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "tslib": "^2.8.1", - "tsm": "^2.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/plugins/chatwoot/rollup.config.js b/packages/plugins/chatwoot/rollup.config.js deleted file mode 100644 index 782032ce3..000000000 --- a/packages/plugins/chatwoot/rollup.config.js +++ /dev/null @@ -1,26 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - { - dir: 'dist', - entryFileNames: '[name].mjs', - format: 'esm', - }, - ], - plugins: [ - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/@builderbot\/bot/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/plugins/chatwoot/src/chatwootApi.ts b/packages/plugins/chatwoot/src/chatwootApi.ts deleted file mode 100644 index 5ca4869d1..000000000 --- a/packages/plugins/chatwoot/src/chatwootApi.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { readFile } from 'node:fs/promises' - -import type { - ChatwootContact, - ChatwootConversation, - ChatwootInbox, - ChatwootMessage, - ChatwootPluginConfig, - ChatwootSearchContactsPayload, -} from './types' - -/** Minimal MIME lookup by file extension — avoids external dependencies. */ -const getContentType = (filename: string): string => { - const ext = filename.split('.').pop()?.toLowerCase() ?? '' - const map: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - webp: 'image/webp', - mp4: 'video/mp4', - mp3: 'audio/mpeg', - ogg: 'audio/ogg', - opus: 'audio/ogg', - wav: 'audio/wav', - pdf: 'application/pdf', - svg: 'image/svg+xml', - } - return map[ext] ?? 'application/octet-stream' -} - -class ChatwootApi { - private baseUrl: string - private token: string - private headers: Record - - constructor(config: ChatwootPluginConfig) { - this.baseUrl = `${config.url.replace(/\/$/, '')}/api/v1/accounts/${config.accountId}` - this.token = config.token - this.headers = { - 'Content-Type': 'application/json', - api_access_token: config.token, - } - } - - /** - * Verifica que las credenciales sean válidas contra la API de Chatwoot. - * Retorna `true` si la cuenta es accesible. - */ - async checkAccount(): Promise { - try { - const response = await fetch(`${this.baseUrl}/`, { - method: 'GET', - headers: this.headers, - }) - const data = (await response.json()) as { error?: string } - return !data?.error - } catch { - return false - } - } - - /** - * Crea un inbox tipo API channel en Chatwoot. - * Si ya existe uno con el mismo nombre, lo retorna. - */ - async findOrCreateInbox(name: string): Promise { - const existing = await this.listInboxes() - const found = existing.find((inbox) => inbox.name === name) - if (found) return found - - const response = await fetch(`${this.baseUrl}/inboxes`, { - method: 'POST', - headers: this.headers, - body: JSON.stringify({ - name, - channel: { - type: 'api', - webhook_url: '', - }, - }), - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`[Chatwoot] Error creating inbox: ${error}`) - } - - return (await response.json()) as ChatwootInbox - } - - /** - * Lista todos los inboxes de la cuenta. - */ - async listInboxes(): Promise { - const response = await fetch(`${this.baseUrl}/inboxes`, { - method: 'GET', - headers: this.headers, - }) - - if (!response.ok) return [] - - const data = (await response.json()) as { payload: ChatwootInbox[] } - return data?.payload ?? [] - } - - /** - * Busca un contacto por teléfono. Si no existe, lo crea. - */ - async findOrCreateContact(phone: string, name?: string): Promise { - const found = await this.searchContacts(phone) - if (found) return found - - return this.createContact(phone, name) - } - - /** - * Busca contactos por query (teléfono, nombre, email). - */ - async searchContacts(query: string): Promise { - const response = await fetch(`${this.baseUrl}/contacts/search?q=${encodeURIComponent(query)}`, { - method: 'GET', - headers: this.headers, - }) - - if (!response.ok) return null - - const data = (await response.json()) as ChatwootSearchContactsPayload - return data?.payload?.[0] ?? null - } - - /** - * Crea un nuevo contacto en Chatwoot. - */ - async createContact(phone: string, name?: string): Promise { - const response = await fetch(`${this.baseUrl}/contacts`, { - method: 'POST', - headers: this.headers, - body: JSON.stringify({ - name: name ?? phone, - phone_number: phone.startsWith('+') ? phone : `+${phone}`, - identifier: phone, - }), - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`[Chatwoot] Error creating contact: ${error}`) - } - - const data = (await response.json()) as { payload: { contact: ChatwootContact } } - return data?.payload?.contact ?? (data as unknown as ChatwootContact) - } - - /** - * Busca una conversación abierta para un contacto en un inbox. - * Si no existe, crea una nueva. - */ - async findOrCreateConversation(contactId: number, inboxId: number): Promise { - const existing = await this.getContactConversations(contactId) - const open = existing.find((conv) => conv.inbox_id === inboxId && conv.status !== 'resolved') - if (open) return open - - return this.createConversation(contactId, inboxId) - } - - /** - * Obtiene las conversaciones de un contacto. - */ - async getContactConversations(contactId: number): Promise { - const response = await fetch(`${this.baseUrl}/contacts/${contactId}/conversations`, { - method: 'GET', - headers: this.headers, - }) - - if (!response.ok) return [] - - const data = (await response.json()) as { payload: ChatwootConversation[] } - return data?.payload ?? [] - } - - /** - * Crea una nueva conversación en Chatwoot. - */ - async createConversation(contactId: number, inboxId: number): Promise { - const response = await fetch(`${this.baseUrl}/conversations`, { - method: 'POST', - headers: this.headers, - body: JSON.stringify({ - contact_id: contactId, - inbox_id: inboxId, - }), - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`[Chatwoot] Error creating conversation: ${error}`) - } - - return (await response.json()) as ChatwootConversation - } - - /** - * Envía un mensaje a una conversación de Chatwoot. - * Si se provee `mediaSource` (URL o ruta local), el mensaje se envía con adjunto via FormData. - */ - async sendMessage( - conversationId: number, - content: string, - messageType: 'incoming' | 'outgoing' = 'incoming', - mediaSource?: string | null - ): Promise { - if (mediaSource) { - return this.sendMessageWithMedia(conversationId, content, messageType, mediaSource) - } - - const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/messages`, { - method: 'POST', - headers: this.headers, - body: JSON.stringify({ - content, - message_type: messageType, - }), - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`[Chatwoot] Error sending message: ${error}`) - } - - return (await response.json()) as ChatwootMessage - } - - /** - * Envía un mensaje con adjunto multimedia a una conversación. - * `mediaSource` puede ser una URL https:// o una ruta local en disco. - */ - private async sendMessageWithMedia( - conversationId: number, - content: string, - messageType: 'incoming' | 'outgoing', - mediaSource: string - ): Promise { - const form = new FormData() - form.set('content', content) - form.set('message_type', messageType) - - try { - const isUrl = mediaSource.startsWith('http://') || mediaSource.startsWith('https://') - - if (isUrl) { - const mediaResponse = await fetch(mediaSource) - if (mediaResponse.ok) { - const buffer = await mediaResponse.arrayBuffer() - const contentType = mediaResponse.headers.get('content-type') ?? 'application/octet-stream' - const fileName = mediaSource.split('/').pop()?.split('?')[0] ?? 'file' - form.set('attachments[]', new Blob([buffer], { type: contentType }), fileName) - } - } else { - const fileName = mediaSource.split('/').pop() ?? 'file' - const fileBuffer = await readFile(mediaSource) - const mimeType = getContentType(fileName) - form.set('attachments[]', new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), fileName) - } - } catch (mediaErr) { - console.error('[Chatwoot] Could not attach media, sending text-only:', mediaErr) - } - - const response = await fetch(`${this.baseUrl}/conversations/${conversationId}/messages`, { - method: 'POST', - headers: { api_access_token: this.token }, - body: form, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`[Chatwoot] Error sending message with media: ${error}`) - } - - return (await response.json()) as ChatwootMessage - } - - /** - * Busca un webhook existente cuya URL contenga `matchUrl`. - */ - async findWebhook(matchUrl: string): Promise<{ id: number; url: string } | null> { - try { - const response = await fetch(`${this.baseUrl}/webhooks`, { - method: 'GET', - headers: this.headers, - }) - - if (!response.ok) return null - - const data = (await response.json()) as { payload?: { webhooks?: Array<{ id: number; url: string }> } } - const webhooks = data?.payload?.webhooks ?? [] - return webhooks.find((w) => w.url === matchUrl) ?? null - } catch { - return null - } - } - - /** - * Crea un webhook en Chatwoot. - */ - async createWebhook(url: string, subscriptions: string[]): Promise { - try { - const response = await fetch(`${this.baseUrl}/webhooks`, { - method: 'POST', - headers: this.headers, - body: JSON.stringify({ webhook: { url, subscriptions } }), - }) - - if (!response.ok) { - const error = await response.text() - console.error(`[Chatwoot] Error creating webhook: ${error}`) - } - } catch (err) { - console.error('[Chatwoot] Error creating webhook:', err) - } - } -} - -export { ChatwootApi } diff --git a/packages/plugins/chatwoot/src/chatwootPlugin.ts b/packages/plugins/chatwoot/src/chatwootPlugin.ts deleted file mode 100644 index deaedf8ed..000000000 --- a/packages/plugins/chatwoot/src/chatwootPlugin.ts +++ /dev/null @@ -1,412 +0,0 @@ -import type { CoreClass } from '@builderbot/bot' -import { createReadStream } from 'node:fs' -import { mkdir, unlink } from 'node:fs/promises' -import { join } from 'node:path' - -import { ChatwootApi } from './chatwootApi' -import type { - BotIncomingMessagePayload, - BotOutgoingPayload, - ChatwootBotRef, - ChatwootInbox, - ChatwootPluginConfig, - ChatwootWebhookBody, -} from './types' - -const DEFAULT_INBOX_NAME = 'BuilderBot Inbox' -const WEBHOOK_SUBSCRIPTIONS = ['conversation_updated', 'message_created'] - -/** Maps file extensions to MIME types for correct Content-Type headers when serving media. */ -const MIME_MAP: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - webp: 'image/webp', - mp4: 'video/mp4', - mp3: 'audio/mpeg', - ogg: 'audio/ogg', - opus: 'audio/ogg', - wav: 'audio/wav', - pdf: 'application/pdf', - svg: 'image/svg+xml', -} - -function mimeFromFilename(filename: string): string { - const ext = filename.split('.').pop()?.toLowerCase() ?? '' - return MIME_MAP[ext] ?? 'application/octet-stream' -} - -/** Maps WhatsApp `_event_*` body strings to human-readable labels shown in Chatwoot. */ -const EVENT_LABELS: Record = { - _event_media_: '[image]', - _event_voice_note_: '[audio]', - _event_document_: '[file]', - _event_location_: '[location]', - _event_video_: '[video]', - _event_sticker_: '[sticker]', - _event_order_: '[order]', -} - -/** - * Converts a WhatsApp provider event string (e.g. `_event_media_`) into a readable label. - * Returns the original string unchanged for normal text messages. - */ -function normalizeBody(raw: string): string { - for (const [key, label] of Object.entries(EVENT_LABELS)) { - if (raw.includes(key)) return label - } - return raw -} - -/** Returns true if the body string corresponds to a WhatsApp media event. */ -function isMediaEvent(raw: string): boolean { - return Object.keys(EVENT_LABELS).some((key) => raw.includes(key)) -} - -/** - * Extracts the real caption from the raw provider message context (e.g. Baileys WAMessage). - * Baileys always overwrites `body` with `_event_media_` for media messages, but the original - * caption typed by the user is preserved in `message.imageMessage.caption` (and equivalent - * fields for video, document, sticker, etc.). - * Returns the caption string if present and non-empty, otherwise null. - */ -function extractCaption(payload: BotIncomingMessagePayload): string | null { - const msg = (payload as any).message - if (!msg) return null - const caption = - msg.imageMessage?.caption || - msg.videoMessage?.caption || - msg.documentMessage?.caption || - msg.documentWithCaptionMessage?.message?.documentMessage?.caption || - msg.extendedTextMessage?.text || - null - return typeof caption === 'string' && caption.trim() ? caption.trim() : null -} - -/** - * Minimal single-concurrency async queue. - * Ensures Chatwoot API calls are serialized to avoid race conditions. - */ -class SimpleQueue { - private running = false - private tasks: Array<() => Promise> = [] - - enqueue(task: () => Promise): void { - this.tasks.push(task) - if (!this.running) this.drain() - } - - private async drain(): Promise { - this.running = true - while (this.tasks.length > 0) { - const task = this.tasks.shift()! - await task().catch((err) => console.error('[Chatwoot] Queue task error:', err)) - } - this.running = false - } -} - -const MEDIA_ROUTE = '/media' -const MEDIA_DIR_NAME = join('tmp', 'chatwoot-media') - -class ChatwootPlugin { - private api: ChatwootApi - private config: ChatwootPluginConfig - private inbox: ChatwootInbox | null = null - private conversationCache = new Map() - private contactCache = new Map() - private messageQueue = new SimpleQueue() - private mediaDir: string | null = null - private mediaBaseUrl: string | null = null - - /** False if Chatwoot credentials are invalid or unreachable. All operations are skipped when false. */ - public status = true - - constructor(config: ChatwootPluginConfig) { - this.config = config - this.api = new ChatwootApi(config) - } - - /** - * Conecta el plugin al bot. Una sola línea y listo. - * - * ```ts - * const bot = await createBot({ flow, provider, database }) - * await chatwoot.attach(bot) - * ``` - */ - async attach(bot: CoreClass): Promise { - const accountOk = await this.api.checkAccount() - if (!accountOk) { - console.error('[Chatwoot] Invalid credentials or unreachable endpoint. Plugin disabled.') - this.status = false - return - } - - const inboxName = this.config.inboxName ?? DEFAULT_INBOX_NAME - this.inbox = await this.api.findOrCreateInbox(inboxName) - console.log(`[Chatwoot] Inbox "${this.inbox.name}" ready (id: ${this.inbox.id})`) - - const server = (bot as any).provider?.server - - if (this.config.webhookUrl) { - const existing = await this.api.findWebhook(this.config.webhookUrl) - if (!existing) { - await this.api.createWebhook(this.config.webhookUrl, WEBHOOK_SUBSCRIPTIONS) - console.log(`[Chatwoot] Webhook registered: ${this.config.webhookUrl}`) - } else { - console.log(`[Chatwoot] Webhook already exists (id: ${existing.id})`) - } - - const urlPath = new URL(this.config.webhookUrl).pathname - if (server?.post) { - server.post(urlPath, (req: any, res: any) => { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ status: 'ok' })) - - const botRef = bot as CoreClass & ChatwootBotRef - if (typeof (botRef as any).sendMessage !== 'function') { - ;(botRef as any).sendMessage = (phone: string, msg: string, opts?: any) => - (bot as any).provider?.sendMessage(phone, msg, opts) - } - if (!(botRef as any).blacklist) { - ;(botRef as any).blacklist = (bot as any).dynamicBlacklist - } - - this.handleWebhook(botRef, req.body ?? {}).catch((err) => - console.error('[Chatwoot] Webhook processing error:', err) - ) - }) - console.log(`[Chatwoot] Webhook route auto-registered at ${urlPath}`) - } else { - console.warn('[Chatwoot] provider.server not available — register the webhook route manually') - } - - // Register a public GET route to serve media files downloaded from WhatsApp. - // Files are stored in tmp/chatwoot-media/ and exposed at /media/:filename - // so Chatwoot can download them by URL rather than receiving a binary upload. - if (server?.get) { - this.mediaDir = join(process.cwd(), MEDIA_DIR_NAME) - this.mediaBaseUrl = new URL(this.config.webhookUrl).origin - await mkdir(this.mediaDir, { recursive: true }) - - server.get(`${MEDIA_ROUTE}/:filename`, (req: any, res: any) => { - const raw = req.params?.filename ?? '' - const safeName = raw.replace(/[^a-zA-Z0-9._-]/g, '') - if (!safeName) { - res.writeHead(400) - res.end('Bad request') - return - } - const filePath = join(this.mediaDir!, safeName) - const contentType = mimeFromFilename(safeName) - const stream = createReadStream(filePath) - stream.on('error', () => { - res.writeHead(404) - res.end('Not found') - }) - res.writeHead(200, { 'Content-Type': contentType }) - stream.pipe(res) - }) - console.log(`[Chatwoot] Media route registered at ${MEDIA_ROUTE}/:filename`) - } - } - - bot.on('send_message', async (payload) => { - if (!this.status) return - if (payload.from?.includes('@g.us')) return - - this.messageQueue.enqueue(async () => { - const { from, answer } = payload - if (!from) return - - const rawContent = Array.isArray(answer) ? answer.join('\n') : String(answer ?? '') - if (rawContent.startsWith('__')) return - - const mediaUrl = (payload as unknown as BotOutgoingPayload).options?.media ?? null - const content = normalizeBody(rawContent) - if (!content && !mediaUrl) return - - const conversationId = await this.resolveConversation(from) - await this.api.sendMessage(conversationId, content, 'outgoing', mediaUrl) - }) - }) - - bot.provider.on('message', async (payload: BotIncomingMessagePayload) => { - if (!this.status) return - if (payload.from?.includes('@g.us')) return - - this.messageQueue.enqueue(async () => { - const { from, body, name } = payload - if (!from) return - - let mediaUrl: string | null = payload.options?.media ?? null - let tempFilePath: string | null = null - - // Fallback for providers (e.g. Baileys) that carry the raw WAMessage context - // but do not populate options.media. Download the file via provider.saveFile, - // save it to the public media directory and expose it as an HTTP asset so - // Chatwoot can fetch and store it by URL. - if (!mediaUrl && body && isMediaEvent(body)) { - const saveFile = (bot as any).provider?.saveFile - if (typeof saveFile === 'function') { - try { - if (this.mediaDir && this.mediaBaseUrl) { - tempFilePath = await saveFile.call((bot as any).provider, payload, { - path: this.mediaDir, - }) - const filename = tempFilePath!.split('/').pop() - mediaUrl = `${this.mediaBaseUrl}${MEDIA_ROUTE}/${filename}` - } else { - tempFilePath = await saveFile.call((bot as any).provider, payload) - mediaUrl = tempFilePath - } - } catch (err) { - console.error('[Chatwoot] Could not download media via saveFile:', err) - } - } - } - - if (!body && !mediaUrl) return - - // Prefer the real caption from the raw message context over the normalised label. - // When a user sends an image with text Baileys sets body to _event_media_ and - // stores the actual caption inside message.imageMessage.caption (and similar). - // If there is a media attachment but no caption, send empty content so Chatwoot - // shows just the image/file preview without a redundant [image] label. - // Only fall back to the event label when there is no media file at all - // (e.g. location, sticker, order — events that have no downloadable attachment). - const caption = extractCaption(payload) - const content = caption ?? (mediaUrl ? '' : normalizeBody(body ?? '')) - const conversationId = await this.resolveConversation(from, name) - await this.api.sendMessage(conversationId, content, 'incoming', mediaUrl) - - if (tempFilePath) { - unlink(tempFilePath).catch(() => undefined) - } - }) - }) - - console.log('[Chatwoot] Plugin attached successfully') - } - - /** - * Procesa un webhook entrante desde Chatwoot. - * - * Wire this to your HTTP route handler: - * ```ts - * server.post('/v1/chatwoot', handleCtx(async (bot, req, res) => { - * await chatwoot.handleWebhook(bot, req.body) - * res.end(JSON.stringify({ status: 'ok' })) - * })) - * ``` - * - * Handles: - * - `conversation_updated` + assignee change → add/remove phone from blacklist - * - `message_created` outgoing on API channel → forward message to WhatsApp - */ - async handleWebhook(bot: CoreClass & ChatwootBotRef, body: ChatwootWebhookBody): Promise { - if (!this.inbox) return - - const inboxIdFromBody = - body?.conversation?.inbox_id ?? body?.inbox?.id ?? body?.conversation?.contact_inbox?.inbox_id - - if (inboxIdFromBody !== undefined && inboxIdFromBody !== this.inbox.id) return - - const changedKeys = body?.changed_attributes?.flatMap((attr) => Object.keys(attr)) ?? [] - - if (body?.event === 'conversation_updated' && changedKeys.includes('assignee_id')) { - const phone = body?.meta?.sender?.phone_number?.replace('+', '') - const idAssigned = (body?.changed_attributes?.[0] as any)?.assignee_id?.current_value ?? null - - if (phone) { - if (idAssigned) { - bot.blacklist?.add(phone) - } else if (bot.blacklist?.checkIf(phone)) { - bot.blacklist?.remove(phone) - } - } - return - } - - if ( - body?.private === false && - body?.event === 'message_created' && - body?.message_type === 'outgoing' && - body?.conversation?.channel?.includes('Channel::Api') - ) { - const phone = body?.conversation?.meta?.sender?.phone_number?.replace('+', '') - const content = body?.content ?? '' - const attachments = body?.attachments ?? [] - - if (phone && (content || attachments.length)) { - const firstMedia = attachments[0]?.data_url ?? null - await bot.sendMessage(phone, content, { media: firstMedia }) - - for (const attachment of attachments.slice(1)) { - if (attachment.data_url) { - await bot.sendMessage(phone, '', { media: attachment.data_url }) - } - } - } - } - } - - /** - * Resuelve (o crea) el contacto y la conversación en Chatwoot para un número dado. - */ - private async resolveConversation(phone: string, name?: string): Promise { - const cached = this.conversationCache.get(phone) - if (cached) return cached - - let contactId = this.contactCache.get(phone) - if (!contactId) { - const contact = await this.api.findOrCreateContact(phone, name) - if (!contact?.id) throw new Error(`[Chatwoot] Could not resolve contact for ${phone}`) - contactId = contact.id! - this.contactCache.set(phone, contactId) - } - - if (!this.inbox) throw new Error('[Chatwoot] Plugin not attached yet. Call attach() first.') - const conversation = await this.api.findOrCreateConversation(contactId, this.inbox.id) - this.conversationCache.set(phone, conversation.id) - - return conversation.id - } - - /** - * Acceso directo a la API de Chatwoot para operaciones avanzadas. - */ - getApi(): ChatwootApi { - return this.api - } - - /** - * Retorna el inbox creado por el plugin. - */ - getInbox(): ChatwootInbox | null { - return this.inbox - } -} - -/** - * Crea una instancia del plugin de Chatwoot. - * - * ```ts - * const chatwoot = createChatwootPlugin({ - * token: 'tu-token', - * url: 'https://app.chatwoot.com', - * accountId: 1, - * webhookUrl: 'https://mi-bot.example.com/v1/chatwoot', - * }) - * - * const bot = await createBot({ flow, provider, database }) - * await chatwoot.attach(bot) - * ``` - */ -const createChatwootPlugin = (config: ChatwootPluginConfig): ChatwootPlugin => { - return new ChatwootPlugin(config) -} - -export { ChatwootPlugin, createChatwootPlugin } diff --git a/packages/plugins/chatwoot/src/index.ts b/packages/plugins/chatwoot/src/index.ts deleted file mode 100644 index c46b80ff6..000000000 --- a/packages/plugins/chatwoot/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ChatwootPlugin, createChatwootPlugin } from './chatwootPlugin' -export { ChatwootApi } from './chatwootApi' -export * from './types' diff --git a/packages/plugins/chatwoot/src/types.ts b/packages/plugins/chatwoot/src/types.ts deleted file mode 100644 index 51f554c31..000000000 --- a/packages/plugins/chatwoot/src/types.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Configuración principal del plugin de Chatwoot. - * Solo necesitas `token`, `url` y `accountId` para empezar. - */ -export interface ChatwootPluginConfig { - /** API access token de Chatwoot (User o Agent token) */ - token: string - /** URL base de tu instancia de Chatwoot (ej: https://app.chatwoot.com) */ - url: string - /** ID de la cuenta en Chatwoot */ - accountId: number - /** Nombre del inbox que se creará automáticamente (default: 'BuilderBot Inbox') */ - inboxName?: string - /** - * URL pública donde Chatwoot enviará webhooks hacia el bot. - * Si se provee, el plugin registrará (o reutilizará) el webhook automáticamente. - * Ej: 'https://mi-bot.example.com/v1/chatwoot' - */ - webhookUrl?: string -} - -export interface ChatwootContact { - id?: number - name?: string - phone_number?: string - email?: string - identifier?: string - contact_inboxes?: Array<{ inbox: { id: number } }> -} - -export interface ChatwootConversation { - id: number - inbox_id: number - contact_id: number - status?: string - account_id?: number -} - -export interface ChatwootInbox { - id: number - name: string - channel_type?: string - webhook_url?: string -} - -export interface ChatwootMessage { - id?: number - content: string - message_type: 'incoming' | 'outgoing' - content_type?: string - private?: boolean -} - -export interface ChatwootSearchContactsPayload { - payload: ChatwootContact[] -} - -export interface BotIncomingMessagePayload { - from: string - body: string - name?: string - options?: { media?: string } - /** - * Raw provider-specific message context. Baileys spreads the full WAMessage - * here, which is needed by `provider.saveFile` to download media when - * `options.media` is not populated. - */ - [key: string]: unknown -} - -/** - * Duck-typed interface for providers that can download incoming media to disk. - * Baileys exposes this as `provider.saveFile(ctx)` returning a local file path. - */ -export interface BotProviderWithSaveFile { - saveFile(ctx: BotIncomingMessagePayload, options?: { path?: string }): Promise -} - -/** - * Duck-typed interface for the bot fields used by handleWebhook. - * Combine with CoreClass: `bot: CoreClass & ChatwootBotRef`. - */ -export interface ChatwootBotRef { - blacklist?: { - add(phone: string): void - remove(phone: string): void - checkIf(phone: string): boolean - } - sendMessage(number: string, message: string, options?: { media?: string | null }): Promise -} - -/** - * Minimal shape of the send_message event payload for accessing options.media. - * The full payload is inferred from HostEventTypes; this covers only what the plugin needs. - */ -export interface BotOutgoingPayload { - from?: string - answer?: string | string[] - options?: { media?: string | null } -} - -/** Shape of the webhook body that Chatwoot POSTs to the bot */ -export interface ChatwootWebhookBody { - event?: string - message_type?: string - private?: boolean - content?: string - attachments?: Array<{ data_url?: string; [key: string]: unknown }> - changed_attributes?: Array> - meta?: { - sender?: { phone_number?: string; name?: string } - assignee?: { id?: number } | null - } - conversation?: { - id?: number - inbox_id?: number - channel?: string - status?: string - meta?: { - sender?: { phone_number?: string; name?: string } - assignee?: { id?: number } | null - } - contact_inbox?: { inbox_id?: number } - messages?: Array<{ inbox_id?: number }> - } - inbox?: { id?: number } - sender?: { phone_number?: string; type?: string } - account?: { id?: number } -} diff --git a/packages/plugins/chatwoot/tsconfig.json b/packages/plugins/chatwoot/tsconfig.json deleted file mode 100644 index a05589529..000000000 --- a/packages/plugins/chatwoot/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "module": "ES2020", - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/provider-baileys/package.json b/packages/provider-baileys/package.json index a2bfbd084..85755b180 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,81 +1,81 @@ { - "name": "@builderbot/provider-baileys", - "version": "1.4.2-alpha.11", - "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --forceExit", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@hapi/boom": "^10.0.1", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/cors": "^2.8.17", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "body-parser": "^2.2.1", - "cors": "^2.8.5", - "jest": "^30.2.0", - "mime-types": "^3.0.2", - "pino": "^10.1.0", - "polka": "^0.5.2", - "qr-image": "^3.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "ts-jest": "^29.4.6", - "ts-node": "^10.9.2", - "wa-sticker-formatter": "^4.4.4", - "wtfnode": "^0.10.1" - }, - "dependencies": { - "@adiwajshing/keyed-db": "^0.2.4", - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "@types/polka": "^0.5.7", - "baileys": "7.0.0-rc.9", - "cheerio": "^1.1.2", - "fluent-ffmpeg": "^2.1.2", - "fs-extra": "^11.3.2", - "jimp": "^1.6.0", - "node-cache": "^5.1.2", - "sharp": "0.33.3" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" + "name": "@japcon-bot/provider-baileys", + "version": "1.0.0", + "description": "Now I'm the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin' letters to relatives / Embellishin' my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", + "keywords": [], + "author": "Leifer Mendez ", + "license": "ISC", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --forceExit", + "test:coverage": "jest --coverage", + "test:watch": "jest --watchAll --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@hapi/boom": "^10.0.1", + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/cors": "^2.8.17", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/qr-image": "^3.2.9", + "@types/sinon": "^17.0.3", + "body-parser": "^2.2.1", + "cors": "^2.8.5", + "jest": "^30.2.0", + "mime-types": "^3.0.2", + "pino": "^10.1.0", + "polka": "^0.5.2", + "qr-image": "^3.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "sinon": "^17.0.1", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "wa-sticker-formatter": "^4.4.4", + "wtfnode": "^0.10.1", + "@japcon-bot/bot": "workspace:^" + }, + "dependencies": { + "@adiwajshing/keyed-db": "^0.2.4", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@types/polka": "^0.5.7", + "baileys": "7.0.0-rc.9", + "cheerio": "^1.1.2", + "fluent-ffmpeg": "^2.1.2", + "fs-extra": "^11.3.2", + "jimp": "^1.6.0", + "node-cache": "^5.1.2", + "sharp": "0.33.3" + }, + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-email/LICENSE.md b/packages/provider-email/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/provider-email/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/provider-email/README.md b/packages/provider-email/README.md deleted file mode 100644 index d0d44bebd..000000000 --- a/packages/provider-email/README.md +++ /dev/null @@ -1,254 +0,0 @@ -# @builderbot/provider-email - -Email provider for BuilderBot using IMAP/SMTP. Receive emails in real-time using IMAP IDLE and send emails via SMTP. - -## Installation - -```bash -npm install @builderbot/provider-email -# or -pnpm add @builderbot/provider-email -``` - -## Features - -- Real-time email reception using IMAP IDLE -- Send emails via SMTP -- Thread/conversation tracking -- Attachment support (send and receive) -- Compatible with any IMAP/SMTP server (Gmail, Outlook, custom servers, etc.) - -## Quick Start - -```typescript -import { createBot, createProvider, createFlow, addKeyword } from '@builderbot/bot' -import { EmailProvider } from '@builderbot/provider-email' - -const emailProvider = createProvider(EmailProvider, { - imap: { - host: 'imap.gmail.com', - port: 993, - secure: true, - auth: { - user: 'your-email@gmail.com', - pass: 'your-app-password' - } - }, - smtp: { - host: 'smtp.gmail.com', - port: 465, - secure: true, - auth: { - user: 'your-email@gmail.com', - pass: 'your-app-password' - } - } -}) - -const welcomeFlow = addKeyword(['hello', 'hi']) - .addAnswer('Hello! I received your email.') - .addAction(async (ctx, { provider }) => { - console.log('Email from:', ctx.from) - console.log('Subject:', ctx.subject) - console.log('Body:', ctx.body) - console.log('Is reply:', ctx.isReply) - }) - -const main = async () => { - await createBot({ - flow: createFlow([welcomeFlow]), - provider: emailProvider, - database: new MemoryDB() - }) -} - -main() -``` - -## Configuration - -### IEmailProviderArgs - -| Property | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| -| `imap` | `ImapConfig` | Yes | - | IMAP server configuration | -| `smtp` | `SmtpConfig` | Yes | - | SMTP server configuration | -| `mailbox` | `string` | No | `'INBOX'` | Mailbox to monitor | -| `markAsRead` | `boolean` | No | `true` | Mark emails as read after processing | -| `fromEmail` | `string` | No | SMTP user | From address for outgoing emails | -| `fromName` | `string` | No | - | Display name for outgoing emails | - -### ImapConfig / SmtpConfig - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `host` | `string` | Yes | Server hostname | -| `port` | `number` | Yes | Server port | -| `secure` | `boolean` | No | Use SSL/TLS (default: true) | -| `auth.user` | `string` | Yes | Username | -| `auth.pass` | `string` | Yes | Password or app password | - -## Email Context (ctx) - -When an email is received, the context object includes: - -```typescript -interface EmailBotContext { - from: string // Sender's email address - name: string // Sender's display name - body: string // Email body (plain text) - subject: string // Email subject - messageId: string // Unique message ID - threadId?: string // Thread ID for conversations - inReplyTo?: string // ID of email being replied to - isReply: boolean // Whether this is a reply - attachments?: Array<{ - filename: string - contentType: string - size: number - }> - html?: string // HTML content (if available) - to?: string[] // Recipients - cc?: string[] // CC recipients - date?: Date // Email date -} -``` - -## API Methods - -### sendMessage(to, message, options?) - -Send an email to a recipient. - -```typescript -await provider.sendMessage('recipient@example.com', 'Hello!', { - subject: 'Greeting', - html: '

Hello!

' -}) -``` - -### sendMedia(to, message, mediaPath, options?) - -Send an email with an attachment. - -```typescript -await provider.sendMedia( - 'recipient@example.com', - 'Please find the document attached.', - '/path/to/document.pdf', - { subject: 'Document' } -) -``` - -### reply(ctx, message, options?) - -Reply to an existing email thread. - -```typescript -.addAction(async (ctx, { provider }) => { - await provider.reply(ctx, 'Thank you for your message!') -}) -``` - -### saveFile(ctx, options?) - -Save an email attachment to disk. - -```typescript -.addAction(async (ctx, { provider }) => { - if (ctx.attachments?.length) { - const filePath = await provider.saveFile(ctx, { - path: './downloads', - attachmentIndex: 0 - }) - console.log('Saved to:', filePath) - } -}) -``` - -### getAttachments(ctx) - -Get all attachments from an email. - -```typescript -const attachments = provider.getAttachments(ctx) -``` - -### isReply(ctx) - -Check if the email is a reply. - -```typescript -if (provider.isReply(ctx)) { - console.log('This is a reply to:', ctx.inReplyTo) -} -``` - -### getThreadId(ctx) - -Get the thread ID for conversation tracking. - -```typescript -const threadId = provider.getThreadId(ctx) -``` - -## Gmail Configuration - -For Gmail, you need to use an App Password: - -1. Enable 2-Factor Authentication on your Google account -2. Go to [Google App Passwords](https://myaccount.google.com/apppasswords) -3. Generate a new app password for "Mail" -4. Use this password in the configuration - -```typescript -{ - imap: { - host: 'imap.gmail.com', - port: 993, - secure: true, - auth: { - user: 'your-email@gmail.com', - pass: 'xxxx xxxx xxxx xxxx' // App password - } - }, - smtp: { - host: 'smtp.gmail.com', - port: 465, - secure: true, - auth: { - user: 'your-email@gmail.com', - pass: 'xxxx xxxx xxxx xxxx' // App password - } - } -} -``` - -## Outlook/Office 365 Configuration - -```typescript -{ - imap: { - host: 'outlook.office365.com', - port: 993, - secure: true, - auth: { - user: 'your-email@outlook.com', - pass: 'your-password' - } - }, - smtp: { - host: 'smtp.office365.com', - port: 587, - secure: false, // Use STARTTLS - auth: { - user: 'your-email@outlook.com', - pass: 'your-password' - } - } -} -``` - -## License - -MIT diff --git a/packages/provider-email/__tests__/core.test.ts b/packages/provider-email/__tests__/core.test.ts deleted file mode 100644 index 14f43c20b..000000000 --- a/packages/provider-email/__tests__/core.test.ts +++ /dev/null @@ -1,457 +0,0 @@ -import { describe, expect, test, jest, beforeEach } from '@jest/globals' -import type { ParsedMail, AddressObject } from 'mailparser' - -// Mock @builderbot/bot before importing EmailCoreVendor -jest.mock('@builderbot/bot', () => ({ - ProviderClass: class MockProviderClass {}, - utils: { - generateRefProvider: jest.fn((event: string) => `REF:${event}`), - }, -})) - -import { EmailCoreVendor } from '../src/email/core' -import type { IEmailProviderArgs, EmailBotContext } from '../src/types' - -const mockConfig: IEmailProviderArgs = { - imap: { - host: 'imap.example.com', - port: 993, - secure: true, - auth: { - user: 'test@example.com', - pass: 'password123', - }, - }, - smtp: { - host: 'smtp.example.com', - port: 465, - secure: true, - auth: { - user: 'test@example.com', - pass: 'password123', - }, - }, -} - -/** - * Helper to create a mock ParsedMail object - */ -const createMockParsedMail = (options: { - text?: string - html?: string - attachments?: Array<{ - filename?: string - contentType: string - size?: number - content?: Buffer - }> - from?: string - subject?: string - messageId?: string - inReplyTo?: string - references?: string[] -}): ParsedMail => { - const fromAddress = options.from || 'sender@example.com' - return { - from: { - value: [{ address: fromAddress, name: 'Test Sender' }], - html: '', - text: fromAddress, - } as AddressObject, - to: { - value: [{ address: 'recipient@example.com', name: 'Recipient' }], - html: '', - text: 'recipient@example.com', - } as AddressObject, - subject: options.subject !== undefined ? options.subject : 'Test Subject', - messageId: options.messageId || '', - text: options.text || '', - html: options.html || false, - textAsHtml: options.text || '', - attachments: (options.attachments || []).map((att) => ({ - filename: att.filename || 'file', - contentType: att.contentType, - size: att.size || 100, - content: att.content || Buffer.from('test'), - contentDisposition: 'attachment', - related: false, - type: att.contentType.split('/')[0], - contentId: undefined, - cid: undefined, - headers: new Map(), - checksum: 'abc123', - })), - inReplyTo: options.inReplyTo, - references: options.references, - date: new Date(), - headerLines: [], - headers: new Map(), - } as unknown as ParsedMail -} - -describe('EmailCoreVendor', () => { - let vendor: EmailCoreVendor - - beforeEach(() => { - jest.clearAllMocks() - vendor = new EmailCoreVendor(mockConfig) - }) - - describe('parseEmailToContext - event detection', () => { - test('should generate _event_media_ for image attachments', () => { - const parsed = createMockParsedMail({ - text: 'Hello', - attachments: [{ contentType: 'image/png', filename: 'photo.png' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('REF:_event_media_') - }) - - test('should generate _event_media_ for video attachments', () => { - const parsed = createMockParsedMail({ - text: 'Check this video', - attachments: [{ contentType: 'video/mp4', filename: 'video.mp4' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('REF:_event_media_') - }) - - test('should generate _event_voice_note_ for audio attachments', () => { - const parsed = createMockParsedMail({ - text: '', - attachments: [{ contentType: 'audio/mp3', filename: 'voice.mp3' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('REF:_event_voice_note_') - }) - - test('should generate _event_document_ for application/pdf', () => { - const parsed = createMockParsedMail({ - text: '', // Empty body for document to trigger - attachments: [{ contentType: 'application/pdf', filename: 'document.pdf' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('REF:_event_document_') - }) - - test('should generate _event_document_ for text/csv', () => { - const parsed = createMockParsedMail({ - text: '', // Empty body for document to trigger - attachments: [{ contentType: 'text/csv', filename: 'data.csv' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('REF:_event_document_') - }) - - test('should NOT generate _event_document_ for text/plain attachments', () => { - const parsed = createMockParsedMail({ - text: '', - attachments: [{ contentType: 'text/plain', filename: 'note.txt' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - // Should keep empty body, not generate document event - expect(result.body).toBe('') - }) - - test('should NOT generate _event_document_ for text/html attachments', () => { - const parsed = createMockParsedMail({ - text: '', - attachments: [{ contentType: 'text/html', filename: 'page.html' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - // Should keep empty body, not generate document event - expect(result.body).toBe('') - }) - - test('should keep text body when no special attachments', () => { - const parsed = createMockParsedMail({ - text: 'Hello world', - attachments: [], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('Hello world') - }) - }) - - describe('parseEmailToContext - event priority', () => { - test('MEDIA should have priority over VOICE_NOTE', () => { - const parsed = createMockParsedMail({ - text: '', - attachments: [ - { contentType: 'image/jpeg', filename: 'photo.jpg' }, - { contentType: 'audio/mp3', filename: 'audio.mp3' }, - ], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('REF:_event_media_') - }) - - test('MEDIA should have priority over DOCUMENT', () => { - const parsed = createMockParsedMail({ - text: '', - attachments: [ - { contentType: 'video/mp4', filename: 'video.mp4' }, - { contentType: 'application/pdf', filename: 'doc.pdf' }, - ], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('REF:_event_media_') - }) - - test('VOICE_NOTE should have priority over DOCUMENT', () => { - const parsed = createMockParsedMail({ - text: '', - attachments: [ - { contentType: 'audio/ogg', filename: 'voice.ogg' }, - { contentType: 'application/msword', filename: 'doc.doc' }, - ], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('REF:_event_voice_note_') - }) - - test('DOCUMENT only triggers when no text body', () => { - const parsed = createMockParsedMail({ - text: 'Please see attached document', - attachments: [{ contentType: 'application/pdf', filename: 'report.pdf' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - // Should keep text body since it's not empty - expect(result.body).toBe('Please see attached document') - }) - - test('MEDIA triggers even with text body', () => { - const parsed = createMockParsedMail({ - text: 'Check out this photo!', - attachments: [{ contentType: 'image/png', filename: 'photo.png' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - // MEDIA always triggers regardless of text - expect(result.body).toBe('REF:_event_media_') - }) - - test('VOICE_NOTE triggers even with text body', () => { - const parsed = createMockParsedMail({ - text: 'Listen to this', - attachments: [{ contentType: 'audio/wav', filename: 'recording.wav' }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - // VOICE_NOTE always triggers regardless of text - expect(result.body).toBe('REF:_event_voice_note_') - }) - }) - - describe('parseEmailToContext - email parsing', () => { - test('should return null for email without from address', () => { - const parsed = createMockParsedMail({ text: 'Hello' }) - // Remove from address - ;(parsed as any).from = undefined - - const result = (vendor as any).parseEmailToContext(parsed, 1) - - expect(result).toBeNull() - }) - - test('should detect reply from inReplyTo header', () => { - const parsed = createMockParsedMail({ - text: 'Thanks for your email', - inReplyTo: '', - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.isReply).toBe(true) - expect(result.inReplyTo).toBe('') - }) - - test('should detect reply from references header', () => { - const parsed = createMockParsedMail({ - text: 'Following up', - references: ['', ''], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.isReply).toBe(true) - }) - - test('should extract threadId from references', () => { - const parsed = createMockParsedMail({ - text: 'Reply', - references: ['', ''], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.threadId).toBe('') - }) - - test('should include attachments in context', () => { - const parsed = createMockParsedMail({ - text: 'See attached', - attachments: [{ contentType: 'text/plain', filename: 'notes.txt', size: 500 }], - }) - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.attachments).toBeDefined() - expect(result.attachments).toHaveLength(1) - expect(result.attachments![0].filename).toBe('notes.txt') - expect(result.attachments![0].contentType).toBe('text/plain') - }) - - test('should use default subject when missing', () => { - const parsed = createMockParsedMail({ text: 'Hello' }) - ;(parsed as any).subject = undefined - - const result = (vendor as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.subject).toBe('(no subject)') - }) - }) - - describe('parseEmailToContext - messageSource option', () => { - test('should use body by default', () => { - const vendorDefault = new EmailCoreVendor(mockConfig) - const parsed = createMockParsedMail({ - text: 'This is the body', - subject: 'This is the subject', - }) - - const result = (vendorDefault as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('This is the body') - }) - - test('should use subject when messageSource is "subject"', () => { - const vendorSubject = new EmailCoreVendor({ - ...mockConfig, - messageSource: 'subject', - }) - const parsed = createMockParsedMail({ - text: 'This is the body', - subject: 'This is the subject', - }) - - const result = (vendorSubject as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('This is the subject') - }) - - test('should use both when messageSource is "both"', () => { - const vendorBoth = new EmailCoreVendor({ - ...mockConfig, - messageSource: 'both', - }) - const parsed = createMockParsedMail({ - text: 'This is the body', - subject: 'This is the subject', - }) - - const result = (vendorBoth as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('This is the subject\n\nThis is the body') - }) - - test('should handle empty body with messageSource "both"', () => { - const vendorBoth = new EmailCoreVendor({ - ...mockConfig, - messageSource: 'both', - }) - const parsed = createMockParsedMail({ - text: '', - subject: 'Only subject', - }) - - const result = (vendorBoth as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - expect(result.body).toBe('Only subject') - }) - - test('should handle empty subject with messageSource "both"', () => { - const vendorBoth = new EmailCoreVendor({ - ...mockConfig, - messageSource: 'both', - }) - const parsed = createMockParsedMail({ - text: 'Only body', - subject: '', - }) - - const result = (vendorBoth as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - // Note: subject in context will be '(no subject)' but body uses the raw value - expect(result.body).toBe('Only body') - }) - - test('messageSource should not affect MEDIA event detection', () => { - const vendorSubject = new EmailCoreVendor({ - ...mockConfig, - messageSource: 'subject', - }) - const parsed = createMockParsedMail({ - text: 'Check this image', - subject: 'Photo for you', - attachments: [{ contentType: 'image/png', filename: 'photo.png' }], - }) - - const result = (vendorSubject as any).parseEmailToContext(parsed, 1) as EmailBotContext - - expect(result).not.toBeNull() - // MEDIA event should override messageSource - expect(result.body).toBe('REF:_event_media_') - }) - }) -}) diff --git a/packages/provider-email/__tests__/emailProvider.test.ts b/packages/provider-email/__tests__/emailProvider.test.ts deleted file mode 100644 index 7a490c29f..000000000 --- a/packages/provider-email/__tests__/emailProvider.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { describe, expect, test, beforeEach, jest } from '@jest/globals' - -import { EmailProvider } from '../src/email/provider' -import type { IEmailProviderArgs, EmailBotContext } from '../src/types' - -const mockConfig: IEmailProviderArgs = { - imap: { - host: 'imap.example.com', - port: 993, - secure: true, - auth: { - user: 'test@example.com', - pass: 'password123', - }, - }, - smtp: { - host: 'smtp.example.com', - port: 465, - secure: true, - auth: { - user: 'test@example.com', - pass: 'password123', - }, - }, -} - -describe('EmailProvider', () => { - describe('constructor', () => { - test('should create instance with valid config', () => { - const provider = new EmailProvider(mockConfig) - expect(provider).toBeInstanceOf(EmailProvider) - expect(provider.globalVendorArgs.imap).toBeDefined() - expect(provider.globalVendorArgs.smtp).toBeDefined() - }) - - test('should throw error without IMAP config', () => { - expect(() => { - new EmailProvider({ - smtp: mockConfig.smtp, - } as IEmailProviderArgs) - }).toThrow('IMAP configuration is required') - }) - - test('should throw error without SMTP config', () => { - expect(() => { - new EmailProvider({ - imap: mockConfig.imap, - } as IEmailProviderArgs) - }).toThrow('SMTP configuration is required') - }) - - test('should throw error without IMAP auth', () => { - expect(() => { - new EmailProvider({ - imap: { - host: 'imap.example.com', - port: 993, - } as any, - smtp: mockConfig.smtp, - }) - }).toThrow('IMAP host and authentication are required') - }) - - test('should set default values', () => { - const provider = new EmailProvider(mockConfig) - expect(provider.globalVendorArgs.name).toBe('email-bot') - expect(provider.globalVendorArgs.port).toBe(3000) - expect(provider.globalVendorArgs.mailbox).toBe('INBOX') - expect(provider.globalVendorArgs.markAsRead).toBe(true) - }) - - test('should allow overriding default values', () => { - const provider = new EmailProvider({ - ...mockConfig, - name: 'custom-bot', - port: 4000, - mailbox: 'Custom', - markAsRead: false, - }) - expect(provider.globalVendorArgs.name).toBe('custom-bot') - expect(provider.globalVendorArgs.port).toBe(4000) - expect(provider.globalVendorArgs.mailbox).toBe('Custom') - expect(provider.globalVendorArgs.markAsRead).toBe(false) - }) - }) - - describe('helper methods', () => { - let provider: EmailProvider - - beforeEach(() => { - provider = new EmailProvider(mockConfig) - }) - - test('isReply should return correct value', () => { - expect(provider.isReply({ isReply: true } as any)).toBe(true) - expect(provider.isReply({ isReply: false } as any)).toBe(false) - }) - - test('getThreadId should return threadId', () => { - expect(provider.getThreadId({ threadId: 'test-thread' } as any)).toBe('test-thread') - expect(provider.getThreadId({} as any)).toBeUndefined() - }) - - test('getAttachments should return attachments array', () => { - const attachments = [{ filename: 'test.txt', contentType: 'text/plain', size: 100 }] - expect(provider.getAttachments({ attachments } as any)).toEqual(attachments) - expect(provider.getAttachments({} as any)).toEqual([]) - }) - }) - - describe('thread replies', () => { - let provider: EmailProvider - - // Helper to create a mock sendEmail function - const createMockSendEmail = () => { - const fn = jest.fn() - fn.mockImplementation(() => Promise.resolve({ messageId: '' })) - return fn - } - - beforeEach(() => { - provider = new EmailProvider(mockConfig) - }) - - test('busEvents should store context in conversationContexts Map', () => { - const busEvents = provider['busEvents']() - const messageHandler = busEvents.find((e) => e.event === 'message') - - expect(messageHandler).toBeDefined() - - const mockPayload: EmailBotContext = { - from: 'user@example.com', - name: 'Test User', - body: 'Hello', - subject: 'Test Subject', - messageId: '', - threadId: '', - isReply: false, - uid: 1, - } - - // Call the message handler - messageHandler!.func(mockPayload) - - // Check that context was stored - const storedContext = (provider as any).conversationContexts.get('user@example.com') - expect(storedContext).toBeDefined() - expect(storedContext.messageId).toBe('') - expect(storedContext.subject).toBe('Test Subject') - }) - - test('sendMessage should use stored context for inReplyTo', async () => { - // Setup: store a context - const mockContext: EmailBotContext = { - from: 'user@example.com', - name: 'Test User', - body: 'Hello', - subject: 'Original Subject', - messageId: '', - threadId: '', - isReply: false, - uid: 1, - } - ;(provider as any).conversationContexts.set('user@example.com', mockContext) - - // Mock vendor.sendEmail - const mockSendEmail = createMockSendEmail() - ;(provider as any).vendor = { sendEmail: mockSendEmail } - - // Call sendMessage - await provider.sendMessage('user@example.com', 'Reply message') - - // Verify sendEmail was called with correct inReplyTo - expect(mockSendEmail).toHaveBeenCalledWith( - 'user@example.com', - 'Re: Original Subject', - 'Reply message', - expect.objectContaining({ - inReplyTo: '', - }) - ) - }) - - test('sendMessage should add Re: prefix to subject', async () => { - const mockContext: EmailBotContext = { - from: 'user@example.com', - name: 'Test User', - body: 'Hello', - subject: 'Question about product', - messageId: '', - threadId: '', - isReply: false, - uid: 1, - } - ;(provider as any).conversationContexts.set('user@example.com', mockContext) - - const mockSendEmail = createMockSendEmail() - ;(provider as any).vendor = { sendEmail: mockSendEmail } - - await provider.sendMessage('user@example.com', 'Here is the answer') - - expect(mockSendEmail).toHaveBeenCalledWith( - 'user@example.com', - 'Re: Question about product', - 'Here is the answer', - expect.any(Object) - ) - }) - - test('sendMessage should include references header', async () => { - const mockContext: EmailBotContext = { - from: 'user@example.com', - name: 'Test User', - body: 'Hello', - subject: 'Thread test', - messageId: '', - threadId: '', - isReply: false, - uid: 1, - } - ;(provider as any).conversationContexts.set('user@example.com', mockContext) - - const mockSendEmail = createMockSendEmail() - ;(provider as any).vendor = { sendEmail: mockSendEmail } - - await provider.sendMessage('user@example.com', 'Following up') - - expect(mockSendEmail).toHaveBeenCalledWith( - 'user@example.com', - expect.any(String), - 'Following up', - expect.objectContaining({ - references: [''], - }) - ) - }) - - test('sendMessage should not add Re: if already present', async () => { - const mockContext: EmailBotContext = { - from: 'user@example.com', - name: 'Test User', - body: 'Hello', - subject: 'Re: Already a reply', - messageId: '', - threadId: '', - isReply: true, - uid: 1, - } - ;(provider as any).conversationContexts.set('user@example.com', mockContext) - - const mockSendEmail = createMockSendEmail() - ;(provider as any).vendor = { sendEmail: mockSendEmail } - - await provider.sendMessage('user@example.com', 'Continuing the thread') - - // Should NOT have "Re: Re:" - expect(mockSendEmail).toHaveBeenCalledWith( - 'user@example.com', - 'Re: Already a reply', // Not "Re: Re: Already a reply" - 'Continuing the thread', - expect.any(Object) - ) - }) - - test('sendMessage without context should use default subject', async () => { - // No context stored for this user - const mockSendEmail = createMockSendEmail() - ;(provider as any).vendor = { sendEmail: mockSendEmail } - - await provider.sendMessage('newuser@example.com', 'Hello!') - - expect(mockSendEmail).toHaveBeenCalledWith( - 'newuser@example.com', - 'Message from Bot', - 'Hello!', - expect.objectContaining({ - inReplyTo: undefined, - }) - ) - }) - - test('sendMessage should allow custom subject override', async () => { - const mockContext: EmailBotContext = { - from: 'user@example.com', - name: 'Test User', - body: 'Hello', - subject: 'Original', - messageId: '', - threadId: '', - isReply: false, - uid: 1, - } - ;(provider as any).conversationContexts.set('user@example.com', mockContext) - - const mockSendEmail = createMockSendEmail() - ;(provider as any).vendor = { sendEmail: mockSendEmail } - - await provider.sendMessage('user@example.com', 'Custom message', { - subject: 'Custom Subject', - }) - - // Custom subject should be used with Re: prefix since there's context - expect(mockSendEmail).toHaveBeenCalledWith( - 'user@example.com', - 'Re: Custom Subject', - 'Custom message', - expect.any(Object) - ) - }) - }) -}) diff --git a/packages/provider-email/__tests__/utils.test.ts b/packages/provider-email/__tests__/utils.test.ts deleted file mode 100644 index 0dbac4512..000000000 --- a/packages/provider-email/__tests__/utils.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { describe, expect, test } from '@jest/globals' - -import { - extractEmailAddress, - extractEmailName, - isValidEmail, - cleanEmail, - parseEmailList, - formatEmailAddress, - htmlToText, - isHtml, - extractThreadId, - isReplySubject, - stripReplyPrefix, - addReplyPrefix, - parseMimeType, - mimeToExtension, -} from '../src/utils' - -describe('#extractEmailAddress', () => { - test('should extract email from "Name " format', () => { - const input = 'John Doe ' - const result = extractEmailAddress(input) - expect(result).toBe('john@example.com') - }) - - test('should handle plain email address', () => { - const input = 'john@example.com' - const result = extractEmailAddress(input) - expect(result).toBe('john@example.com') - }) - - test('should handle email with quotes in name', () => { - const input = '"John Doe" ' - const result = extractEmailAddress(input) - expect(result).toBe('john@example.com') - }) - - test('should return empty string for empty input', () => { - const result = extractEmailAddress('') - expect(result).toBe('') - }) -}) - -describe('#extractEmailName', () => { - test('should extract name from "Name " format', () => { - const input = 'John Doe ' - const result = extractEmailName(input) - expect(result).toBe('John Doe') - }) - - test('should handle quoted name', () => { - const input = '"John Doe" ' - const result = extractEmailName(input) - expect(result).toBe('John Doe') - }) - - test('should return empty string for plain email', () => { - const input = 'john@example.com' - const result = extractEmailName(input) - expect(result).toBe('') - }) -}) - -describe('#isValidEmail', () => { - test('should return true for valid email', () => { - expect(isValidEmail('john@example.com')).toBe(true) - expect(isValidEmail('test.user@domain.org')).toBe(true) - }) - - test('should return false for invalid email', () => { - expect(isValidEmail('invalid')).toBe(false) - expect(isValidEmail('invalid@')).toBe(false) - expect(isValidEmail('@domain.com')).toBe(false) - expect(isValidEmail('')).toBe(false) - }) -}) - -describe('#cleanEmail', () => { - test('should clean and normalize email', () => { - const result = cleanEmail(' John@EXAMPLE.COM ') - expect(result).toBe('john@example.com') - }) - - test('should extract and clean email from full format', () => { - const result = cleanEmail('John Doe ') - expect(result).toBe('john@example.com') - }) -}) - -describe('#parseEmailList', () => { - test('should parse comma-separated emails', () => { - const result = parseEmailList('john@example.com, jane@example.com') - expect(result).toEqual(['john@example.com', 'jane@example.com']) - }) - - test('should parse semicolon-separated emails', () => { - const result = parseEmailList('john@example.com; jane@example.com') - expect(result).toEqual(['john@example.com', 'jane@example.com']) - }) - - test('should filter out invalid emails', () => { - const result = parseEmailList('john@example.com, invalid, jane@example.com') - expect(result).toEqual(['john@example.com', 'jane@example.com']) - }) -}) - -describe('#formatEmailAddress', () => { - test('should format with name', () => { - const result = formatEmailAddress('john@example.com', 'John Doe') - expect(result).toBe('"John Doe" ') - }) - - test('should return plain email without name', () => { - const result = formatEmailAddress('john@example.com') - expect(result).toBe('john@example.com') - }) -}) - -describe('#htmlToText', () => { - test('should strip HTML tags', () => { - const html = '

Hello World

' - const result = htmlToText(html) - expect(result).toContain('Hello') - expect(result).toContain('World') - expect(result).not.toContain('<') - }) - - test('should decode HTML entities', () => { - const html = '& < > "' - const result = htmlToText(html) - expect(result).toBe('& < > "') - }) - - test('should handle empty input', () => { - expect(htmlToText('')).toBe('') - }) -}) - -describe('#isHtml', () => { - test('should detect HTML content', () => { - expect(isHtml('

Hello

')).toBe(true) - expect(isHtml('
Content
')).toBe(true) - }) - - test('should return false for plain text', () => { - expect(isHtml('Hello World')).toBe(false) - expect(isHtml('')).toBe(false) - }) -}) - -describe('#extractThreadId', () => { - test('should extract from references array', () => { - const references = ['', ''] - const result = extractThreadId(references) - expect(result).toBe('') - }) - - test('should extract from references string', () => { - const references = ' ' - const result = extractThreadId(references) - expect(result).toBe('') - }) - - test('should fall back to inReplyTo', () => { - const result = extractThreadId(undefined, '') - expect(result).toBe('') - }) - - test('should return undefined when no data', () => { - const result = extractThreadId(undefined, undefined) - expect(result).toBeUndefined() - }) -}) - -describe('#isReplySubject', () => { - test('should detect reply subjects', () => { - expect(isReplySubject('Re: Hello')).toBe(true) - expect(isReplySubject('RE: Hello')).toBe(true) - expect(isReplySubject('re: Hello')).toBe(true) - expect(isReplySubject('Aw: Hello')).toBe(true) // German - expect(isReplySubject('Sv: Hello')).toBe(true) // Swedish - }) - - test('should return false for non-reply subjects', () => { - expect(isReplySubject('Hello')).toBe(false) - expect(isReplySubject('Meeting Request')).toBe(false) - expect(isReplySubject('')).toBe(false) - }) -}) - -describe('#stripReplyPrefix', () => { - test('should strip reply prefix', () => { - expect(stripReplyPrefix('Re: Hello')).toBe('Hello') - expect(stripReplyPrefix('RE: Hello')).toBe('Hello') - }) - - test('should not modify non-reply subjects', () => { - expect(stripReplyPrefix('Hello')).toBe('Hello') - }) -}) - -describe('#addReplyPrefix', () => { - test('should add reply prefix', () => { - expect(addReplyPrefix('Hello')).toBe('Re: Hello') - }) - - test('should not add prefix if already present', () => { - expect(addReplyPrefix('Re: Hello')).toBe('Re: Hello') - }) - - test('should handle empty subject', () => { - expect(addReplyPrefix('')).toBe('Re:') - }) -}) - -describe('#parseMimeType', () => { - test('should parse simple MIME type', () => { - const result = parseMimeType('text/plain') - expect(result.type).toBe('text') - expect(result.subtype).toBe('plain') - }) - - test('should parse MIME type with parameters', () => { - const result = parseMimeType('text/plain; charset=utf-8') - expect(result.type).toBe('text') - expect(result.subtype).toBe('plain') - expect(result.parameters.charset).toBe('utf-8') - }) - - test('should handle empty input', () => { - const result = parseMimeType('') - expect(result.type).toBe('text') - expect(result.subtype).toBe('plain') - }) -}) - -describe('#mimeToExtension', () => { - test('should return correct extension for known types', () => { - expect(mimeToExtension('text/plain')).toBe('txt') - expect(mimeToExtension('text/html')).toBe('html') - expect(mimeToExtension('application/pdf')).toBe('pdf') - expect(mimeToExtension('image/jpeg')).toBe('jpg') - expect(mimeToExtension('image/png')).toBe('png') - }) - - test('should return subtype for unknown types', () => { - expect(mimeToExtension('application/unknown')).toBe('unknown') - }) -}) diff --git a/packages/provider-email/jest.config.ts b/packages/provider-email/jest.config.ts deleted file mode 100644 index ca2c6a76f..000000000 --- a/packages/provider-email/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, -} - -export default config diff --git a/packages/provider-email/package.json b/packages/provider-email/package.json deleted file mode 100644 index de5044d26..000000000 --- a/packages/provider-email/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "@builderbot/provider-email", - "version": "1.4.2-alpha.11", - "description": "Email provider for BuilderBot using IMAP/SMTP", - "author": "Leifer Mendez ", - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "license": "MIT", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "directories": { - "lib": "dist", - "test": "__tests__" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/node": "^24.10.2", - "@types/nodemailer": "^6.4.17", - "@types/polka": "^0.5.7", - "cors": "^2.8.5", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0" - }, - "dependencies": { - "body-parser": "^2.2.1", - "imapflow": "^1.0.171", - "mailparser": "^3.7.2", - "nodemailer": "^6.10.1", - "polka": "^0.5.2" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-email/rollup.config.js b/packages/provider-email/rollup.config.js deleted file mode 100644 index c2c48296b..000000000 --- a/packages/provider-email/rollup.config.js +++ /dev/null @@ -1,24 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import json from '@rollup/plugin-json' -import commonjs from '@rollup/plugin-commonjs' -import { nodeResolve } from '@rollup/plugin-node-resolve' - -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - json(), - nodeResolve({ - resolveOnly: (module) => !/imapflow|nodemailer|mailparser|@builderbot\/bot/i.test(module), - }), - commonjs(), - typescript(), - ], -} diff --git a/packages/provider-email/src/email/core.ts b/packages/provider-email/src/email/core.ts deleted file mode 100644 index 28eae6134..000000000 --- a/packages/provider-email/src/email/core.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { utils } from '@builderbot/bot' -import { ImapFlow } from 'imapflow' -import { simpleParser, type ParsedMail, type AddressObject } from 'mailparser' -import EventEmitter from 'node:events' -import nodemailer, { type Transporter } from 'nodemailer' - -import type { IEmailProviderArgs, EmailBotContext, EmailSendOptions, EmailAttachment } from '../types' - -/** - * Class representing EmailCoreVendor, handles IMAP/SMTP operations. - * @extends EventEmitter - */ -export class EmailCoreVendor extends EventEmitter { - private imapClient: ImapFlow | null = null - private smtpTransporter: Transporter | null = null - private config: IEmailProviderArgs - private isConnected: boolean = false - private reconnectAttempts: number = 0 - private maxReconnectAttempts: number = 10 - private reconnectDelay: number = 5000 - - constructor(config: IEmailProviderArgs) { - super() - this.config = config - this.initializeSmtp() - } - - /** - * Initialize SMTP transporter for sending emails - */ - private initializeSmtp(): void { - try { - this.smtpTransporter = nodemailer.createTransport({ - host: this.config.smtp.host, - port: this.config.smtp.port, - secure: this.config.smtp.secure ?? true, - auth: { - user: this.config.smtp.auth.user, - pass: this.config.smtp.auth.pass, - }, - }) - console.log('[EmailProvider] SMTP transporter initialized') - } catch (error) { - console.error('[EmailProvider] Failed to initialize SMTP:', error) - this.emit('auth_failure', error) - } - } - - /** - * Connect to IMAP server and start listening for new emails - */ - public async connect(): Promise { - try { - this.imapClient = new ImapFlow({ - host: this.config.imap.host, - port: this.config.imap.port, - secure: this.config.imap.secure ?? true, - auth: { - user: this.config.imap.auth.user, - pass: this.config.imap.auth.pass, - }, - logger: false, - }) - - // Handle connection events - this.imapClient.on('error', (err: Error) => { - console.error('[EmailProvider] IMAP error:', err) - this.emit('error', err) - this.handleDisconnect() - }) - - this.imapClient.on('close', () => { - console.log('[EmailProvider] IMAP connection closed') - this.isConnected = false - this.handleDisconnect() - }) - - await this.imapClient.connect() - this.isConnected = true - this.reconnectAttempts = 0 - console.log('[EmailProvider] Connected to IMAP server') - - const host = { - email: this.config.imap.auth.user, - phone: this.config.imap.auth.user, - } - this.emit('host', host) - this.emit('ready') - - // Start listening for new emails (non-blocking) - this.startIdleListener() - } catch (error) { - console.error('[EmailProvider] Failed to connect to IMAP:', error) - this.emit('auth_failure', error) - throw error - } - } - - /** - * Handle disconnection and attempt reconnection - */ - private async handleDisconnect(): Promise { - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error('[EmailProvider] Max reconnection attempts reached') - this.emit('auth_failure', new Error('Max reconnection attempts reached')) - return - } - - this.reconnectAttempts++ - const delay = this.reconnectDelay * this.reconnectAttempts - - console.log( - `[EmailProvider] Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms` - ) - - setTimeout(async () => { - try { - await this.connect() - } catch (error) { - console.error('[EmailProvider] Reconnection failed:', error) - } - }, delay) - } - - /** - * Start IMAP IDLE listener for real-time email notifications - * This runs in the background and doesn't block the initialization - */ - private startIdleListener(): void { - if (!this.imapClient || !this.isConnected) return - - const mailbox = this.config.mailbox || 'INBOX' - - // Listen for new messages using EXISTS event - this.imapClient.on('exists', async (data: { path: string; count: number; prevCount: number }) => { - if (data.count > data.prevCount) { - console.log(`[EmailProvider] New email detected in ${data.path}`) - await this.fetchNewEmails(data.prevCount + 1, data.count) - } - }) - - // Start the IDLE loop in background - this.runIdleLoop(mailbox) - } - - /** - * Run the IDLE loop in background without blocking - */ - private async runIdleLoop(mailbox: string): Promise { - try { - const lock = await this.imapClient!.getMailboxLock(mailbox) - - try { - console.log(`[EmailProvider] Starting IDLE mode on ${mailbox}`) - - // Keep the connection alive with IDLE - while (this.isConnected && this.imapClient) { - try { - await this.imapClient.idle() - } catch (idleError) { - if (this.isConnected) { - console.error('[EmailProvider] IDLE error:', idleError) - } - break - } - } - } finally { - lock.release() - } - } catch (error) { - console.error('[EmailProvider] Failed to start IDLE listener:', error) - this.emit('error', error) - } - } - - /** - * Fetch new emails from a sequence range - */ - private async fetchNewEmails(startSeq: number, endSeq: number): Promise { - if (!this.imapClient || !this.isConnected) return - - const mailbox = this.config.mailbox || 'INBOX' - const processedEmails: { uid: number; context: EmailBotContext }[] = [] - - try { - const lock = await this.imapClient.getMailboxLock(mailbox) - - try { - for await (const message of this.imapClient.fetch(`${startSeq}:${endSeq}`, { - source: true, - uid: true, - })) { - try { - const parsed = await simpleParser(message.source) - const emailContext = this.parseEmailToContext(parsed, message.uid) - - if (emailContext) { - processedEmails.push({ uid: message.uid, context: emailContext }) - } - } catch (parseError) { - console.error('[EmailProvider] Failed to parse email:', parseError) - } - } - - // Mark emails as read after fetching (outside the fetch iterator) - if (this.config.markAsRead !== false && processedEmails.length > 0) { - const uids = processedEmails.map((e) => e.uid) - try { - await this.imapClient.messageFlagsAdd(uids, ['\\Seen']) - } catch (flagError) { - console.error('[EmailProvider] Failed to mark emails as read:', flagError) - } - } - } finally { - lock.release() - } - - // Emit events after releasing the lock to avoid blocking - for (const { context } of processedEmails) { - console.log('[EmailProvider] About to emit message event') - console.log('[EmailProvider] Listener count for "message":', this.listenerCount('message')) - console.log('[EmailProvider] Listeners:', this.listeners('message').length) - this.emit('message', context) - console.log('[EmailProvider] Message event emitted') - } - } catch (error) { - console.error('[EmailProvider] Failed to fetch new emails:', error) - } - } - - /** - * Parse a mailparser ParsedMail object to EmailBotContext - */ - private parseEmailToContext(parsed: ParsedMail, uid: number): EmailBotContext | null { - const fromAddress = this.extractAddress(parsed.from) - if (!fromAddress) { - console.warn('[EmailProvider] Email has no from address, skipping') - return null - } - - // Extract attachments - const attachments: EmailAttachment[] = (parsed.attachments || []).map((att) => ({ - filename: att.filename || 'unnamed', - contentType: att.contentType, - size: att.size, - contentId: att.contentId, - content: att.content, - })) - - // Determine if this is a reply - const isReply = !!(parsed.inReplyTo || (parsed.references && parsed.references.length > 0)) - - // Get thread ID from references - const threadId = parsed.references - ? Array.isArray(parsed.references) - ? parsed.references[0] - : parsed.references - : parsed.inReplyTo || parsed.messageId - - // Determine attachment types for event routing - const hasMedia = attachments.some( - (a) => a.contentType.startsWith('image/') || a.contentType.startsWith('video/') - ) - const hasAudio = attachments.some((a) => a.contentType.startsWith('audio/')) - const hasDocument = attachments.some((a) => { - // application/* are documents (pdf, msword, etc.) - if (a.contentType.startsWith('application/')) return true - // text/csv, text/calendar, etc. are documents, but NOT text/plain or text/html - if ( - a.contentType.startsWith('text/') && - !a.contentType.includes('plain') && - !a.contentType.includes('html') - ) { - return true - } - return false - }) - - // Determine base body based on messageSource config - let body = '' - const messageSource = this.config.messageSource || 'body' - switch (messageSource) { - case 'subject': - body = parsed.subject || '' - break - case 'both': - body = [parsed.subject, parsed.text].filter(Boolean).join('\n\n') - break - case 'body': - default: - body = parsed.text || '' - break - } - - // Build body - generate special events for attachments - // Priority: MEDIA > VOICE_NOTE > DOCUMENT > text - if (hasMedia) { - // Media attachments always trigger MEDIA event - body = utils.generateRefProvider('_event_media_') - } else if (hasAudio) { - // Audio attachments trigger VOICE_NOTE event - body = utils.generateRefProvider('_event_voice_note_') - } else if (hasDocument && !body.trim()) { - // Documents only trigger event if no text body - body = utils.generateRefProvider('_event_document_') - } - - const context: EmailBotContext = { - from: fromAddress.address, - name: fromAddress.name || fromAddress.address, - body: body, - subject: parsed.subject || '(no subject)', - messageId: parsed.messageId || `${uid}@${this.config.imap.host}`, - threadId: threadId, - inReplyTo: parsed.inReplyTo, - attachments: attachments.length > 0 ? attachments : undefined, - isReply: isReply, - html: parsed.html || undefined, - to: this.extractAddresses(parsed.to), - cc: parsed.cc ? this.extractAddresses(parsed.cc) : undefined, - date: parsed.date, - uid: uid, - } - - return context - } - - /** - * Extract single address from AddressObject - */ - private extractAddress( - addressObj: AddressObject | AddressObject[] | undefined - ): { address: string; name: string } | null { - if (!addressObj) return null - - const obj = Array.isArray(addressObj) ? addressObj[0] : addressObj - if (!obj || !obj.value || obj.value.length === 0) return null - - const first = obj.value[0] - return { - address: first.address || '', - name: first.name || '', - } - } - - /** - * Extract array of addresses from AddressObject - */ - private extractAddresses(addressObj: AddressObject | AddressObject[] | undefined): string[] { - if (!addressObj) return [] - - const objects = Array.isArray(addressObj) ? addressObj : [addressObj] - const addresses: string[] = [] - - for (const obj of objects) { - if (obj && obj.value) { - for (const addr of obj.value) { - if (addr.address) { - addresses.push(addr.address) - } - } - } - } - - return addresses - } - - /** - * Send an email via SMTP - */ - public async sendEmail( - to: string, - subject: string, - text: string, - options?: EmailSendOptions - ): Promise<{ messageId: string }> { - if (!this.smtpTransporter) { - throw new Error('SMTP transporter not initialized') - } - - const fromEmail = this.config.fromEmail || this.config.smtp.auth.user - const fromName = this.config.fromName || fromEmail - - const mailOptions: nodemailer.SendMailOptions = { - from: `"${fromName}" <${fromEmail}>`, - to: to, - subject: subject, - text: text, - } - - // Add optional fields - if (options?.html) { - mailOptions.html = options.html - } - if (options?.cc) { - mailOptions.cc = options.cc - } - if (options?.bcc) { - mailOptions.bcc = options.bcc - } - if (options?.replyTo) { - mailOptions.replyTo = options.replyTo - } - if (options?.inReplyTo) { - mailOptions.inReplyTo = options.inReplyTo - } - if (options?.references) { - mailOptions.references = Array.isArray(options.references) - ? options.references.join(' ') - : options.references - } - if (options?.attachments) { - mailOptions.attachments = options.attachments.map((att) => ({ - filename: att.filename, - path: att.path, - content: att.content, - contentType: att.contentType, - })) - } - - try { - const info = await this.smtpTransporter.sendMail(mailOptions) - console.log(`[EmailProvider] Email sent: ${info.messageId}`) - return { messageId: info.messageId } - } catch (error) { - console.error('[EmailProvider] Failed to send email:', error) - throw error - } - } - - /** - * Reply to an existing email thread - */ - public async replyToEmail( - originalContext: EmailBotContext, - text: string, - options?: Omit - ): Promise<{ messageId: string }> { - // Build references chain - const references: string[] = [] - if (originalContext.threadId) { - references.push(originalContext.threadId) - } - if (originalContext.messageId && originalContext.messageId !== originalContext.threadId) { - references.push(originalContext.messageId) - } - - // Prepare subject with Re: prefix if not already present - let subject = originalContext.subject - if (!subject.toLowerCase().startsWith('re:')) { - subject = `Re: ${subject}` - } - - return this.sendEmail(originalContext.from, subject, text, { - ...options, - inReplyTo: originalContext.messageId, - references: references, - }) - } - - /** - * Download attachment content - */ - public async downloadAttachment(ctx: EmailBotContext, attachmentIndex: number): Promise { - if (!ctx.attachments || attachmentIndex >= ctx.attachments.length) { - return null - } - - const attachment = ctx.attachments[attachmentIndex] - if (attachment.content) { - return attachment.content - } - - // Attachment content should already be in memory from parsing - console.warn('[EmailProvider] Attachment content not available') - return null - } - - /** - * Disconnect from IMAP server - */ - public async disconnect(): Promise { - this.isConnected = false - - if (this.imapClient) { - try { - await this.imapClient.logout() - } catch (error) { - console.error('[EmailProvider] Error during logout:', error) - } - this.imapClient = null - } - - if (this.smtpTransporter) { - this.smtpTransporter.close() - this.smtpTransporter = null - } - - console.log('[EmailProvider] Disconnected') - } - - /** - * Check if connected to IMAP server - */ - public isImapConnected(): boolean { - return this.isConnected && this.imapClient !== null - } - - /** - * Verify SMTP connection - */ - public async verifySmtp(): Promise { - if (!this.smtpTransporter) return false - - try { - await this.smtpTransporter.verify() - return true - } catch { - return false - } - } -} diff --git a/packages/provider-email/src/email/provider.ts b/packages/provider-email/src/email/provider.ts deleted file mode 100644 index 5f89712b1..000000000 --- a/packages/provider-email/src/email/provider.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { ProviderClass } from '@builderbot/bot' -import type { BotContext, SendOptions } from '@builderbot/bot/dist/types' -import { writeFile } from 'fs/promises' -import { tmpdir } from 'os' -import { join, resolve } from 'path' - -import { EmailCoreVendor } from './core' -import type { IEmailProviderArgs, EmailBotContext, EmailSendOptions } from '../types' - -/** - * Email Provider for BuilderBot - * Supports receiving emails via IMAP (with IDLE) and sending via SMTP - * @extends ProviderClass - */ -class EmailProvider extends ProviderClass { - globalVendorArgs: IEmailProviderArgs - - // Map to store the last context of each conversation for thread replies - private conversationContexts: Map = new Map() - - constructor(args: IEmailProviderArgs) { - super() - - // Validate required configuration - if (!args.imap) { - throw new Error('IMAP configuration is required') - } - if (!args.smtp) { - throw new Error('SMTP configuration is required') - } - if (!args.imap.host || !args.imap.auth?.user || !args.imap.auth?.pass) { - throw new Error('IMAP host and authentication are required') - } - if (!args.smtp.host || !args.smtp.auth?.user || !args.smtp.auth?.pass) { - throw new Error('SMTP host and authentication are required') - } - - this.globalVendorArgs = { - name: 'email-bot', - port: 3000, - writeMyself: 'none', - mailbox: 'INBOX', - markAsRead: true, - messageSource: 'body', - ...args, - } - } - - /** - * Initialize the email vendor (IMAP/SMTP connections) - */ - protected async initVendor(): Promise { - console.log('[EmailProvider] initVendor() called') - const vendor = new EmailCoreVendor(this.globalVendorArgs) - this.vendor = vendor - - // Connect to IMAP server - await vendor.connect() - - console.log('[EmailProvider] initVendor() returning vendor') - return vendor - } - - /** - * Called before HTTP server initialization - */ - protected beforeHttpServerInit(): void { - this.server = this.server - .use((req, _, next) => { - req['globalVendorArgs'] = this.globalVendorArgs - return next() - }) - .get('/', this.indexHome) - .post('/webhook', this.webhookHandler) - } - - /** - * Called after HTTP server initialization - */ - protected afterHttpServerInit(): void {} - - /** - * Index home endpoint - */ - private indexHome = (_: any, res: any) => { - res.end('Email Provider running') - } - - /** - * Webhook handler for external email notifications (optional) - */ - private webhookHandler = (req: any, res: any) => { - // This can be used for external email webhook integrations - const body = req.body - console.log('[EmailProvider] Webhook received:', body) - res.end(JSON.stringify({ status: 'ok' })) - } - - /** - * Map vendor events to provider events - */ - protected busEvents = () => { - console.log('[EmailProvider] busEvents() called - registering listeners') - return [ - { - event: 'auth_failure', - func: (payload: any) => this.emit('auth_failure', payload), - }, - { - event: 'ready', - func: () => { - console.log('[EmailProvider] busEvents ready handler called') - this.emit('ready', true) - }, - }, - { - event: 'message', - func: (payload: EmailBotContext) => { - console.log('[EmailProvider] busEvents message handler called!') - console.log('[EmailProvider] Payload from:', payload.from, 'body:', payload.body?.substring(0, 50)) - // Store context to enable thread replies - this.conversationContexts.set(payload.from, payload) - this.emit('message', payload) - console.log('[EmailProvider] Provider emitted message to bot') - }, - }, - { - event: 'host', - func: (payload: any) => { - this.emit('host', payload) - }, - }, - { - event: 'error', - func: (payload: any) => { - console.error('[EmailProvider] Error:', payload) - }, - }, - ] - } - - /** - * Send an email message - * @param to - Recipient email address - * @param message - Email body content - * @param options - Send options (subject, attachments, etc.) - */ - async sendMessage(to: string, message: string, options?: SendOptions & EmailSendOptions): Promise { - // Look up existing conversation context for thread replies - const conversationCtx = this.conversationContexts.get(to) - - // Build email options with thread context if available - const baseSubject = - options?.subject || (conversationCtx?.subject ? conversationCtx.subject : 'Message from Bot') - const subject = - conversationCtx && !baseSubject.toLowerCase().startsWith('re:') ? `Re: ${baseSubject}` : baseSubject - - const emailOptions: EmailSendOptions = { - ...options, - subject, - inReplyTo: options?.inReplyTo || conversationCtx?.messageId, - references: - options?.references || - (conversationCtx - ? ([conversationCtx.threadId || conversationCtx.messageId].filter(Boolean) as string[]) - : undefined), - } - - // Check for media/attachments - if (options?.media) { - return this.sendMedia(to, message, options.media, emailOptions) - } - - return this.vendor.sendEmail(to, subject, message, emailOptions) - } - - /** - * Send an email with media attachment - * @param to - Recipient email address - * @param message - Email body content - * @param mediaPath - Path to media file - * @param options - Additional email options - */ - async sendMedia(to: string, message: string, mediaPath: string, options?: EmailSendOptions): Promise { - const subject = options?.subject || 'Message with attachment' - - const attachments = [ - { - filename: mediaPath.split('/').pop() || 'attachment', - path: mediaPath, - }, - ] - - return this.vendor.sendEmail(to, subject, message, { - ...options, - attachments: [...(options?.attachments || []), ...attachments], - }) - } - - /** - * Reply to an existing email thread - * @param ctx - Original email context - * @param message - Reply message content - * @param options - Additional email options - */ - async reply( - ctx: EmailBotContext, - message: string, - options?: Omit - ): Promise { - return this.vendor.replyToEmail(ctx, message, options) - } - - /** - * Save an attachment from an email to disk - * @param ctx - Email context containing attachments - * @param options - Save options (path, attachment index) - */ - async saveFile( - ctx: Partial, - options?: { path?: string; attachmentIndex?: number } - ): Promise { - try { - const emailCtx = ctx as EmailBotContext - - if (!emailCtx.attachments || emailCtx.attachments.length === 0) { - throw new Error('No attachments in email') - } - - const attachmentIndex = options?.attachmentIndex ?? 0 - const attachment = emailCtx.attachments[attachmentIndex] - - if (!attachment) { - throw new Error(`Attachment at index ${attachmentIndex} not found`) - } - - if (!attachment.content) { - throw new Error('Attachment content not available') - } - - const savePath = options?.path ?? tmpdir() - const fileName = `${Date.now()}-${attachment.filename}` - const filePath = join(savePath, fileName) - - await writeFile(filePath, attachment.content) - return resolve(filePath) - } catch (error) { - console.error('[EmailProvider] Error saving file:', error) - throw error - } - } - - /** - * Get all attachments from an email - * @param ctx - Email context - */ - getAttachments(ctx: EmailBotContext) { - return ctx.attachments || [] - } - - /** - * Check if the email is a reply - * @param ctx - Email context - */ - isReply(ctx: EmailBotContext): boolean { - return ctx.isReply - } - - /** - * Get the thread ID from an email - * @param ctx - Email context - */ - getThreadId(ctx: EmailBotContext): string | undefined { - return ctx.threadId - } - - /** - * Disconnect the email provider - */ - async disconnect(): Promise { - if (this.vendor) { - await this.vendor.disconnect() - } - } -} - -export { EmailProvider } diff --git a/packages/provider-email/src/index.ts b/packages/provider-email/src/index.ts deleted file mode 100644 index 935d1b6bc..000000000 --- a/packages/provider-email/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { EmailProvider } from './email/provider' -export { EmailCoreVendor } from './email/core' -export type { - IEmailProviderArgs, - ImapConfig, - SmtpConfig, - EmailBotContext, - EmailSendOptions, - EmailAttachment, - ParsedEmail, - EmailVendorEvents, -} from './types' -export type { EmailInterface } from './interface/email' -export * from './utils' diff --git a/packages/provider-email/src/interface/email.ts b/packages/provider-email/src/interface/email.ts deleted file mode 100644 index 49144cf2a..000000000 --- a/packages/provider-email/src/interface/email.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { SendOptions, BotContext } from '@builderbot/bot/dist/types' - -import type { EmailBotContext, EmailSendOptions } from '../types' - -/** - * Interface for the Email Provider - */ -export interface EmailInterface { - /** - * Send an email with optional media attachment - * @param to - Recipient email address - * @param message - Email body content - * @param mediaPath - Path to media file to attach - * @param options - Additional email options - */ - sendMedia: (to: string, message: string, mediaPath: string, options?: EmailSendOptions) => Promise - - /** - * Send an email message - * @param to - Recipient email address - * @param message - Email body content - * @param options - Send options including subject, attachments, etc. - */ - sendMessage: (to: string, message: string, options?: SendOptions & EmailSendOptions) => Promise - - /** - * Save an attachment from an email to disk - * @param ctx - Email context with attachments - * @param options - Save options (path, attachment index) - */ - saveFile: ( - ctx: Partial, - options?: { path?: string; attachmentIndex?: number } - ) => Promise - - /** - * Reply to an existing email thread - * @param ctx - Original email context - * @param message - Reply message content - * @param options - Additional email options - */ - reply: ( - ctx: EmailBotContext, - message: string, - options?: Omit - ) => Promise - - /** - * Get all attachments from an email - * @param ctx - Email context - */ - getAttachments: (ctx: EmailBotContext) => EmailBotContext['attachments'] - - /** - * Check if the email is a reply to another email - * @param ctx - Email context - */ - isReply: (ctx: EmailBotContext) => boolean - - /** - * Get the thread ID from an email - * @param ctx - Email context - */ - getThreadId: (ctx: EmailBotContext) => string | undefined - - /** - * Disconnect the email provider - */ - disconnect: () => Promise -} diff --git a/packages/provider-email/src/types.ts b/packages/provider-email/src/types.ts deleted file mode 100644 index 1c65359c9..000000000 --- a/packages/provider-email/src/types.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { BotContext, GlobalVendorArgs } from '@builderbot/bot/dist/types' - -/** - * IMAP server configuration - */ -export interface ImapConfig { - /** IMAP server host (e.g., 'imap.gmail.com') */ - host: string - /** IMAP server port (e.g., 993 for SSL) */ - port: number - /** Use secure connection (SSL/TLS) */ - secure?: boolean - /** Authentication credentials */ - auth: { - user: string - pass: string - } -} - -/** - * Source for the message body - * - 'body': Use email body only (default) - * - 'subject': Use email subject only - * - 'both': Use both subject and body (format: "subject\n\nbody") - */ -export type MessageSource = 'body' | 'subject' | 'both' - -/** - * SMTP server configuration - */ -export interface SmtpConfig { - /** SMTP server host (e.g., 'smtp.gmail.com') */ - host: string - /** SMTP server port (e.g., 465 for SSL, 587 for TLS) */ - port: number - /** Use secure connection (SSL/TLS) */ - secure?: boolean - /** Authentication credentials */ - auth: { - user: string - pass: string - } -} - -/** - * Email provider configuration arguments - */ -export interface IEmailProviderArgs extends GlobalVendorArgs { - /** IMAP server configuration for receiving emails */ - imap: ImapConfig - /** SMTP server configuration for sending emails */ - smtp: SmtpConfig - /** Mailbox to monitor (default: 'INBOX') */ - mailbox?: string - /** Mark emails as read after processing (default: true) */ - markAsRead?: boolean - /** From email address for outgoing emails (defaults to imap.auth.user) */ - fromEmail?: string - /** From name for outgoing emails */ - fromName?: string - /** Source for message body: 'body', 'subject', or 'both' (default: 'body') */ - messageSource?: MessageSource -} - -/** - * Email attachment information - */ -export interface EmailAttachment { - /** Attachment filename */ - filename: string - /** MIME content type */ - contentType: string - /** File size in bytes */ - size: number - /** Content ID for inline attachments */ - contentId?: string - /** Raw content buffer (available when downloading) */ - content?: Buffer -} - -/** - * Email context extending BotContext - */ -export interface EmailBotContext extends BotContext { - /** Sender's email address */ - from: string - /** Sender's display name */ - name: string - /** Email body content (plain text preferred, fallback to HTML) */ - body: string - /** Email subject line */ - subject: string - /** Unique Message-ID header */ - messageId: string - /** Thread ID (derived from References header) */ - threadId?: string - /** In-Reply-To header value (if this is a reply) */ - inReplyTo?: string - /** List of attachments in the email */ - attachments?: EmailAttachment[] - /** Whether this email is a reply to another email */ - isReply: boolean - /** Original HTML content */ - html?: string - /** All recipients (To field) */ - to?: string[] - /** CC recipients */ - cc?: string[] - /** Email date */ - date?: Date - /** Raw email UID from IMAP */ - uid?: number -} - -/** - * Options for sending emails - */ -export interface EmailSendOptions { - /** Email subject (required for new threads) */ - subject?: string - /** CC recipients */ - cc?: string | string[] - /** BCC recipients */ - bcc?: string | string[] - /** Reply-To address */ - replyTo?: string - /** Attachments to send */ - attachments?: Array<{ - filename: string - path?: string - content?: Buffer | string - contentType?: string - }> - /** HTML content (alternative to plain text) */ - html?: string - /** In-Reply-To header for replies */ - inReplyTo?: string - /** References header for thread continuity */ - references?: string | string[] -} - -/** - * Internal email message structure from IMAP - */ -export interface ParsedEmail { - uid: number - messageId: string - from: { - address: string - name: string - } - to: Array<{ - address: string - name: string - }> - cc?: Array<{ - address: string - name: string - }> - subject: string - text?: string - html?: string - date: Date - inReplyTo?: string - references?: string[] - attachments: EmailAttachment[] -} - -/** - * Email vendor events - */ -export type EmailVendorEvents = { - message: [payload: EmailBotContext] - ready: [] - auth_failure: [error: Error] - error: [error: Error] -} diff --git a/packages/provider-email/src/utils/index.ts b/packages/provider-email/src/utils/index.ts deleted file mode 100644 index 089e6c613..000000000 --- a/packages/provider-email/src/utils/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - extractEmailAddress, - extractEmailName, - isValidEmail, - cleanEmail, - parseEmailList, - formatEmailAddress, - htmlToText, - isHtml, - extractThreadId, - isReplySubject, - stripReplyPrefix, - addReplyPrefix, - generateMessageId, - parseMimeType, - mimeToExtension, -} from './parser' diff --git a/packages/provider-email/src/utils/parser.ts b/packages/provider-email/src/utils/parser.ts deleted file mode 100644 index bfde861cb..000000000 --- a/packages/provider-email/src/utils/parser.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Email parsing utilities - */ - -/** - * Extract email address from a string that might contain name and email - * e.g., "John Doe " -> "john@example.com" - */ -export function extractEmailAddress(input: string): string { - if (!input) return '' - - // Check if it contains angle brackets - const match = input.match(/<([^>]+)>/) - if (match) { - return match[1].trim().toLowerCase() - } - - // Return as-is if it looks like an email - const trimmed = input.trim().toLowerCase() - if (isValidEmail(trimmed)) { - return trimmed - } - - return trimmed -} - -/** - * Extract name from email string - * e.g., "John Doe " -> "John Doe" - */ -export function extractEmailName(input: string): string { - if (!input) return '' - - // Check if it contains angle brackets - const bracketIndex = input.indexOf('<') - if (bracketIndex > 0) { - return input - .substring(0, bracketIndex) - .trim() - .replace(/^["']|["']$/g, '') - } - - return '' -} - -/** - * Validate email address format - */ -export function isValidEmail(email: string): boolean { - if (!email) return false - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - return emailRegex.test(email) -} - -/** - * Clean and normalize email address - */ -export function cleanEmail(email: string): string { - return extractEmailAddress(email).toLowerCase().trim() -} - -/** - * Parse email list (comma or semicolon separated) - */ -export function parseEmailList(input: string): string[] { - if (!input) return [] - - return input - .split(/[,;]/) - .map((email) => extractEmailAddress(email)) - .filter((email) => isValidEmail(email)) -} - -/** - * Format email address with optional name - */ -export function formatEmailAddress(email: string, name?: string): string { - if (name) { - return `"${name}" <${email}>` - } - return email -} - -/** - * Extract plain text from HTML email content - * Basic implementation - strips HTML tags - */ -export function htmlToText(html: string): string { - if (!html) return '' - - return ( - html - // Remove script and style tags with content - .replace(/]*>[\s\S]*?<\/script>/gi, '') - .replace(/]*>[\s\S]*?<\/style>/gi, '') - // Replace common block elements with newlines - .replace(/<\/?(div|p|br|h[1-6]|li|tr)[^>]*>/gi, '\n') - // Remove remaining HTML tags - .replace(/<[^>]+>/g, '') - // Decode common HTML entities - .replace(/ /gi, ' ') - .replace(/&/gi, '&') - .replace(/</gi, '<') - .replace(/>/gi, '>') - .replace(/"/gi, '"') - .replace(/'/gi, "'") - // Clean up whitespace - .replace(/\n\s*\n/g, '\n\n') - .trim() - ) -} - -/** - * Check if content is likely HTML - */ -export function isHtml(content: string): boolean { - if (!content) return false - return /<[a-z][\s\S]*>/i.test(content) -} - -/** - * Extract thread ID from References or In-Reply-To headers - */ -export function extractThreadId(references?: string | string[], inReplyTo?: string): string | undefined { - // Try references first (get the first/root message) - if (references) { - if (Array.isArray(references) && references.length > 0) { - return references[0] - } - if (typeof references === 'string') { - const refs = references.split(/\s+/).filter(Boolean) - if (refs.length > 0) return refs[0] - } - } - - // Fall back to In-Reply-To - if (inReplyTo) { - return inReplyTo - } - - return undefined -} - -/** - * Check if email subject indicates a reply - */ -export function isReplySubject(subject: string): boolean { - if (!subject) return false - const replyPrefixes = ['re:', 'r:', 'aw:', 'sv:', 'antw:', 'odp:'] - const lowerSubject = subject.toLowerCase().trim() - return replyPrefixes.some((prefix) => lowerSubject.startsWith(prefix)) -} - -/** - * Strip reply prefixes from subject - */ -export function stripReplyPrefix(subject: string): string { - if (!subject) return '' - return subject.replace(/^(re:|r:|aw:|sv:|antw:|odp:)\s*/i, '').trim() -} - -/** - * Add reply prefix to subject if not present - */ -export function addReplyPrefix(subject: string): string { - if (!subject) return 'Re:' - if (isReplySubject(subject)) return subject - return `Re: ${subject}` -} - -/** - * Generate a unique message ID - */ -export function generateMessageId(domain: string): string { - const timestamp = Date.now().toString(36) - const random = Math.random().toString(36).substring(2, 10) - return `<${timestamp}.${random}@${domain}>` -} - -/** - * Parse MIME content type - */ -export function parseMimeType(contentType: string): { - type: string - subtype: string - parameters: Record -} { - if (!contentType) { - return { type: 'text', subtype: 'plain', parameters: {} } - } - - const parts = contentType.split(';') - const [type, subtype] = (parts[0] || 'text/plain').split('/') - const parameters: Record = {} - - for (let i = 1; i < parts.length; i++) { - const param = parts[i].trim() - const eqIndex = param.indexOf('=') - if (eqIndex > 0) { - const key = param.substring(0, eqIndex).trim().toLowerCase() - let value = param.substring(eqIndex + 1).trim() - // Remove quotes - if (value.startsWith('"') && value.endsWith('"')) { - value = value.slice(1, -1) - } - parameters[key] = value - } - } - - return { - type: type?.toLowerCase() || 'text', - subtype: subtype?.toLowerCase() || 'plain', - parameters, - } -} - -/** - * Get file extension from MIME type - */ -export function mimeToExtension(mimeType: string): string { - const mimeMap: Record = { - 'text/plain': 'txt', - 'text/html': 'html', - 'text/css': 'css', - 'text/javascript': 'js', - 'application/json': 'json', - 'application/pdf': 'pdf', - 'application/zip': 'zip', - 'application/xml': 'xml', - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/gif': 'gif', - 'image/webp': 'webp', - 'image/svg+xml': 'svg', - 'audio/mpeg': 'mp3', - 'audio/wav': 'wav', - 'audio/ogg': 'ogg', - 'video/mp4': 'mp4', - 'video/webm': 'webm', - 'video/quicktime': 'mov', - } - - const { type, subtype } = parseMimeType(mimeType) - const fullType = `${type}/${subtype}` - - return mimeMap[fullType] || subtype || 'bin' -} diff --git a/packages/provider-email/tsconfig.json b/packages/provider-email/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/provider-email/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/provider-evolution-api/LICENSE.md b/packages/provider-evolution-api/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/provider-evolution-api/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/provider-evolution-api/README.md b/packages/provider-evolution-api/README.md deleted file mode 100644 index 906020f65..000000000 --- a/packages/provider-evolution-api/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/provider-meta

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/provider-evolution-api/__mock__/http.ts b/packages/provider-evolution-api/__mock__/http.ts deleted file mode 100644 index 61a8cf239..000000000 --- a/packages/provider-evolution-api/__mock__/http.ts +++ /dev/null @@ -1,5 +0,0 @@ -import axios from 'axios' -import * as sinon from 'sinon' -export const httpsMock = { - get: sinon.stub(axios, 'get'), -} diff --git a/packages/provider-evolution-api/__tests__/provider.test.ts b/packages/provider-evolution-api/__tests__/provider.test.ts deleted file mode 100644 index 0c6b57531..000000000 --- a/packages/provider-evolution-api/__tests__/provider.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Mock the @builderbot/bot dependency first -jest.mock('@builderbot/bot', () => { - return { - ProviderClass: class MockProvider { - server: any - constructor() { - this.server = { - use: jest.fn().mockReturnThis(), - post: jest.fn().mockReturnThis(), - } - } - emit = jest.fn() - middleware = jest.fn() - handleCtx = jest.fn() - }, - } -}) - -import { expect, describe, test, jest } from '@jest/globals' - -import { EvolutionProvider } from '../src/evolution/provider' - -// Mock all external dependencies -jest.mock('axios', () => ({ - get: jest.fn(() => Promise.resolve({ data: { state: 'open' } })), - post: jest.fn(() => Promise.resolve({ data: { id: 'message-id-123' } })), -})) - -jest.mock('../src/utils', () => ({ - generalDownload: jest.fn(() => Promise.resolve('/tmp/test-file.jpg')), -})) - -jest.mock('fs', () => ({ - readFileSync: jest.fn(() => 'base64_encoded_content'), - existsSync: jest.fn(() => true), -})) - -jest.mock('fs/promises', () => ({ - writeFile: jest.fn(() => Promise.resolve()), -})) - -jest.mock('mime-types', () => ({ - lookup: jest.fn(() => 'image/jpeg'), - contentType: jest.fn(() => 'image/jpeg'), -})) - -// Mock ffmpeg and related modules -jest.mock('@ffmpeg-installer/ffmpeg', () => ({ - path: '/mock/path/to/ffmpeg', -})) - -// Mock any potential FFmpeg/fluent-ffmpeg modules -jest.mock('fluent-ffmpeg', () => { - const mockFfmpeg = () => ({ - setFfmpegPath: jest.fn(), - on: jest.fn().mockReturnThis(), - save: jest.fn().mockReturnThis(), - run: jest.fn().mockReturnThis(), - }) - mockFfmpeg.setFfmpegPath = jest.fn() - return mockFfmpeg -}) - -// Mock other potential dependencies that might be causing issues -jest.mock('polka', () => ({ - default: jest.fn(() => ({ - use: jest.fn().mockReturnThis(), - listen: jest.fn().mockReturnThis(), - post: jest.fn().mockReturnThis(), - })), -})) - -jest.mock('queue-promise', () => { - return jest.fn().mockImplementation(() => ({ - add: jest.fn(), - on: jest.fn(), - })) -}) - -describe('EvolutionProvider', () => { - test('should initialize with correct vendor args', () => { - const provider = new EvolutionProvider({ - name: 'test-bot', - apiKey: 'test-api-key', - baseURL: 'http://localhost:8080', - instanceName: 'test-instance', - port: 3000, - }) - - expect(provider.globalVendorArgs).toEqual({ - name: 'test-bot', - apiKey: 'test-api-key', - baseURL: 'http://localhost:8080', - instanceName: 'test-instance', - port: 3000, - }) - }) - - test('should have correct structure', () => { - // Just check if the class is defined correctly - expect(EvolutionProvider).toBeDefined() - expect(typeof EvolutionProvider).toBe('function') - }) -}) diff --git a/packages/provider-evolution-api/__tests__/utils.test.ts b/packages/provider-evolution-api/__tests__/utils.test.ts deleted file mode 100644 index f5527ab71..000000000 --- a/packages/provider-evolution-api/__tests__/utils.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { expect, describe, test, jest } from '@jest/globals' -import * as os from 'os' -import * as path from 'path' - -// Mock implementations -jest.mock('follow-redirects', () => ({ - https: { - get: jest.fn((url, options, callback) => { - if (typeof callback === 'function') { - callback({ - pipe: jest.fn(), - headers: { 'content-type': 'image/jpeg' }, - }) - } - return { on: jest.fn() } - }), - }, - http: { - get: jest.fn(), - }, -})) - -jest.mock('fs', () => ({ - rename: jest.fn((from, to, callback) => { - if (typeof callback === 'function') callback(null) - }), - createWriteStream: jest.fn(() => ({ - close: jest.fn(), - on: jest.fn((event, callback) => { - if (event === 'finish' && typeof callback === 'function') callback() - return { on: jest.fn() } - }), - pipe: jest.fn(), - })), - existsSync: jest.fn((filepath: string) => filepath.includes('local')), -})) - -jest.mock('mime-types', () => ({ - extension: jest.fn(() => 'jpeg'), - contentType: jest.fn(() => 'image/jpeg'), -})) - -// Import after mocks -import { generalDownload } from '../src/utils/download' - -describe('Utils - generalDownload', () => { - test('should handle remote file downloads', async () => { - const result = await generalDownload('https://example.com/image.jpg') - expect(result).toBeDefined() - }) - - test('should handle local files', async () => { - const mockLocalPath = path.join(os.tmpdir(), 'local-image.jpg') - const result = await generalDownload(mockLocalPath) - expect(result).toBeDefined() - }) - - test('should use custom path when provided', async () => { - const customPath = '/custom/path' - const result = await generalDownload('https://example.com/image.png', customPath) - expect(result).toBeDefined() - }) - - test('should accept custom headers', async () => { - const customHeaders = { Authorization: 'Bearer token' } - const result = await generalDownload('https://example.com/image.gif', undefined, customHeaders) - expect(result).toBeDefined() - }) -}) diff --git a/packages/provider-evolution-api/jest.config.ts b/packages/provider-evolution-api/jest.config.ts deleted file mode 100644 index ca2c6a76f..000000000 --- a/packages/provider-evolution-api/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, -} - -export default config diff --git a/packages/provider-evolution-api/package.json b/packages/provider-evolution-api/package.json deleted file mode 100644 index 704d583df..000000000 --- a/packages/provider-evolution-api/package.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "name": "@builderbot/provider-evolution-api", - "version": "1.4.2-alpha.11", - "description": "> TODO: description", - "author": "aurik3 ", - "homepage": "https://github.com/aurik3/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/aurik3/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "bugs": { - "url": "https://github.com/aurik3/bot-whatsapp/issues" - }, - "dependencies": { - "@polka/parse": "1.0.0-next.0", - "axios": "^1.13.2", - "body-parser": "^2.2.1", - "file-type": "^19.0.0", - "follow-redirects": "^1.15.6", - "form-data": "^4.0.5", - "mime-types": "^3.0.2", - "polka": "^0.5.2", - "queue-promise": "^2.2.1" - }, - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@rollup/plugin-typescript": "^12.3.0", - "@rollup/pluginutils": "^5.3.0", - "@types/cors": "^2.8.17", - "@types/jest": "^30.0.0", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "@types/sinon": "^17.0.3", - "cors": "^2.8.5", - "jest": "^30.2.0", - "kleur": "^4.1.5", - "proxyquire": "^2.1.3", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "supertest": "^6.3.4", - "ts-jest": "^29.4.6", - "tslib": "^2.6.2", - "tsm": "^2.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-evolution-api/rollup.config.js b/packages/provider-evolution-api/rollup.config.js deleted file mode 100644 index a76268a10..000000000 --- a/packages/provider-evolution-api/rollup.config.js +++ /dev/null @@ -1,25 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import json from '@rollup/plugin-json' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - json(), - commonjs(), - nodeResolve({ - resolveOnly: (module) => - !/ffmpeg|baileys|@adiwajshing|link-preview-js|@builderbot\/bot|sharp/i.test(module), - }), - typescript(), - // terser() - ], -} diff --git a/packages/provider-evolution-api/src/evolution/core.ts b/packages/provider-evolution-api/src/evolution/core.ts deleted file mode 100644 index 53e0feddd..000000000 --- a/packages/provider-evolution-api/src/evolution/core.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { randomUUID } from 'node:crypto' -import EventEmitter from 'node:events' -import type polka from 'polka' -import type Queue from 'queue-promise' - -import type { EvolutionGlobalVendorArgs } from '../types' - -/** - * Genera un UUID único con un prefijo opcional. - * @param prefix - Prefijo opcional para el UUID. - * @returns Un identificador único (UUID v4). - */ -const generateRefProvider = (prefix?: string): string => { - const id = randomUUID() - return prefix ? `${prefix}_${id}` : id -} - -/** - * Elimina el dominio del JID de WhatsApp (e.g., "@s.whatsapp.net"). - * @param jid - JID completo. - * @returns El número limpio. - */ -const cleanJid = (jid: string): string => { - return jid?.split('@')[0] ?? '' -} - -type RawMessage = Record -type IncomingEvent = 'messages.upsert' | 'connection.update' | 'qrcode.updated' | 'logout.instance' | 'send.message' - -/** - * Class representing EvolutionCoreVendor, a vendor class for WhatsApp Business API integration. - * Handles webhook validation, message reception, and processing through Meta's Cloud API. - * @extends EventEmitter - */ -export class EvolutionCoreVendor extends EventEmitter { - /** - * Queue for handling asynchronous message processing - * @private - */ - private readonly queue: Queue - - /** - * Creates an instance of EvolutionCoreVendor. - * @param {Queue} _queue - The queue instance for managing message processing. - */ - constructor(_queue: Queue) { - super() - if (!_queue) { - throw new Error('Queue instance is required') - } - this.queue = _queue - } - - /** - * Middleware function for health check endpoint. - * Returns a simple response to verify the service is running. - * @type {polka.Middleware} - */ - public indexHome: polka.Middleware = (_, res) => { - try { - res.end('ok') - } catch (error) { - console.error('Error in indexHome middleware:', error) - res.statusCode = 500 - res.end('Internal server error') - } - } - - /** - * Middleware function for handling incoming webhook messages. - * Processes incoming messages from WhatsApp and adds them to the processing queue. - * @type {polka.Middleware} - */ - public incomingMsg: polka.Middleware = async (req: any, res: any) => { - try { - const globalVendorArgs: EvolutionGlobalVendorArgs = req['globalVendorArgs'] ?? null - if (!globalVendorArgs) { - res.statusCode = 400 - res.end('Missing vendor arguments') - return - } - - const { event, data }: { event: IncomingEvent; data: RawMessage } = req.body - - if (!req.body) { - res.statusCode = 400 - res.end('Invalid request body') - return - } - - switch (event) { - case 'messages.upsert': - if (data.message) { - const { message } = data - const from = cleanJid(data.key?.remoteJid) - const name = data.pushName - let responseObj: Record | null = null - - if (message.documentMessage) { - responseObj = { - type: data.messageType, - from, - mimetype: message.documentMessage.mimetype, - body: generateRefProvider('_event_document_'), - name, - caption: message.documentMessage.caption, - base64: message.base64, - } - } - else if (message.orderMessage) { - responseObj = { - from, - name, - body: generateRefProvider('_event_order_'), - } - } - else if (message.videoMessage) { - responseObj = { - type: data.messageType, - from, - mimetype: message.videoMessage.mimetype, - body: generateRefProvider('_event_media_'), - name, - caption: message.videoMessage.caption || '', - base64: message.base64, - } - } else if (message.imageMessage) { - responseObj = { - type: data.messageType, - from, - mimetype: message.imageMessage.mimetype, - body: generateRefProvider('_event_media_'), - name, - caption: message.imageMessage.caption || '', - base64: message.base64, - } - } else if (message.audioMessage) { - responseObj = { - type: data.messageType, - from, - mimetype: message.audioMessage.mimetype, - body: generateRefProvider('_event_voice_note_'), - name, - caption: message.audioMessage.caption || '', - base64: message.base64, - } - } else if (message.locationMessage || message.liveLocationMessage) { - responseObj = { - type: data.messageType, - from, - latitude: - message.locationMessage?.degreesLatitude ?? - message.liveLocationMessage?.degreesLatitude, - longitude: - message.locationMessage?.degreesLongitude ?? - message.liveLocationMessage?.degreesLongitude, - body: generateRefProvider('_event_location_'), - name, - } - } else if (message.conversation) { - responseObj = { - type: data.messageType, - from, - body: message.conversation, - name, - } - } - - if (responseObj) { - const enrichedMessage = { ...data, ...responseObj } - - await this.queue.enqueue(() => this.processMessage(enrichedMessage)) - } - } - } - res.statusCode = 200 - res.end('Message processed successfully') - - // Check for errors reported by Meta - } catch (error) { - console.error('Error processing incoming message:', error) - this.emit('notice', { - title: '🔔 EVOLUTION API ALERT 🔔', - instructions: [error.message || 'An error occurred while processing message.'], - }) - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - error: error.message || 'An error occurred while processing message.', - stack: process.env.NODE_ENV === 'development' ? error.stack : undefined, - }) - ) - } - } - - /** - * Procesa un mensaje entrante y lo emite al flujo del bot. - * @param message - Objeto de mensaje enriquecido. - */ - public processMessage = (message: any): Promise => { - return new Promise((resolve, reject) => { - try { - this.emit('message', message) - resolve() - } catch (error) { - reject(error) - } - }) - } -} diff --git a/packages/provider-evolution-api/src/evolution/provider.ts b/packages/provider-evolution-api/src/evolution/provider.ts deleted file mode 100644 index db0630c6e..000000000 --- a/packages/provider-evolution-api/src/evolution/provider.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { ProviderClass } from '@builderbot/bot' -import type { Vendor } from '@builderbot/bot/dist/provider/interface/provider' -import type { BotContext, SendOptions } from '@builderbot/bot/dist/types' -import { json } from '@polka/parse' -import axios, { AxiosError, AxiosResponse } from 'axios' -import fs, { createReadStream } from 'fs' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import { tmpdir } from 'os' -import path, { join, resolve } from 'path' -import { Middleware } from 'polka' -import type polka from 'polka' -import Queue from 'queue-promise' - -import type { EvolutionInterface } from '../interface/evolution' -import type { - EvolutionGlobalVendorArgs, - SaveFileOptions, - MediaMessage, - TextMessage, - ApiResponse, - MediaType, -} from '../types' -import { generalDownload } from '../utils' -import { EvolutionCoreVendor } from './core' - -// Maximum file size in bytes (10MB) -const MAX_FILE_SIZE = 10 * 1024 * 1024 -// Default timeout for API requests in ms -const DEFAULT_TIMEOUT = 30000 - -/** - * Evolution API Provider implementation - * Handles all communication with Evolution API for sending messages, media, etc. - */ -class EvolutionProvider extends ProviderClass implements EvolutionInterface { - public vendor: Vendor< - EvolutionInterface & { - indexHome: polka.Middleware - incomingMsg: polka.Middleware - } - > - public queue: Queue = new Queue() - public incomingMsg: (req: any, res: any) => void | Promise - - public globalVendorArgs: EvolutionGlobalVendorArgs = { - name: 'bot', - apiKey: '', - baseURL: 'http://localhost:8080', - instanceName: '', - port: 3000, - } - - /** - * Creates an instance of Evolution Provider - * @param args Provider configuration - * @throws Error if required configuration is missing - */ - constructor(args: EvolutionGlobalVendorArgs) { - super() - - // Validate required parameters - if (!args.apiKey) { - throw new Error('API Key is required') - } - if (!args.instanceName) { - throw new Error('Instance name is required') - } - - this.globalVendorArgs = { ...this.globalVendorArgs, ...args } - this.queue = new Queue({ - concurrent: 1, - interval: 100, - start: true, - }) - } - - sendMessageMeta: (body: any) => Promise - sendImageUrl: (to: string, url: string, mediaName?: string, caption?: string) => Promise - sendVideoUrl: (to: string, url: string, mediaName?: string, caption?: string) => Promise - sendAudioUrl: (to: string, url: string, mediaName?: string, caption?: string) => Promise - sendList: (to: string, list: any) => Promise - sendListComplete: (to: string, list: any) => Promise - indexHome?: Middleware - - /** - * Initialize HTTP server middleware - */ - protected beforeHttpServerInit(): void { - this.server = this.server - .use(json({ limit: '50mb' })) - .use((req, _, next) => { - req['globalVendorArgs'] = this.globalVendorArgs - return next() - }) - .post('/', this.vendor.indexHome) - .post('/webhook', this.vendor.incomingMsg) - } - - /** - * Initialize vendor core - * @returns Promise resolving to the vendor instance - */ - protected initVendor(): Promise { - const vendor = new EvolutionCoreVendor(this.queue) - this.vendor = vendor as unknown as Vendor< - EvolutionInterface & { indexHome: polka.Middleware; incomingMsg: polka.Middleware } - > - return Promise.resolve(this.vendor) - } - - /** - * Build standard headers for API requests - * @param additionalHeaders Optional additional headers to include - * @returns Headers object with apiKey - */ - private builderHeader(additionalHeaders: Record = {}): Record { - const { apiKey } = this.globalVendorArgs - return { - apikey: apiKey, - 'Content-Type': 'application/json', - ...additionalHeaders, - } - } - - /** - * Verify connection with Evolution API after HTTP server initialization - * @throws Error if connection fails - */ - protected async afterHttpServerInit(): Promise { - try { - const { baseURL, instanceName } = this.globalVendorArgs - - // Verify connection with Evolution API - const response = await axios.get(`${baseURL}/instance/connectionState/${instanceName}`, { - headers: this.builderHeader(), - timeout: DEFAULT_TIMEOUT, - }) - - const state = response.data?.state ?? response.data?.instance?.state ?? 'close' - if (state === 'open') { - this.emit('ready') - } else { - throw new Error(`Instance state: ${state}`) - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error' - this.emit('notice', { - title: '🟠 ERROR AUTH 🟠', - instructions: [ - 'Error connecting to Evolution API, please check your credentials', - 'Make sure your instance is connected', - `Details: ${errorMessage}`, - ], - }) - } - } - - /** - * Event bus configuration - * @returns Array of event handlers - */ - protected busEvents() { - return [ - { - event: 'auth_failure', - func: (payload: unknown) => this.emit('auth_failure', payload), - }, - { - event: 'notice', - func: ({ instructions, title }: { instructions: string[]; title: string }) => - this.emit('notice', { instructions, title }), - }, - { - event: 'ready', - func: () => this.emit('ready', true), - }, - { - event: 'message', - func: (payload: BotContext) => { - this.emit('message', payload) - }, - }, - ] - } - - /** - * Entry point for sending media files. - * Detects file type and routes to appropriate function. - * - * @param to Destination phone number - * @param file URL or path to media file - * @param type Optional media type description (unused but required by interface) - * @returns Promise resolving to API response - * @throws Error if file type cannot be determined or exceeds size limit - */ - sendMedia = async (to: string, file: string, type: string): Promise => { - try { - const fileDownloaded = await generalDownload(file) - - // Check file size - const stats = fs.statSync(fileDownloaded) - if (stats.size > MAX_FILE_SIZE) { - throw new Error(`File size exceeds limit of ${MAX_FILE_SIZE / (1024 * 1024)}MB`) - } - - const mimeType = mime.lookup(fileDownloaded) - - if (!mimeType) throw new Error('Could not determine MIME type') - - if (mimeType.includes('image')) { - return this.sendImage(to, fileDownloaded, type || '') - } - - if (mimeType.includes('video')) { - return this.sendVideo(to, fileDownloaded, type || '') - } - - if (mimeType.includes('audio')) { - return this.sendAudio(to, fileDownloaded) - } - - return this.sendFile(to, fileDownloaded, type || '') - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error sending media' - console.error('Error sending media:', errorMessage) - throw new Error(`Failed to send media: ${errorMessage}`) - } - } - - /** - * Convert file to base64 with size validation - * @param filePath Path to file - * @returns Base64 encoded file content - * @throws Error if file is too large - */ - private async fileToBase64(filePath: string): Promise { - const stats = fs.statSync(filePath) - if (stats.size > MAX_FILE_SIZE) { - throw new Error(`File size exceeds limit of ${MAX_FILE_SIZE / (1024 * 1024)}MB`) - } - return fs.readFileSync(filePath, { encoding: 'base64' }) - } - - /** - * Prepare media message body - * @param number Destination number - * @param filePath Media file path - * @param mediaType Type of media - * @param caption Optional caption - * @returns Prepared message body - */ - private async prepareMediaMessageBody( - number: string, - filePath: string, - mediaType: MediaType, - caption?: string - ): Promise { - const mediaBase64 = await this.fileToBase64(filePath) - const mimeType = mime.lookup(filePath) || 'application/octet-stream' - const fileName = path.basename(filePath) - - const body: MediaMessage = { - number, - media: mediaBase64, - mimetype: mimeType, - mediatype: mediaType, - caption: caption || fileName, - delay: 0, - } - - if (mediaType === 'document') { - body.fileName = fileName - } - - return body - } - - /** - * Send an image to the given number - * @param number Destination number - * @param filePath Local path to image - * @param caption Optional text caption - * @returns Promise resolving to API response - */ - sendImage = async (number: string, filePath: string, caption: string): Promise => { - try { - const body = await this.prepareMediaMessageBody(number, filePath, 'image', caption) - return this.sendMessageEvoApi(body, '/message/sendMedia/') - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error sending image' - console.error('Error sending image:', errorMessage) - throw new Error(`Failed to send image: ${errorMessage}`) - } - } - - /** - * Send a video to the given number - * @param to Destination number - * @param mediaUrl Local path to video - * @param caption Optional text caption - * @returns Promise resolving to API response - */ - sendVideo = async (to: string, mediaUrl: string, caption?: string): Promise => { - try { - const fileDownloaded = mediaUrl.startsWith('http') ? await generalDownload(mediaUrl) : mediaUrl - - const body = await this.prepareMediaMessageBody(to, fileDownloaded, 'video', caption) - return this.sendMessageEvoApi(body, '/message/sendMedia/') - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error sending video' - console.error('Error sending video:', errorMessage) - throw new Error(`Failed to send video: ${errorMessage}`) - } - } - - /** - * Send an audio file in compatible format (OPUS) - * @param to Destination number - * @param mediaUrl Local path to audio file - * @param mediaName Optional media name (unused but required by interface) - * @param caption Optional caption (unused for audio) - * @returns Promise resolving to API response - */ - sendAudio = async (to: string, mediaUrl: string, mediaName?: string, caption?: string): Promise => { - try { - const fileDownloaded = mediaUrl.startsWith('http') ? await generalDownload(mediaUrl) : mediaUrl - - const mediaBase64 = await this.fileToBase64(fileDownloaded) - - const body: MediaMessage = { - number: to, - media: mediaBase64, - mimetype: 'audio/ogg; codecs=opus', - mediatype: 'audio', - delay: 0, - } - - return this.sendMessageEvoApi(body, '/message/sendMedia/') - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error sending audio' - console.error('Error sending audio:', errorMessage) - throw new Error(`Failed to send audio: ${errorMessage}`) - } - } - - /** - * Send a generic document to the given number - * @param number Destination number - * @param filePath Local path to the file - * @param caption Optional text caption - * @returns Promise resolving to API response - */ - sendFile = async (number: string, filePath: string, caption: string): Promise => { - try { - const body = await this.prepareMediaMessageBody(number, filePath, 'document', caption) - return this.sendMessageEvoApi(body, '/message/sendMedia/') - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error sending file' - console.error('Error sending file:', errorMessage) - throw new Error(`Failed to send file: ${errorMessage}`) - } - } - - /** - * Send a plain text message - * @param number Destination number - * @param message Message content - * @returns Promise resolving to API response - */ - sendText = async (number: string, message: string): Promise => { - try { - const endpoint = '/message/sendText/' - - const body: TextMessage = { - number, - text: message, - delay: 0, - } - - return this.sendMessageEvoApi(body, endpoint) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error sending text' - console.error('Error sending text:', errorMessage) - throw new Error(`Failed to send text: ${errorMessage}`) - } - } - - /** - * General function for making POST requests to external API - * @param body Request body - * @param endpoint Relative endpoint path (optional) - * @returns Promise resolving to API response - * @throws Error if request fails - */ - sendMessageToApi = async (body: any, endpoint: string = '/message/'): Promise => { - const { baseURL, instanceName, apiKey } = this.globalVendorArgs - const url = `${baseURL}${endpoint}${instanceName}` - - try { - const response = await fetch(url, { - method: 'POST', - headers: this.builderHeader(), - body: JSON.stringify(body), - signal: AbortSignal.timeout(DEFAULT_TIMEOUT), - }) - - if (!response.ok) { - throw new Error(`Error sending message: ${response.statusText}`) - } - - const data = await response.json() - return data as K - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error in API request' - console.error(`API request failed (${endpoint}):`, message) - throw new Error(`API request failed: ${message}`) - } - } - - /** - * Queue a message send to ensure order and avoid conflicts - * @param body Message body - * @param endpoint API endpoint - * @returns Promise resolving to API response - */ - sendMessageEvoApi = (body: any, endpoint: string): Promise => { - return new Promise((resolve, reject) => - this.queue.add(async () => { - try { - const resp = await this.sendMessageToApi(body, endpoint) - resolve(resp) - } catch (error) { - reject(error) - } - }) - ) - } - - /** - * General router for sending messages. - * If it includes media, it's sent as a file. If not, as plain text. - * - * @param to Destination number - * @param message Text message - * @param args Additional options (media, etc.) - * @returns Promise resolving to API response - */ - async sendMessage(to: string, message: string, args?: any): Promise { - try { - // Sanitize phone number (remove non-numeric chars except +) - const sanitizedNumber = to.replace(/[^\d+]/g, '') - - // Process options - const options = args as SendOptions - const mergedOptions = { ...options, ...options?.['options'] } - - let response: ApiResponse - if (mergedOptions?.media) { - response = await this.sendMedia(sanitizedNumber, mergedOptions.media, mergedOptions.type || '') - } else { - response = await this.sendText(sanitizedNumber, message) - } - - return response as unknown as K - } catch (error) { - console.error('Error in sendMessage:', error) - throw error - } - } - - /** - * Save a file from context - * @param ctx Partial bot context - * @param options Save options - * @returns Promise resolving to the file path - * @throws Error if file cannot be saved - */ - saveFile = async (ctx: Partial, options: SaveFileOptions = {}): Promise => { - try { - if (!ctx.base64) { - throw new Error('No base64 data provided') - } - - const buffer = ctx.base64 - const extension = mime.extension(ctx.mimetype ?? 'application/octet-stream') as string - const fileName = `file-${Date.now()}.${extension}` - const pathFile = join(options?.path ?? tmpdir(), fileName) - - await writeFile(pathFile, buffer) - return resolve(pathFile) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error saving file' - console.error('Error saving file:', errorMessage) - return Promise.reject(new Error(`Failed to save file: ${errorMessage}`)) - } - } -} - -export { EvolutionProvider } diff --git a/packages/provider-evolution-api/src/index.ts b/packages/provider-evolution-api/src/index.ts deleted file mode 100644 index be528d5f4..000000000 --- a/packages/provider-evolution-api/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EvolutionProvider } from './evolution/provider' diff --git a/packages/provider-evolution-api/src/interface/evolution.ts b/packages/provider-evolution-api/src/interface/evolution.ts deleted file mode 100644 index 1a9572fb7..000000000 --- a/packages/provider-evolution-api/src/interface/evolution.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { BotContext } from '@builderbot/bot/dist/types' -import type polka from 'polka' - -import type { SaveFileOptions } from '../types' - -export interface EvolutionInterface { - // Interface elements - indexHome?: polka.Middleware - - // Queue related methods that are used in the implementation - sendMessageEvoApi: (body: any, ruta: string) => Promise - sendMessageMeta: (body: any) => Promise - sendMessageToApi: (body: any, ruta?: string) => Promise - - // Message sending methods - sendMessage: (to: string, message: string, args?: any) => Promise - sendText: (to: string, message: string, context?: string | null) => Promise - sendImage: (to: string, mediaInput: string, caption?: string, context?: string | null) => Promise - sendVideo: (to: string, mediaUrl: string, mediaName?: string, caption?: string) => Promise - sendAudio: (to: string, mediaUrl: string, mediaName?: string, caption?: string) => Promise - sendMedia: (to: string, file: string, type: string) => Promise - sendFile: (to: string, file: string, caption?: string) => Promise - incomingMsg: (req: any, res: any) => void | Promise -} diff --git a/packages/provider-evolution-api/src/types.ts b/packages/provider-evolution-api/src/types.ts deleted file mode 100644 index c9273f176..000000000 --- a/packages/provider-evolution-api/src/types.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { GlobalVendorArgs } from '@builderbot/bot/dist/types' - -// Core file type used by the provider -export class File { - mime_type?: string - sha256?: string - id?: string - voice?: boolean - animated?: boolean - filename?: string - caption?: string - link?: string -} - -export interface SaveFileOptions { - path?: string -} - -/** - * Possible types of media to send - */ -export type MediaType = 'image' | 'video' | 'audio' | 'document' - -/** - * Base message structure for API requests - */ -interface BaseMessage { - number: string - delay: number -} - -/** - * Structure for text messages - */ -export interface TextMessage extends BaseMessage { - text: string -} - -/** - * Structure for media messages - */ -export interface MediaMessage extends BaseMessage { - media: string - mimetype: string - mediatype: MediaType - caption?: string - fileName?: string -} - -/** - * Standard API response structure - */ -export interface ApiResponse { - key?: { - remoteJid?: string - fromMe?: boolean - id?: string - } - status?: string - message?: string - error?: boolean - [key: string]: any -} - -// Provider configuration -export interface EvolutionGlobalVendorArgs extends GlobalVendorArgs { - participant?: string -} - -/** - * Device metadata for message encryption - */ -export interface DeviceListMetadata { - senderKeyHash: string - senderTimestamp: string - recipientKeyHash: string - recipientTimestamp: string -} - -/** - * Context information for a message - */ -export interface MessageContextInfo { - deviceListMetadata: DeviceListMetadata - deviceListMetadataVersion: number - messageSecret: string -} - -/** - * Types of messages that can be received - */ -export type MessageType = - | 'conversation' - | 'imageMessage' - | 'videoMessage' - | 'audioMessage' - | 'documentMessage' - | 'stickerMessage' - | 'contactMessage' - | 'locationMessage' - | 'extendedTextMessage' - | 'buttonResponseMessage' - | 'templateButtonReplyMessage' - | 'listResponseMessage' - | 'reactionMessage' - -/** - * Possible sources for the webhook event - */ -export type MessageSource = 'android' | 'ios' | 'web' | 'desktop' | 'unknown' - -/** - * Possible message content types in a webhook - */ -export interface WebhookMessage { - conversation?: string - messageContextInfo?: MessageContextInfo - // Other message types can be added as needed -} - -/** - * Status of a message - */ -export type MessageStatus = 'PENDING' | 'SERVER_ACK' | 'DELIVERY_ACK' | 'READ' | 'PLAYED' - -/** - * Main data payload of the webhook event - */ -export interface WebhookEventData { - key: WebhookEvent - pushName: string - status: MessageStatus - message: WebhookMessage - messageType: MessageType - messageTimestamp: number - instanceId: string - source: MessageSource -} - -/** - * Event types supported by the webhook - */ -export type WebhookEventType = - | 'messages.upsert' - | 'messages.update' - | 'messages.delete' - | 'presence.update' - | 'contacts.update' - | 'groups.update' - | 'groups.upsert' - | 'group-participants.update' - | 'status.update' - -/** - * Complete webhook event structure - */ -export interface WebhookEvent { - event: WebhookEventType - instance: string - data: WebhookEventData - destination: string - date_time: string - sender: string - server_url: string - apikey: string -} diff --git a/packages/provider-evolution-api/src/utils/download.ts b/packages/provider-evolution-api/src/utils/download.ts deleted file mode 100644 index 2fc1d2e1a..000000000 --- a/packages/provider-evolution-api/src/utils/download.ts +++ /dev/null @@ -1,105 +0,0 @@ -import followRedirects from 'follow-redirects' -import { rename, createWriteStream, existsSync } from 'fs' -import type { IncomingMessage } from 'http' -import mimeTypes from 'mime-types' -import { tmpdir } from 'os' -import { extname, basename, parse, join } from 'path' - -const { http, https } = followRedirects - -/** - * Extraer el mimetype from buffer - * @param response - La respuesta HTTP - * @returns Un objeto con el tipo y la extensión del archivo - */ -const fileTypeFromFile = async (response: IncomingMessage): Promise<{ type: string | null; ext: string | false }> => { - const type = response.headers['content-type'] ?? '' - const ext = mimeTypes.extension(type) - return { - type, - ext, - } -} - -/** - * Descargar archivo binario en tmp - * @param url - La URL del archivo a descargar - * @returns La ruta al archivo descargado - */ -const generalDownload = async (url: string, pathToSave?: string, headers?: Record): Promise => { - const checkIsLocal = existsSync(url) - - const handleDownload = (): Promise<{ - response: IncomingMessage - fullPath: string - }> => { - try { - const checkProtocol = url.startsWith('http') - const handleHttp = checkProtocol ? https : http - const fileName = basename(checkProtocol ? new URL(url).pathname : url) - const name = parse(fileName).name - const fullPath = join(pathToSave ?? tmpdir(), name) - const file = createWriteStream(fullPath) - - if (checkIsLocal) { - /** - * From Local - */ - return new Promise((res) => { - const response = { - headers: { - 'content-type': mimeTypes.contentType(extname(url)) || '', - }, - } as unknown as IncomingMessage - res({ response, fullPath: url }) - }) - } else { - /** - * From URL - */ - return new Promise((res, rej) => { - const options = { - headers: headers ?? {}, - } - handleHttp.get(url, options, function (response) { - response.pipe(file) - file.on('finish', async function () { - file.close() - res({ response, fullPath }) - }) - file.on('error', function () { - file.close() - rej(new Error('Error downloading file')) - }) - }) - }) - } - } catch (err) { - console.error('Error downloading file', err) - return - } - } - - const handleFile = (pathInput: string, ext: string | false): Promise => { - return new Promise((resolve, reject) => { - if (!ext) { - reject(new Error('No extension found for the file')) - return - } - const fullPath = checkIsLocal ? `${pathInput}` : `${pathInput}.${ext}` - rename(pathInput, fullPath, (err) => { - if (err) reject(err) - resolve(fullPath) - }) - }) - } - - const httpResponse = await handleDownload() - const { ext } = await fileTypeFromFile(httpResponse.response) - if (!ext) throw new Error('Unable to determine file extension') - const getPath = await handleFile(httpResponse.fullPath, ext) - - return getPath -} - -export { generalDownload } diff --git a/packages/provider-evolution-api/src/utils/index.ts b/packages/provider-evolution-api/src/utils/index.ts deleted file mode 100644 index 802bc3cec..000000000 --- a/packages/provider-evolution-api/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { generalDownload } from './download' diff --git a/packages/provider-evolution-api/tsconfig.json b/packages/provider-evolution-api/tsconfig.json deleted file mode 100644 index 705da99f1..000000000 --- a/packages/provider-evolution-api/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"], - "paths": { - "~/*": ["./src/*"] - } - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/provider-facebook-messenger/package.json b/packages/provider-facebook-messenger/package.json index a5d884c30..284f9d606 100644 --- a/packages/provider-facebook-messenger/package.json +++ b/packages/provider-facebook-messenger/package.json @@ -1,57 +1,57 @@ { - "name": "@builderbot/provider-facebook-messenger", - "version": "1.4.2-alpha.11", - "description": "Provider for Facebook Messenger", - "keywords": [ - "facebook", - "messenger", - "chatbot", - "builderbot" - ], - "author": "", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "jest": "^30.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.4.6", - "typescript": "^5.9.3" - }, - "dependencies": { - "axios": "^1.13.2", - "mime-types": "^3.0.2", - "polka": "^0.5.2" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" + "name": "@japcon-bot/provider-facebook-messenger", + "version": "1.0.0", + "description": "Provider for Facebook Messenger", + "keywords": [ + "facebook", + "messenger", + "chatbot", + "builderbot" + ], + "author": "", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/polka": "^0.5.7", + "jest": "^30.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "ts-jest": "^29.4.6", + "typescript": "^5.9.3", + "@japcon-bot/bot": "workspace:^" + }, + "dependencies": { + "axios": "^1.13.2", + "mime-types": "^3.0.2", + "polka": "^0.5.2" + }, + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-gohighlevel/README.md b/packages/provider-gohighlevel/README.md deleted file mode 100644 index a174c4647..000000000 --- a/packages/provider-gohighlevel/README.md +++ /dev/null @@ -1,463 +0,0 @@ -# @builderbot/provider-gohighlevel - -GoHighLevel provider for BuilderBot - Supports SMS, WhatsApp, Email, Live Chat, Facebook, Instagram and Custom channels via GHL API v2. - -## Supported Channels - -| Channel | Type | -|---------|------| -| SMS | `SMS` | -| WhatsApp | `WhatsApp` | -| Email | `Email` | -| Live Chat | `Live_Chat` | -| Facebook | `Facebook` | -| Instagram | `Instagram` | -| Custom | `Custom` | - -## Installation - -```bash -pnpm add @builderbot/provider-gohighlevel -# or -npm install @builderbot/provider-gohighlevel -``` - -## Prerequisites - GoHighLevel Configuration - -Before using this provider, you need to configure your GoHighLevel App in the Marketplace. - -### Step 1: Create App in GHL Marketplace - -1. Go to [GHL Marketplace](https://marketplace.gohighlevel.com) -2. Click on **"Create App"** -3. Fill in the app details: - - App Name - - Description - - App Type: **Private** (for your own use) or **Public** - -### Step 2: Configure OAuth2 Scopes - -In your app settings, enable the following scopes: - -``` -conversations.readonly -conversations.write -conversations/message.readonly -conversations/message.write -``` - -### Step 3: Get Client Credentials - -After creating the app, you'll receive: -- **Client ID** - Your OAuth2 client identifier -- **Client Secret** - Your OAuth2 client secret (keep this secure!) - -### Step 4: Configure Redirect URI - -Set your Redirect URI to point to your server's OAuth callback endpoint: - -``` -https://your-domain.com/oauth/callback -``` - -For local development: -``` -http://localhost:3000/oauth/callback -``` - -### Step 5: Get Location ID - -1. Go to your GoHighLevel sub-account -2. Navigate to **Settings > Business Profile** -3. Copy the **Location ID** (also visible in the URL) - -Alternatively, find it in the URL when logged into a sub-account: -``` -https://app.gohighlevel.com/v2/location/YOUR_LOCATION_ID/... -``` - -## Environment Variables - -Create a `.env` file with your credentials: - -```env -# Required -GHL_CLIENT_ID=your_client_id_here -GHL_CLIENT_SECRET=your_client_secret_here -GHL_LOCATION_ID=your_location_id_here - -# Optional -GHL_REDIRECT_URI=http://localhost:3000/oauth/callback -GHL_WEBHOOK_SECRET=your_webhook_secret_for_hmac_verification -GHL_CHANNEL_TYPE=WhatsApp -``` - -## Basic Usage - -### Minimal Setup - -```typescript -import { createBot, createProvider, createFlow, addKeyword } from '@builderbot/bot' -import { GoHighLevelProvider } from '@builderbot/provider-gohighlevel' -import { MemoryDB } from '@builderbot/bot' - -// Create the provider -const provider = createProvider(GoHighLevelProvider, { - clientId: process.env.GHL_CLIENT_ID, - clientSecret: process.env.GHL_CLIENT_SECRET, - locationId: process.env.GHL_LOCATION_ID, - channelType: 'WhatsApp', - redirectUri: process.env.GHL_REDIRECT_URI, -}) - -// Create a simple flow -const welcomeFlow = addKeyword(['hello', 'hi']) - .addAnswer('Welcome! How can I help you today?') - -// Create and start the bot -const main = async () => { - await createBot({ - flow: createFlow([welcomeFlow]), - provider, - database: new MemoryDB(), - }) - - console.log('Bot is running!') -} - -main() -``` - -### With Webhook Signature Verification (Recommended for Production) - -```typescript -const provider = createProvider(GoHighLevelProvider, { - clientId: process.env.GHL_CLIENT_ID, - clientSecret: process.env.GHL_CLIENT_SECRET, - locationId: process.env.GHL_LOCATION_ID, - channelType: 'WhatsApp', - redirectUri: process.env.GHL_REDIRECT_URI, - webhookSecret: process.env.GHL_WEBHOOK_SECRET, // HMAC SHA256 verification -}) -``` - -### With Pre-existing Tokens - -If you already have access tokens (e.g., from a previous session): - -```typescript -const provider = createProvider(GoHighLevelProvider, { - clientId: process.env.GHL_CLIENT_ID, - clientSecret: process.env.GHL_CLIENT_SECRET, - locationId: process.env.GHL_LOCATION_ID, - channelType: 'WhatsApp', - accessToken: 'your_existing_access_token', - refreshToken: 'your_existing_refresh_token', -}) -``` - -## Webhook Configuration in GoHighLevel - -### Step 1: Get Your Webhook URL - -Once your bot is running, your webhook URL will be: - -``` -https://your-domain.com/webhook -``` - -For local development with ngrok: -```bash -ngrok http 3000 -# Use the ngrok URL: https://abc123.ngrok.io/webhook -``` - -### Step 2: Configure Webhook in GHL - -1. Go to **Settings > Integrations > Webhooks** in your GHL sub-account -2. Click **"Add Webhook"** -3. Configure: - - **Webhook URL**: `https://your-domain.com/webhook` - - **Events**: See table below -4. (Optional) Set a **Webhook Secret** for HMAC verification - -### Webhook Events - -#### Required Event - -| Event | Scope Required | Description | -|-------|----------------|-------------| -| **InboundMessage** | `conversations/message.readonly` | **REQUIRED** - Receives incoming messages from users | - -#### Optional Events (Recommended) - -| Event | Scope Required | Description | -|-------|----------------|-------------| -| OutboundMessage | `conversations/message.readonly` | Track outbound messages | -| ConversationUnreadUpdate | `conversations.readonly` | Know when there are unread conversations | - -### Step 3: Verify Webhook is Working - -Send a test message to your GHL number/channel. You should see the bot respond. - -## OAuth2 Authorization Flow - -``` -+--------+ +---------------+ -| |---(1) Authorization Request-->| GHL OAuth | -| User | | Server | -| |<--(2) Authorization Code------| | -+--------+ +---------------+ - | | - | | - v v -+--------+ +---------------+ -| |---(3) Exchange Code---------->| GHL OAuth | -| Bot | | Server | -| Server |<--(4) Access + Refresh Token--| | -+--------+ +---------------+ - | - | (5) Auto-refresh before expiry - v -``` - -### First-Time Authorization - -1. Start your bot server -2. If no valid token exists, the provider will emit a `notice` event with the authorization URL -3. Visit the URL and authorize the app -4. GHL redirects to `/oauth/callback` with an authorization code -5. The provider exchanges the code for access/refresh tokens -6. Tokens are automatically refreshed 5 minutes before expiry - -## Configuration Options - -| Option | Type | Required | Default | Description | -|--------|------|----------|---------|-------------| -| `clientId` | string | Yes | - | OAuth2 Client ID from GHL Marketplace | -| `clientSecret` | string | Yes | - | OAuth2 Client Secret from GHL Marketplace | -| `locationId` | string | Yes | - | GHL Location/Sub-account ID | -| `channelType` | string | No | `'SMS'` | Channel type: SMS, WhatsApp, Email, Live_Chat, Facebook, Instagram, Custom | -| `redirectUri` | string | No | - | OAuth2 callback URL | -| `webhookSecret` | string | No | - | Secret for HMAC SHA256 webhook verification | -| `accessToken` | string | No | - | Pre-existing access token | -| `refreshToken` | string | No | - | Pre-existing refresh token | -| `conversationProviderId` | string | No | - | Custom conversation provider ID | -| `versionId` | string | No | - | App version ID for OAuth authorization URL | -| `port` | number | No | `3000` | HTTP server port | -| `apiVersion` | string | No | `'2021-07-28'` | GHL API version | - -## Exposed Endpoints - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/` | Health check - returns "running ok" | -| GET | `/oauth/callback` | OAuth2 authorization callback | -| POST | `/webhook` | Incoming messages from GHL | - -## Handling Files/Media - -### Saving Received Files - -```typescript -const mediaFlow = addKeyword(['_event_media_']) - .addAction(async (ctx, { provider }) => { - // Save the received file - const filePath = await provider.saveFile(ctx, { - path: './downloads' - }) - - console.log('File saved to:', filePath) - }) -``` - -### Sending Media - -```typescript -const sendMediaFlow = addKeyword('send photo') - .addAnswer('Here is your image!', { - media: 'https://example.com/image.jpg' - }) -``` - -### Sending Local Files - -```typescript -const sendLocalFile = addKeyword('send document') - .addAnswer('Here is the document!', { - media: './files/document.pdf' - }) -``` - -## Provider Events - -Listen to provider events for monitoring and debugging: - -```typescript -provider.on('ready', () => { - console.log('Provider is ready and connected!') -}) - -provider.on('message', (ctx) => { - console.log('Message received:', ctx.body, 'from:', ctx.from) -}) - -provider.on('auth_failure', (payload) => { - console.error('Authentication failed:', payload) -}) - -provider.on('notice', ({ title, instructions }) => { - console.log(`[${title}]`, instructions.join('\n')) -}) - -provider.on('tokens_updated', (tokens) => { - // Optionally persist tokens for later use - console.log('Tokens updated, new expiry:', tokens.expires_in) -}) -``` - -## Sending Messages Programmatically - -```typescript -// Send text message -await provider.sendMessage('contact_phone_number', 'Hello!') - -// Send with buttons (rendered as numbered list) -await provider.sendMessage('contact_phone_number', 'Choose an option:', { - buttons: [ - { body: 'Option 1' }, - { body: 'Option 2' }, - { body: 'Option 3' }, - ] -}) - -// Send media -await provider.sendMessage('contact_phone_number', 'Check this out!', { - media: 'https://example.com/image.png' -}) -``` - -## Troubleshooting - -### Error: "clientId and clientSecret are required" - -**Cause**: Missing OAuth2 credentials. - -**Solution**: Ensure you've set `GHL_CLIENT_ID` and `GHL_CLIENT_SECRET` environment variables. - -### Error: "locationId is required" - -**Cause**: Missing GHL sub-account location ID. - -**Solution**: Set the `GHL_LOCATION_ID` environment variable with your sub-account's location ID. - -### Error: "Contact not found for phone: XXX" - -**Cause**: The phone number doesn't exist as a contact in GHL. - -**Solution**: -1. Ensure the contact exists in your GHL sub-account -2. Check the phone number format (should be digits only, e.g., `1234567890`) - -### Error: "Invalid webhook signature" - -**Cause**: Webhook signature verification failed. - -**Solution**: -1. Ensure `webhookSecret` matches the secret configured in GHL -2. Check that the webhook is sending the signature in the correct header - -### Error: "Missing webhook signature" - -**Cause**: Webhook secret is configured but GHL isn't sending a signature. - -**Solution**: -1. Configure the webhook secret in GHL's webhook settings -2. Or remove `webhookSecret` from your provider config if you don't need verification - -### Authorization URL Not Working - -**Cause**: Incorrect redirect URI configuration. - -**Solution**: -1. Ensure `redirectUri` matches exactly what's configured in GHL Marketplace -2. For local development, use `http://localhost:PORT/oauth/callback` - -## Complete Example - -```typescript -import { createBot, createProvider, createFlow, addKeyword, EVENTS } from '@builderbot/bot' -import { GoHighLevelProvider } from '@builderbot/provider-gohighlevel' -import { MemoryDB } from '@builderbot/bot' - -// Environment variables -const config = { - clientId: process.env.GHL_CLIENT_ID, - clientSecret: process.env.GHL_CLIENT_SECRET, - locationId: process.env.GHL_LOCATION_ID, - channelType: 'WhatsApp' as const, - redirectUri: process.env.GHL_REDIRECT_URI, - webhookSecret: process.env.GHL_WEBHOOK_SECRET, - port: 3000, -} - -// Create provider -const provider = createProvider(GoHighLevelProvider, config) - -// Flows -const welcomeFlow = addKeyword(['hello', 'hi', 'hola']) - .addAnswer('Welcome to our service!') - .addAnswer('How can I help you today?', { - buttons: [ - { body: 'Sales' }, - { body: 'Support' }, - { body: 'Information' }, - ] - }) - -const salesFlow = addKeyword(['sales', '1']) - .addAnswer('Our sales team will contact you shortly!') - -const supportFlow = addKeyword(['support', '2']) - .addAnswer('Please describe your issue and we will help you.') - -const mediaFlow = addKeyword([EVENTS.MEDIA]) - .addAction(async (ctx, { provider, flowDynamic }) => { - const filePath = await provider.saveFile(ctx, { path: './uploads' }) - await flowDynamic(`File received and saved: ${filePath}`) - }) - -// Main -const main = async () => { - const bot = await createBot({ - flow: createFlow([welcomeFlow, salesFlow, supportFlow, mediaFlow]), - provider, - database: new MemoryDB(), - }) - - // Event listeners - provider.on('ready', () => { - console.log('GoHighLevel bot is ready!') - }) - - provider.on('notice', ({ title, instructions }) => { - console.log(`[${title}]`) - instructions.forEach(i => console.log(` - ${i}`)) - }) - - console.log(`Server running on port ${config.port}`) -} - -main().catch(console.error) -``` - -## Useful Links - -- [GoHighLevel API Documentation](https://highlevel.stoplight.io/docs/integrations) -- [GHL Marketplace](https://marketplace.gohighlevel.com) -- [BuilderBot Documentation](https://builderbot.app) -- [BuilderBot GitHub](https://github.com/codigoencasa/builderbot) - -## License - -ISC diff --git a/packages/provider-gohighlevel/__tests__/core.test.ts b/packages/provider-gohighlevel/__tests__/core.test.ts deleted file mode 100644 index f1e1a55af..000000000 --- a/packages/provider-gohighlevel/__tests__/core.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals' -import { createHmac } from 'node:crypto' -import Queue from 'queue-promise' - -import { GoHighLevelCoreVendor } from '../src/gohighlevel/core' -import { GHLMessage } from '../src/types' -import { TokenManager } from '../src/utils/tokenManager' - -jest.mock('../src/utils/processIncomingMsg', () => ({ - processIncomingMessage: jest.fn(), -})) - -describe('#GoHighLevelCoreVendor', () => { - let coreVendor: GoHighLevelCoreVendor - let tokenManager: TokenManager - let mockNext: any - - beforeEach(() => { - const queue = new Queue({ concurrent: 1, interval: 100, start: true }) - tokenManager = new TokenManager('client_id', 'client_secret', 'http://localhost/callback') - coreVendor = new GoHighLevelCoreVendor(queue, tokenManager) - mockNext = jest.fn() - }) - - afterEach(() => { - jest.clearAllMocks() - tokenManager.destroy() - }) - - describe('#indexHome', () => { - test('should respond with "running ok"', () => { - const mockResponse = { end: jest.fn() } - - coreVendor.indexHome(null as any, mockResponse as any, mockNext) - - expect(mockResponse.end).toHaveBeenCalledWith('running ok') - }) - }) - - describe('#oauthCallback', () => { - test('should return 400 if no code provided', async () => { - const req = { query: {} } - const res = { statusCode: 0, end: jest.fn() } - - await coreVendor.oauthCallback(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(400) - expect(res.end).toHaveBeenCalledWith(JSON.stringify({ error: 'Missing authorization code' })) - }) - - test('should return 500 if token exchange fails', async () => { - const req = { query: { code: 'test_code' } } - const res = { statusCode: 0, end: jest.fn() } - - jest.spyOn(tokenManager, 'exchangeAuthorizationCode').mockRejectedValue(new Error('Exchange failed')) - - await coreVendor.oauthCallback(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(500) - expect(res.end).toHaveBeenCalledWith(JSON.stringify({ error: 'Failed to exchange authorization code' })) - }) - - test('should return 200 on successful token exchange', async () => { - const req = { query: { code: 'valid_code' } } - const res = { statusCode: 0, end: jest.fn() } - - jest.spyOn(tokenManager, 'exchangeAuthorizationCode').mockResolvedValue({ - access_token: 'token', - refresh_token: 'refresh', - token_type: 'Bearer', - expires_in: 86400, - scope: 'all', - locationId: 'loc_123', - }) - - await coreVendor.oauthCallback(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(200) - expect(res.end).toHaveBeenCalledWith( - JSON.stringify({ message: 'Authorization successful', locationId: 'loc_123' }) - ) - }) - }) - - describe('#incomingMsg', () => { - test('should return 400 for invalid webhook payload', async () => { - const req = { body: null } - const res = { statusCode: 0, end: jest.fn() } - - await coreVendor.incomingMsg(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(400) - expect(res.end).toHaveBeenCalledWith(JSON.stringify({ error: 'Invalid webhook payload' })) - }) - - test('should return 400 when payload has no type', async () => { - const req = { body: { locationId: 'loc_123' } } - const res = { statusCode: 0, end: jest.fn() } - - await coreVendor.incomingMsg(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(400) - }) - - test('should return 200 when message is processed', async () => { - const req = { - body: { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: 'Hello', - phone: '+1234567890', - messageId: 'msg_123', - }, - } - const res = { statusCode: 0, end: jest.fn() } - - const { processIncomingMessage } = require('../src/utils/processIncomingMsg') - ;(processIncomingMessage as jest.Mock).mockReturnValue({ - type: 'text', - from: '1234567890', - to: 'loc_123', - body: 'Hello', - name: 'Unknown', - pushName: 'Unknown', - }) - - await coreVendor.incomingMsg(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(200) - expect(res.end).toHaveBeenCalledWith(JSON.stringify({ success: true })) - }) - - test('should return 200 when processIncomingMessage returns null', async () => { - const req = { - body: { - type: 'OutboundMessage', - locationId: 'loc_123', - direction: 'outbound', - }, - } - const res = { statusCode: 0, end: jest.fn() } - - const { processIncomingMessage } = require('../src/utils/processIncomingMsg') - ;(processIncomingMessage as jest.Mock).mockReturnValue(null) - - await coreVendor.incomingMsg(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(200) - expect(res.end).toHaveBeenCalledWith(JSON.stringify({ success: true })) - }) - }) - - describe('#processMessage', () => { - test('should emit a "message" event and resolve', async () => { - const mockMessage: GHLMessage = { - type: 'text', - from: '1234567890', - to: 'loc_123', - body: 'Hello', - name: 'Test', - pushName: 'Test', - } - const mockEmit = jest.fn() - coreVendor.emit = mockEmit as any - - await coreVendor.processMessage(mockMessage) - - expect(mockEmit).toHaveBeenCalledWith('message', mockMessage) - }) - - test('should reject if emit throws', async () => { - const mockMessage: GHLMessage = { - type: 'text', - from: '1234567890', - to: 'loc_123', - body: 'Hello', - name: 'Test', - pushName: 'Test', - } - const mockEmitError = jest.fn(() => { - throw new Error('Emit error') - }) - coreVendor.emit = mockEmitError as any - - await expect(coreVendor.processMessage(mockMessage)).rejects.toThrow('Emit error') - }) - }) -}) - -describe('#GoHighLevelCoreVendor with webhook verification', () => { - const webhookSecret = 'test_webhook_secret' - let coreVendorWithSecret: GoHighLevelCoreVendor - let tokenManager: TokenManager - let mockNext: any - - beforeEach(() => { - const queue = new Queue({ concurrent: 1, interval: 100, start: true }) - tokenManager = new TokenManager('client_id', 'client_secret', 'http://localhost/callback') - coreVendorWithSecret = new GoHighLevelCoreVendor(queue, tokenManager, webhookSecret) - mockNext = jest.fn() - }) - - afterEach(() => { - jest.clearAllMocks() - tokenManager.destroy() - }) - - describe('#incomingMsg with signature verification', () => { - test('should return 401 when signature is missing', async () => { - const body = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: 'Hello', - } - const req = { - body, - headers: {}, - rawBody: JSON.stringify(body), - } - const res = { statusCode: 0, end: jest.fn() } - - await coreVendorWithSecret.incomingMsg(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(401) - expect(res.end).toHaveBeenCalledWith(JSON.stringify({ error: 'Missing webhook signature' })) - }) - - test('should return 401 when signature is invalid', async () => { - const body = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: 'Hello', - } - const req = { - body, - headers: { 'x-ghl-signature': 'invalid_signature' }, - rawBody: JSON.stringify(body), - } - const res = { statusCode: 0, end: jest.fn() } - - await coreVendorWithSecret.incomingMsg(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(401) - expect(res.end).toHaveBeenCalledWith(JSON.stringify({ error: 'Invalid webhook signature' })) - }) - - test('should process message when signature is valid', async () => { - const body = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: 'Hello', - phone: '+1234567890', - } - const rawBody = JSON.stringify(body) - const validSignature = createHmac('sha256', webhookSecret).update(rawBody).digest('hex') - - const req = { - body, - headers: { 'x-ghl-signature': validSignature }, - rawBody, - } - const res = { statusCode: 0, end: jest.fn() } - - const { processIncomingMessage } = require('../src/utils/processIncomingMsg') - ;(processIncomingMessage as jest.Mock).mockReturnValue({ - type: 'text', - from: '1234567890', - to: 'loc_123', - body: 'Hello', - name: 'Test', - pushName: 'Test', - }) - - await coreVendorWithSecret.incomingMsg(req as any, res as any, mockNext) - - expect(res.statusCode).toBe(200) - expect(res.end).toHaveBeenCalledWith(JSON.stringify({ success: true })) - }) - - test('should emit notice event when signature is missing', async () => { - const body = { type: 'InboundMessage', locationId: 'loc_123' } - const req = { - body, - headers: {}, - rawBody: JSON.stringify(body), - } - const res = { statusCode: 0, end: jest.fn() } - const mockEmit = jest.fn() - coreVendorWithSecret.emit = mockEmit as any - - await coreVendorWithSecret.incomingMsg(req as any, res as any, mockNext) - - expect(mockEmit).toHaveBeenCalledWith('notice', { - title: 'GHL WEBHOOK WARNING', - instructions: ['Webhook signature missing from request headers'], - }) - }) - - test('should emit notice event when signature is invalid', async () => { - const body = { type: 'InboundMessage', locationId: 'loc_123' } - const req = { - body, - headers: { 'x-ghl-signature': 'wrong_signature' }, - rawBody: JSON.stringify(body), - } - const res = { statusCode: 0, end: jest.fn() } - const mockEmit = jest.fn() - coreVendorWithSecret.emit = mockEmit as any - - await coreVendorWithSecret.incomingMsg(req as any, res as any, mockNext) - - expect(mockEmit).toHaveBeenCalledWith('notice', { - title: 'GHL WEBHOOK WARNING', - instructions: ['Invalid webhook signature - request rejected'], - }) - }) - }) -}) diff --git a/packages/provider-gohighlevel/__tests__/provider.test.ts b/packages/provider-gohighlevel/__tests__/provider.test.ts deleted file mode 100644 index 92939495b..000000000 --- a/packages/provider-gohighlevel/__tests__/provider.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import axios from 'axios' - -import { GoHighLevelProvider } from '../src/gohighlevel/provider' -import { GHLGlobalVendorArgs } from '../src/types' - -jest.mock('axios') -jest.mock('fs/promises', () => ({ - writeFile: jest.fn(), -})) -jest.mock('@builderbot/bot') - -const globalVendorArgs: GHLGlobalVendorArgs = { - name: 'bot', - clientId: 'test_client_id', - clientSecret: 'test_client_secret', - locationId: 'test_location_id', - channelType: 'SMS', - apiVersion: '2021-07-28', - port: 3000, - writeMyself: 'none', - accessToken: 'test_access_token', - refreshToken: 'test_refresh_token', -} - -describe('#GoHighLevelProvider', () => { - let provider: GoHighLevelProvider - - beforeEach(() => { - jest.clearAllMocks() - provider = new GoHighLevelProvider(globalVendorArgs) - }) - - describe('#constructor', () => { - test('should initialize globalVendorArgs correctly', () => { - expect(provider.globalVendorArgs.clientId).toBe('test_client_id') - expect(provider.globalVendorArgs.clientSecret).toBe('test_client_secret') - expect(provider.globalVendorArgs.locationId).toBe('test_location_id') - expect(provider.globalVendorArgs.channelType).toBe('SMS') - expect(provider.globalVendorArgs.apiVersion).toBe('2021-07-28') - }) - - test('should initialize tokenManager with correct credentials', () => { - expect(provider.tokenManager).toBeDefined() - expect(provider.tokenManager.getAccessToken()).toBe('test_access_token') - expect(provider.tokenManager.getRefreshToken()).toBe('test_refresh_token') - }) - - test('should initialize queue', () => { - expect(provider.queue).toBeDefined() - }) - - test('should initialize contactResolver', () => { - expect(provider.contactResolver).toBeDefined() - }) - - test('should throw if clientId is missing', () => { - expect( - () => - new GoHighLevelProvider({ - ...globalVendorArgs, - clientId: '', - }) - ).toThrow('[GoHighLevel] clientId and clientSecret are required') - }) - - test('should throw if clientSecret is missing', () => { - expect( - () => - new GoHighLevelProvider({ - ...globalVendorArgs, - clientSecret: '', - }) - ).toThrow('[GoHighLevel] clientId and clientSecret are required') - }) - - test('should throw if locationId is missing', () => { - expect( - () => - new GoHighLevelProvider({ - ...globalVendorArgs, - locationId: '', - }) - ).toThrow('[GoHighLevel] locationId is required') - }) - }) - - describe('#getAuthorizationUrl', () => { - test('should return a valid authorization URL', () => { - const url = provider.getAuthorizationUrl() - expect(url).toContain('marketplace.gohighlevel.com/oauth/chooselocation') - expect(url).toContain('client_id=test_client_id') - expect(url).toContain('response_type=code') - }) - - test('should include version_id when versionId is provided', () => { - const providerWithVersion = new GoHighLevelProvider({ - ...globalVendorArgs, - versionId: '69806f1da2860d5ef04802d2', - }) - const url = providerWithVersion.getAuthorizationUrl() - expect(url).toContain('version_id=69806f1da2860d5ef04802d2') - }) - }) - - describe('#sendMessageToApi', () => { - test('should send message to GHL API and return response data', async () => { - const fakeBody = { - type: 'SMS' as const, - contactId: 'contact_123', - message: 'Hello, World!', - } - const fakeResponseData = { messageId: '123456' } - ;(axios.post as jest.MockedFunction).mockResolvedValue({ - data: fakeResponseData, - }) - - const responseData = await provider.sendMessageToApi(fakeBody) - - expect(axios.post).toHaveBeenCalledWith( - 'https://services.leadconnectorhq.com/conversations/messages', - fakeBody, - { - headers: { - Authorization: 'Bearer test_access_token', - Version: '2021-07-28', - 'Content-Type': 'application/json', - }, - } - ) - expect(responseData).toEqual(fakeResponseData) - }) - - test('should throw when API call fails', async () => { - const fakeBody = { - type: 'SMS' as const, - contactId: 'contact_123', - message: 'Hello!', - } - const error = new Error('Network error') - ;(axios.post as jest.MockedFunction).mockRejectedValue(error) - - await expect(provider.sendMessageToApi(fakeBody)).rejects.toThrow('Network error') - }) - }) - - describe('#sendText', () => { - test('should resolve contactId and send text message via queue', async () => { - jest.spyOn(provider, 'resolveContactId').mockResolvedValue('contact_123') - jest.spyOn(provider, 'sendMessageGHL').mockResolvedValue({ success: true }) - - await provider.sendText('1234567890', 'Hello, World!') - - expect(provider.resolveContactId).toHaveBeenCalledWith('1234567890') - expect(provider.sendMessageGHL).toHaveBeenCalledWith({ - type: 'SMS', - contactId: 'contact_123', - message: 'Hello, World!', - }) - }) - - test('should throw error when contact not found', async () => { - jest.spyOn(provider, 'resolveContactId').mockResolvedValue(null) - - await expect(provider.sendText('0000000000', 'Hello')).rejects.toThrow('Contact not found for: 0000000000') - }) - }) - - describe('#sendButtons', () => { - test('should format buttons as text and send via sendText', async () => { - jest.spyOn(provider, 'sendText').mockResolvedValue({ success: true }) - - const buttons = [{ body: 'Option 1' }, { body: 'Option 2' }] - await provider.sendButtons('1234567890', buttons, 'Choose an option:') - - expect(provider.sendText).toHaveBeenCalledWith( - '1234567890', - 'Choose an option:\n\n1. Option 1\n2. Option 2' - ) - }) - }) - - describe('#sendMessage', () => { - test('should send text message when no options provided', async () => { - jest.spyOn(provider, 'sendText').mockResolvedValue({ success: true }) - jest.spyOn(provider, 'sendButtons') - jest.spyOn(provider, 'sendMedia') - - await provider.sendMessage('1234567890', 'Hello!', {}) - - expect(provider.sendText).toHaveBeenCalledWith('1234567890', 'Hello!') - expect(provider.sendButtons).not.toHaveBeenCalled() - expect(provider.sendMedia).not.toHaveBeenCalled() - }) - - test('should send buttons when options.buttons is provided', async () => { - jest.spyOn(provider, 'sendButtons').mockResolvedValue({ success: true }) - jest.spyOn(provider, 'sendText') - jest.spyOn(provider, 'sendMedia') - - const buttons = [{ body: 'Yes' }, { body: 'No' }] - await provider.sendMessage('1234567890', 'Confirm?', { buttons }) - - expect(provider.sendButtons).toHaveBeenCalledWith('1234567890', buttons, 'Confirm?') - expect(provider.sendText).not.toHaveBeenCalled() - expect(provider.sendMedia).not.toHaveBeenCalled() - }) - - test('should send media when options.media is provided', async () => { - jest.spyOn(provider, 'sendMedia').mockResolvedValue({ success: true }) - jest.spyOn(provider, 'sendText') - jest.spyOn(provider, 'sendButtons') - - await provider.sendMessage('1234567890', 'Check this', { - media: 'https://example.com/image.jpg', - }) - - expect(provider.sendMedia).toHaveBeenCalledWith('1234567890', 'Check this', 'https://example.com/image.jpg') - expect(provider.sendText).not.toHaveBeenCalled() - expect(provider.sendButtons).not.toHaveBeenCalled() - }) - }) - - describe('#sendMessageGHL', () => { - test('should add message to queue', () => { - const fakeBody = { - type: 'SMS' as const, - contactId: 'contact_123', - message: 'Hello!', - } - const mockQueueAdd = jest.fn() - provider.queue.add = mockQueueAdd - - provider.sendMessageGHL(fakeBody) - - expect(provider.queue.add).toHaveBeenCalled() - }) - }) - - describe('#busEvents', () => { - test('#auth_failure - should emit the correct event', () => { - const payload = { message: 'Test' } - const mockEmit = jest.fn() - provider.emit = mockEmit - - provider.busEvents()[0].func(payload) - - expect(mockEmit).toHaveBeenCalledWith('auth_failure', payload) - }) - - test('#notice - should emit the correct event', () => { - const payload = { instructions: ['Test instruction'], title: 'Test title' } - const mockEmit = jest.fn() - provider.emit = mockEmit - - provider.busEvents()[1].func(payload) - - expect(mockEmit).toHaveBeenCalledWith('notice', payload) - }) - - test('#ready - should emit the correct event', () => { - const mockEmit = jest.fn() - provider.emit = mockEmit - - provider.busEvents()[2].func({} as any) - - expect(mockEmit).toHaveBeenCalledWith('ready', true) - }) - - test('#message - should emit the correct event', () => { - const payload = { body: 'Hello', from: '123456789' } - const mockEmit = jest.fn() - provider.emit = mockEmit - - provider.busEvents()[3].func(payload as any) - - expect(mockEmit).toHaveBeenCalledWith('message', payload) - }) - - test('#host - should emit the correct event', () => { - const payload = { locationId: 'test_location' } - const mockEmit = jest.fn() - provider.emit = mockEmit - - provider.busEvents()[4].func(payload) - - expect(mockEmit).toHaveBeenCalledWith('host', payload) - }) - - test('#tokens_updated - should update globalVendorArgs tokens', () => { - const payload = { - access_token: 'new_access_token', - refresh_token: 'new_refresh_token', - } - - provider.busEvents()[5].func(payload) - - expect(provider.globalVendorArgs.accessToken).toBe('new_access_token') - expect(provider.globalVendorArgs.refreshToken).toBe('new_refresh_token') - }) - }) - - describe('#saveFile', () => { - test('should return ERROR when no URL found in context', async () => { - const ctx = {} - const result = await provider.saveFile(ctx) - expect(result).toBe('ERROR') - }) - }) - - describe('#resolveContactId', () => { - test('should call contactResolver with correct params', async () => { - jest.spyOn(provider.contactResolver, 'resolveContactId').mockResolvedValue('contact_123') - - const result = await provider.resolveContactId('+1 234-567-890') - - expect(provider.contactResolver.resolveContactId).toHaveBeenCalledWith( - '1234567890', - 'test_location_id', - 'test_access_token' - ) - expect(result).toBe('contact_123') - }) - }) - - describe('#stop', () => { - test('should destroy tokenManager and clear cache', () => { - jest.spyOn(provider.tokenManager, 'destroy') - jest.spyOn(provider.contactResolver, 'clearCache') - - provider.stop() - - expect(provider.tokenManager.destroy).toHaveBeenCalled() - expect(provider.contactResolver.clearCache).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/provider-gohighlevel/__tests__/utils.test.ts b/packages/provider-gohighlevel/__tests__/utils.test.ts deleted file mode 100644 index fb3af2e98..000000000 --- a/packages/provider-gohighlevel/__tests__/utils.test.ts +++ /dev/null @@ -1,621 +0,0 @@ -import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals' -import axios from 'axios' - -import { GHLIncomingWebhook } from '../src/types' -import { ContactResolver } from '../src/utils/contactResolver' -import { downloadFile, fileTypeFromResponse } from '../src/utils/downloadFile' -import { parseGHLNumber } from '../src/utils/number' -import { processIncomingMessage } from '../src/utils/processIncomingMsg' -import { TokenManager } from '../src/utils/tokenManager' -import { verifyWebhookSignature, extractSignatureFromHeaders } from '../src/utils/webhookVerification' - -jest.mock('axios') -jest.mock('@builderbot/bot', () => ({ - utils: { - generateRefProvider: jest.fn((type: string) => `__ref_provider_${type}__`), - }, -})) - -describe('#parseGHLNumber', () => { - test('should remove + symbol from number', () => { - expect(parseGHLNumber('+1234567890')).toBe('1234567890') - }) - - test('should remove spaces from number', () => { - expect(parseGHLNumber('1 234 567 890')).toBe('1234567890') - }) - - test('should remove dashes from number', () => { - expect(parseGHLNumber('1-234-567-890')).toBe('1234567890') - }) - - test('should handle combined formatting with parentheses', () => { - expect(parseGHLNumber('+1 (234) 567-890')).toBe('1234567890') - }) - - test('should remove all non-numeric characters', () => { - expect(parseGHLNumber('+1.234.567.890')).toBe('1234567890') - expect(parseGHLNumber('(123) 456-7890')).toBe('1234567890') - }) - - test('should return non-string values as-is', () => { - expect(parseGHLNumber(12345 as any)).toBe(12345) - }) -}) - -describe('#processIncomingMessage', () => { - test('should return null for null input', () => { - expect(processIncomingMessage(null as any)).toBeNull() - }) - - test('should return null for outbound messages', () => { - const webhook: GHLIncomingWebhook = { - type: 'OutboundMessage', - locationId: 'loc_123', - direction: 'outbound', - } - expect(processIncomingMessage(webhook)).toBeNull() - }) - - test('should process inbound text message', () => { - const webhook: GHLIncomingWebhook = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: 'Hello World', - phone: '+1234567890', - contactId: 'contact_123', - conversationId: 'conv_123', - messageId: 'msg_123', - dateAdded: '2025-01-01T00:00:00.000Z', - } - - const result = processIncomingMessage(webhook) - - expect(result).not.toBeNull() - expect(result!.type).toBe('text') - expect(result!.body).toBe('Hello World') - // contactId is prioritized over phone for 'from' field - expect(result!.from).toBe('contact_123') - expect(result!.to).toBe('loc_123') - expect(result!.contactId).toBe('contact_123') - expect(result!.conversationId).toBe('conv_123') - expect(result!.message_id).toBe('msg_123') - }) - - test('should process inbound message with image attachment', () => { - const webhook: GHLIncomingWebhook = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: '', - phone: '+1234567890', - contactId: 'contact_123', - messageId: 'msg_456', - attachments: [{ url: 'https://example.com/image.jpg', type: 'image/jpeg' }], - } - - const result = processIncomingMessage(webhook) - - expect(result).not.toBeNull() - expect(result!.type).toBe('image') - expect(result!.url).toBe('https://example.com/image.jpg') - expect(result!.attachments).toHaveLength(1) - }) - - test('should process inbound message with video attachment', () => { - const webhook: GHLIncomingWebhook = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: '', - phone: '+1234567890', - contactId: 'contact_123', - messageId: 'msg_789', - attachments: [{ url: 'https://example.com/video.mp4', type: 'video/mp4' }], - } - - const result = processIncomingMessage(webhook) - - expect(result).not.toBeNull() - expect(result!.type).toBe('video') - expect(result!.url).toBe('https://example.com/video.mp4') - }) - - test('should process inbound message with audio attachment', () => { - const webhook: GHLIncomingWebhook = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: '', - phone: '+1234567890', - contactId: 'contact_123', - messageId: 'msg_audio', - attachments: [{ url: 'https://example.com/audio.mp3', type: 'audio/mp3' }], - } - - const result = processIncomingMessage(webhook) - - expect(result).not.toBeNull() - expect(result!.type).toBe('audio') - }) - - test('should process inbound message with document attachment', () => { - const webhook: GHLIncomingWebhook = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: '', - phone: '+1234567890', - contactId: 'contact_123', - messageId: 'msg_doc', - attachments: [{ url: 'https://example.com/file.pdf', type: 'application/pdf' }], - } - - const result = processIncomingMessage(webhook) - - expect(result).not.toBeNull() - expect(result!.type).toBe('document') - }) - - test('should use contactId as name fallback', () => { - const webhook: GHLIncomingWebhook = { - type: 'InboundMessage', - locationId: 'loc_123', - direction: 'inbound', - body: 'Hi', - phone: '+1234567890', - contactId: 'contact_ABC', - messageId: 'msg_name', - } - - const result = processIncomingMessage(webhook) - - expect(result!.name).toBe('contact_ABC') - expect(result!.pushName).toBe('contact_ABC') - }) -}) - -describe('#TokenManager', () => { - let tokenManager: TokenManager - - beforeEach(() => { - tokenManager = new TokenManager('client_id', 'client_secret', 'http://localhost/callback') - }) - - afterEach(() => { - tokenManager.destroy() - }) - - test('should initialize with empty tokens', () => { - expect(tokenManager.getAccessToken()).toBe('') - expect(tokenManager.getRefreshToken()).toBe('') - }) - - test('should set tokens correctly', () => { - tokenManager.setTokens({ - access_token: 'test_token', - refresh_token: 'test_refresh', - expires_in: 86400, - }) - - expect(tokenManager.getAccessToken()).toBe('test_token') - expect(tokenManager.getRefreshToken()).toBe('test_refresh') - }) - - test('should report token as not expired after setting', () => { - tokenManager.setTokens({ - access_token: 'test_token', - expires_in: 86400, - }) - - expect(tokenManager.isTokenExpired()).toBe(false) - }) - - test('should report token as expired when no token set', () => { - expect(tokenManager.isTokenExpired()).toBe(true) - }) - - test('should return access token from getValidToken when not expired', async () => { - tokenManager.setTokens({ - access_token: 'valid_token', - expires_in: 86400, - }) - - const token = await tokenManager.getValidToken() - expect(token).toBe('valid_token') - }) - - test('should throw error on refreshAccessToken when no refresh token', async () => { - await expect(tokenManager.refreshAccessToken()).rejects.toThrow('No refresh token available') - }) - - test('destroy should clear refresh timer', () => { - tokenManager.setTokens({ - access_token: 'test', - expires_in: 86400, - }) - - tokenManager.destroy() - // Should not throw - tokenManager.destroy() - }) -}) - -describe('#fileTypeFromResponse', () => { - test('should extract type and extension from content-type header', () => { - const mockResponse = { - headers: { 'content-type': 'image/jpeg' }, - data: Buffer.from('test'), - } - - const result = fileTypeFromResponse(mockResponse as any) - - expect(result.type).toBe('image/jpeg') - // mime-types returns 'jpg' for image/jpeg - expect(result.ext).toBe('jpg') - }) - - test('should handle content-type with charset', () => { - const mockResponse = { - headers: { 'content-type': 'text/plain; charset=utf-8' }, - data: Buffer.from('test'), - } - - const result = fileTypeFromResponse(mockResponse as any) - - expect(result.type).toBe('text/plain; charset=utf-8') - expect(result.ext).toBe('txt') - }) - - test('should return false for unknown content-type', () => { - const mockResponse = { - headers: { 'content-type': 'application/x-unknown' }, - data: Buffer.from('test'), - } - - const result = fileTypeFromResponse(mockResponse as any) - - expect(result.ext).toBe(false) - }) - - test('should handle missing content-type header', () => { - const mockResponse = { - headers: {}, - data: Buffer.from('test'), - } - - const result = fileTypeFromResponse(mockResponse as any) - - expect(result.type).toBe('') - expect(result.ext).toBe(false) - }) -}) - -describe('#downloadFile', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - test('should download file and return buffer with extension', async () => { - const mockBuffer = Buffer.from('file content') - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - headers: { 'content-type': 'image/png' }, - data: mockBuffer, - }) - - const result = await downloadFile('https://example.com/image.png', 'test_token') - - expect(axios.get).toHaveBeenCalledWith('https://example.com/image.png', { - headers: { Authorization: 'Bearer test_token' }, - maxBodyLength: Infinity, - responseType: 'arraybuffer', - }) - expect(result.buffer).toBe(mockBuffer) - expect(result.extension).toBe('png') - }) - - test('should throw error when extension cannot be determined', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - headers: { 'content-type': 'application/x-unknown' }, - data: Buffer.from('data'), - }) - - await expect(downloadFile('https://example.com/file', 'token')).rejects.toThrow( - 'Unable to determine file extension' - ) - }) - - test('should throw error when axios fails', async () => { - ;(axios.get as jest.MockedFunction).mockRejectedValue(new Error('Network error')) - - await expect(downloadFile('https://example.com/file.jpg', 'token')).rejects.toThrow('Network error') - }) - - test('should handle PDF content-type', async () => { - const mockBuffer = Buffer.from('pdf content') - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - headers: { 'content-type': 'application/pdf' }, - data: mockBuffer, - }) - - const result = await downloadFile('https://example.com/doc.pdf', 'token') - - expect(result.extension).toBe('pdf') - }) - - test('should handle audio content-type', async () => { - const mockBuffer = Buffer.from('audio content') - // Use audio/mp3 which returns 'mp3' extension - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - headers: { 'content-type': 'audio/mp3' }, - data: mockBuffer, - }) - - const result = await downloadFile('https://example.com/audio.mp3', 'token') - - expect(result.extension).toBe('mp3') - }) -}) - -describe('#ContactResolver', () => { - let contactResolver: ContactResolver - - beforeEach(() => { - jest.clearAllMocks() - contactResolver = new ContactResolver('2021-07-28', 1000) // 1 second TTL for tests - }) - - test('should initialize with default apiVersion', () => { - const resolver = new ContactResolver() - expect(resolver).toBeDefined() - }) - - test('should initialize with custom cacheTTL', () => { - const resolver = new ContactResolver('2021-07-28', 60000) - expect(resolver).toBeDefined() - }) - - test('should resolve contactId from API', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { - contacts: [{ id: 'contact_123', phone: '+1234567890' }], - }, - }) - - const result = await contactResolver.resolveContactId('+1234567890', 'location_abc', 'test_token') - - expect(result).toBe('contact_123') - expect(axios.get).toHaveBeenCalledWith('https://services.leadconnectorhq.com/contacts/', { - params: { - locationId: 'location_abc', - query: '1234567890', - }, - headers: { - Authorization: 'Bearer test_token', - Version: '2021-07-28', - }, - }) - }) - - test('should return cached contactId on subsequent calls', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { - contacts: [{ id: 'contact_cached', phone: '1234567890' }], - }, - }) - - // First call - should hit API - const result1 = await contactResolver.resolveContactId('1234567890', 'location_abc', 'token') - expect(result1).toBe('contact_cached') - expect(axios.get).toHaveBeenCalledTimes(1) - - // Second call - should use cache - const result2 = await contactResolver.resolveContactId('1234567890', 'location_abc', 'token') - expect(result2).toBe('contact_cached') - expect(axios.get).toHaveBeenCalledTimes(1) // Still 1, cache was used - }) - - test('should return null when no contacts found', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { contacts: [] }, - }) - - const result = await contactResolver.resolveContactId('0000000000', 'location_abc', 'token') - - expect(result).toBeNull() - }) - - test('should return null when API call fails', async () => { - ;(axios.get as jest.MockedFunction).mockRejectedValue(new Error('API Error')) - - // Add error listener to prevent unhandled error - const errorHandler = jest.fn() - contactResolver.on('error', errorHandler) - - const result = await contactResolver.resolveContactId('1234567890', 'location_abc', 'token') - - expect(result).toBeNull() - expect(errorHandler).toHaveBeenCalledWith({ - title: 'GHL CONTACT RESOLVER ERROR', - instructions: ['Error resolving contactId for 1234567890: API Error'], - }) - }) - - test('should find exact phone match in contacts list', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { - contacts: [ - { id: 'contact_wrong', phone: '+9999999999' }, - { id: 'contact_correct', phone: '+1234567890' }, - { id: 'contact_another', phone: '+8888888888' }, - ], - }, - }) - - const result = await contactResolver.resolveContactId('1234567890', 'location_abc', 'token') - - expect(result).toBe('contact_correct') - }) - - test('should fallback to first contact when no exact match', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { - contacts: [ - { id: 'contact_first', phone: '+9999999999' }, - { id: 'contact_second', phone: '+8888888888' }, - ], - }, - }) - - const result = await contactResolver.resolveContactId('1234567890', 'location_abc', 'token') - - expect(result).toBe('contact_first') - }) - - test('should clearCache properly', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { - contacts: [{ id: 'contact_1', phone: '1234567890' }], - }, - }) - - // Populate cache - await contactResolver.resolveContactId('1234567890', 'loc', 'token') - expect(axios.get).toHaveBeenCalledTimes(1) - - // Clear cache - contactResolver.clearCache() - - // Should hit API again - await contactResolver.resolveContactId('1234567890', 'loc', 'token') - expect(axios.get).toHaveBeenCalledTimes(2) - }) - - test('should handle contact with null phone', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { - contacts: [ - { id: 'contact_no_phone', phone: null }, - { id: 'contact_with_phone', phone: '+1234567890' }, - ], - }, - }) - - const result = await contactResolver.resolveContactId('1234567890', 'location_abc', 'token') - - expect(result).toBe('contact_with_phone') - }) - - test('should use different cache keys for different locations', async () => { - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { - contacts: [{ id: 'contact_loc1', phone: '1234567890' }], - }, - }) - - await contactResolver.resolveContactId('1234567890', 'location_1', 'token') - ;(axios.get as jest.MockedFunction).mockResolvedValue({ - data: { - contacts: [{ id: 'contact_loc2', phone: '1234567890' }], - }, - }) - - await contactResolver.resolveContactId('1234567890', 'location_2', 'token') - - // Both calls should hit API (different cache keys) - expect(axios.get).toHaveBeenCalledTimes(2) - }) -}) - -describe('#verifyWebhookSignature', () => { - const secret = 'test_secret_key' - const payload = '{"type":"InboundMessage","body":"Hello"}' - - // Pre-computed HMAC SHA256 signature for the payload with the secret - // crypto.createHmac('sha256', 'test_secret_key').update(payload).digest('hex') - const validSignature = 'f8c0c4e1e8e5c3a5f8c0c4e1e8e5c3a5f8c0c4e1e8e5c3a5f8c0c4e1e8e5c3a5' - - test('should return false for empty payload', () => { - expect(verifyWebhookSignature('', validSignature, secret)).toBe(false) - }) - - test('should return false for empty signature', () => { - expect(verifyWebhookSignature(payload, '', secret)).toBe(false) - }) - - test('should return false for empty secret', () => { - expect(verifyWebhookSignature(payload, validSignature, '')).toBe(false) - }) - - test('should return false for invalid signature', () => { - const invalidSignature = 'invalid_signature_that_is_definitely_wrong' - expect(verifyWebhookSignature(payload, invalidSignature, secret)).toBe(false) - }) - - test('should return false for signature with wrong length', () => { - const shortSignature = 'abcd1234' - expect(verifyWebhookSignature(payload, shortSignature, secret)).toBe(false) - }) - - test('should verify correct signature', () => { - // Generate the actual valid signature - const crypto = require('node:crypto') - const actualSignature = crypto.createHmac('sha256', secret).update(payload).digest('hex') - - expect(verifyWebhookSignature(payload, actualSignature, secret)).toBe(true) - }) - - test('should reject tampered payload', () => { - const crypto = require('node:crypto') - const originalSignature = crypto.createHmac('sha256', secret).update(payload).digest('hex') - const tamperedPayload = '{"type":"InboundMessage","body":"Tampered"}' - - expect(verifyWebhookSignature(tamperedPayload, originalSignature, secret)).toBe(false) - }) -}) - -describe('#extractSignatureFromHeaders', () => { - test('should extract signature from x-ghl-signature header', () => { - const headers = { 'x-ghl-signature': 'abc123' } - expect(extractSignatureFromHeaders(headers)).toBe('abc123') - }) - - test('should extract signature from x-signature header', () => { - const headers = { 'x-signature': 'def456' } - expect(extractSignatureFromHeaders(headers)).toBe('def456') - }) - - test('should extract signature from x-hub-signature-256 header', () => { - const headers = { 'x-hub-signature-256': 'ghi789' } - expect(extractSignatureFromHeaders(headers)).toBe('ghi789') - }) - - test('should extract signature from x-webhook-signature header', () => { - const headers = { 'x-webhook-signature': 'jkl012' } - expect(extractSignatureFromHeaders(headers)).toBe('jkl012') - }) - - test('should handle sha256= prefix', () => { - const headers = { 'x-ghl-signature': 'sha256=abc123' } - expect(extractSignatureFromHeaders(headers)).toBe('abc123') - }) - - test('should return null when no signature header found', () => { - const headers = { 'content-type': 'application/json' } - expect(extractSignatureFromHeaders(headers)).toBeNull() - }) - - test('should handle lowercase header names', () => { - const headers = { 'X-GHL-SIGNATURE': 'uppercase123' } - expect(extractSignatureFromHeaders(headers)).toBe('uppercase123') - }) - - test('should prioritize x-ghl-signature over others', () => { - const headers = { - 'x-ghl-signature': 'ghl_sig', - 'x-signature': 'other_sig', - } - expect(extractSignatureFromHeaders(headers)).toBe('ghl_sig') - }) -}) diff --git a/packages/provider-gohighlevel/jest.config.ts b/packages/provider-gohighlevel/jest.config.ts deleted file mode 100644 index 208945ce9..000000000 --- a/packages/provider-gohighlevel/jest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, - testEnvironment: 'node', -} - -export default config diff --git a/packages/provider-gohighlevel/package.json b/packages/provider-gohighlevel/package.json deleted file mode 100644 index f1d32e6e1..000000000 --- a/packages/provider-gohighlevel/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@builderbot/provider-gohighlevel", - "version": "1.4.2-alpha.11", - "description": "GoHighLevel provider for builderbot - supports SMS, WhatsApp, Email, Live Chat and more channels via GHL API v2", - "author": "codigoencasa", - "homepage": "https://github.com/codigoencasa/builderbot#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/builderbot.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "bugs": { - "url": "https://github.com/codigoencasa/builderbot/issues" - }, - "dependencies": { - "axios": "^1.13.2", - "body-parser": "^2.2.1", - "file-type": "^19.0.0", - "form-data": "^4.0.5", - "mime-types": "^3.0.2", - "polka": "^0.5.2", - "queue-promise": "^2.2.1" - }, - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/jest": "^30.0.0", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "jest": "^30.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.4.6", - "tslib": "^2.6.2" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-gohighlevel/rollup.config.js b/packages/provider-gohighlevel/rollup.config.js deleted file mode 100644 index ca8969dfe..000000000 --- a/packages/provider-gohighlevel/rollup.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import commonjs from '@rollup/plugin-commonjs' -import json from '@rollup/plugin-json' -import { nodeResolve } from '@rollup/plugin-node-resolve' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - json(), - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/axios|@builderbot\/bot/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/provider-gohighlevel/src/gohighlevel/core.ts b/packages/provider-gohighlevel/src/gohighlevel/core.ts deleted file mode 100644 index 578d16b9d..000000000 --- a/packages/provider-gohighlevel/src/gohighlevel/core.ts +++ /dev/null @@ -1,160 +0,0 @@ -import EventEmitter from 'node:events' -import type polka from 'polka' -import type Queue from 'queue-promise' - -import { processIncomingMessage } from '../utils/processIncomingMsg' -import type { TokenManager } from '../utils/tokenManager' -import { verifyWebhookSignature, extractSignatureFromHeaders } from '../utils/webhookVerification' - -import type { GHLGlobalVendorArgs, GHLIncomingWebhook, GHLMessage } from '~/types' - -/** - * Core vendor class handling OAuth callbacks and webhook processing - * @internal - */ -export class GoHighLevelCoreVendor extends EventEmitter { - queue: Queue - tokenManager: TokenManager - webhookSecret?: string - - constructor(_queue: Queue, _tokenManager: TokenManager, webhookSecret?: string) { - super() - this.queue = _queue - this.tokenManager = _tokenManager - this.webhookSecret = webhookSecret - } - - public indexHome: polka.Middleware = (_, res) => { - res.end('running ok') - } - - public oauthCallback: polka.Middleware = async (req: any, res: any) => { - const { query } = req - const code = query?.code as string - const globalVendorArgs = req['globalVendorArgs'] as GHLGlobalVendorArgs - - if (!code) { - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Missing authorization code' })) - return - } - - try { - const tokens = await this.tokenManager.exchangeAuthorizationCode(code) - this.emit('tokens_updated', tokens) - - // Show tokens for user to copy to their config - this.emit('notice', { - title: '🔑 OAuth Tokens - Copy to your config:', - instructions: [ - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - `accessToken: '${tokens.access_token}',`, - `refreshToken: '${tokens.refresh_token}',`, - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ], - }) - - this.emit('notice', { - title: '✅ GHL Authorization Successful', - instructions: [ - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - `Location ID: ${tokens.locationId || globalVendorArgs?.locationId || 'N/A'}`, - `Channel: ${globalVendorArgs?.channelType || 'N/A'}`, - 'Bot is ready to receive messages!', - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ], - }) - this.emit('ready') - res.statusCode = 200 - res.end(JSON.stringify({ message: 'Authorization successful', locationId: tokens.locationId })) - } catch (error) { - this.emit('notice', { - title: '❌ GHL Authorization Failed', - instructions: [error.message || 'Failed to exchange authorization code'], - }) - res.statusCode = 500 - res.end(JSON.stringify({ error: 'Failed to exchange authorization code' })) - } - } - - public incomingMsg: polka.Middleware = async (req: any, res: any) => { - const body = req?.body as GHLIncomingWebhook - - // Debug log for incoming webhook - console.log( - '[GHL DEBUG] Webhook received:', - JSON.stringify( - { - type: body?.type, - contactId: body?.contactId, - phone: body?.phone, - messageType: body?.messageType, - direction: body?.direction, - }, - null, - 2 - ) - ) - - // Verify webhook signature if secret is configured - if (this.webhookSecret) { - const signature = extractSignatureFromHeaders(req.headers) - const rawBody = req.rawBody || JSON.stringify(body) - - if (!signature) { - this.emit('notice', { - title: 'GHL WEBHOOK WARNING', - instructions: ['Webhook signature missing from request headers'], - }) - res.statusCode = 401 - res.end(JSON.stringify({ error: 'Missing webhook signature' })) - return - } - - if (!verifyWebhookSignature(rawBody, signature, this.webhookSecret)) { - this.emit('notice', { - title: 'GHL WEBHOOK WARNING', - instructions: ['Invalid webhook signature - request rejected'], - }) - res.statusCode = 401 - res.end(JSON.stringify({ error: 'Invalid webhook signature' })) - return - } - } - - if (!body || !body.type) { - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Invalid webhook payload' })) - return - } - - try { - const message = processIncomingMessage(body) - - if (message) { - await this.queue.enqueue(() => this.processMessage(message)) - } - - res.statusCode = 200 - res.end(JSON.stringify({ success: true })) - } catch (error) { - this.emit('notice', { - title: 'GHL WEBHOOK ERROR', - instructions: [error.message || 'Error processing incoming message'], - }) - res.statusCode = 400 - res.end(JSON.stringify({ error: error.message || 'Error processing webhook' })) - } - } - - public processMessage = (message: GHLMessage): Promise => { - return new Promise((resolve, reject) => { - try { - this.emit('message', message) - resolve() - } catch (error) { - reject(error) - } - }) - } -} diff --git a/packages/provider-gohighlevel/src/gohighlevel/provider.ts b/packages/provider-gohighlevel/src/gohighlevel/provider.ts deleted file mode 100644 index e7e331e5d..000000000 --- a/packages/provider-gohighlevel/src/gohighlevel/provider.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { ProviderClass, utils } from '@builderbot/bot' -import type { Vendor } from '@builderbot/bot/dist/provider/interface/provider' -import type { BotContext, Button, SendOptions } from '@builderbot/bot/dist/types' -import axios from 'axios' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import { tmpdir } from 'os' -import { join, resolve } from 'path' -import Queue from 'queue-promise' - -import { GoHighLevelCoreVendor } from './core' -import { ChannelLister } from '../utils/channelLister' -import { ContactResolver } from '../utils/contactResolver' -import { downloadFile } from '../utils/downloadFile' -import { parseGHLNumber } from '../utils/number' -import { TokenManager } from '../utils/tokenManager' - -import type { GoHighLevelInterface } from '~/interface/gohighlevel' -import type { GHLChannelType, GHLGlobalVendorArgs, GHLMessage, GHLSendMessageBody, SaveFileOptions } from '~/types' - -const GHL_API_URL = 'https://services.leadconnectorhq.com' - -/** - * GoHighLevel Provider for BuilderBot - * @description Integrates with GoHighLevel CRM to send/receive messages via SMS, WhatsApp, Email, etc. - * @see https://builderbot.app/en/providers/gohighlevel - * @example - * ```typescript - * const provider = createProvider(GoHighLevelProvider, { - * clientId: 'YOUR_CLIENT_ID', - * clientSecret: 'YOUR_CLIENT_SECRET', - * locationId: 'YOUR_LOCATION_ID', - * channelType: 'WhatsApp', - * accessToken: 'OPTIONAL_TOKEN', - * refreshToken: 'OPTIONAL_REFRESH_TOKEN', - * }) - * ``` - */ -class GoHighLevelProvider extends ProviderClass implements GoHighLevelInterface { - public vendor: Vendor - public queue: Queue = new Queue() - public tokenManager: TokenManager - public contactResolver: ContactResolver - public channelLister: ChannelLister - private isReady: boolean = false - /** Cache to store the channel type per contactId for auto-reply on same channel */ - private contactChannelCache: Map = new Map() - - public globalVendorArgs: GHLGlobalVendorArgs = { - name: 'bot', - clientId: '', - clientSecret: '', - locationId: '', - channelType: 'SMS', - apiVersion: '2021-07-28', - port: 3000, - writeMyself: 'none', - } - - /** - * Creates a new GoHighLevel provider instance - * @param args - Configuration options for the provider - * @throws Error if clientId, clientSecret, or locationId are missing - */ - constructor(args: GHLGlobalVendorArgs) { - super() - this.globalVendorArgs = { ...this.globalVendorArgs, ...args } - - if (!this.globalVendorArgs.clientId || !this.globalVendorArgs.clientSecret) { - throw new Error('[GoHighLevel] clientId and clientSecret are required') - } - if (!this.globalVendorArgs.locationId) { - throw new Error('[GoHighLevel] locationId is required') - } - - this.queue = new Queue({ - concurrent: 1, - interval: 100, - start: true, - }) - this.tokenManager = new TokenManager( - this.globalVendorArgs.clientId, - this.globalVendorArgs.clientSecret, - this.globalVendorArgs.redirectUri - ) - this.contactResolver = new ContactResolver(this.globalVendorArgs.apiVersion) - this.channelLister = new ChannelLister(this.globalVendorArgs.apiVersion) - - // Forward ContactResolver errors to provider notice events - this.contactResolver.on('error', (payload) => { - this.emit('notice', payload) - }) - - // Forward ChannelLister errors to provider notice events - this.channelLister.on('error', (payload) => { - this.emit('notice', payload) - }) - - if (this.globalVendorArgs.accessToken) { - this.tokenManager.setTokens({ - access_token: this.globalVendorArgs.accessToken, - refresh_token: this.globalVendorArgs.refreshToken, - expires_in: 86400, - }) - } - } - - protected beforeHttpServerInit(): void { - // Routes are registered in initVendor() to avoid duplicates - } - - protected async afterHttpServerInit(): Promise { - try { - // If tokens are configured, validate them - if (this.globalVendorArgs.accessToken) { - this.emit('notice', { - title: '🔄 Validating existing token...', - instructions: [], - }) - - const isValid = await this.tokenManager.validateToken() - - if (isValid) { - await this.emitReadyNotice() - return - } - - // Token invalid, try refresh if available - if (this.globalVendorArgs.refreshToken) { - try { - this.emit('notice', { - title: '🔄 Token expired, refreshing...', - instructions: [], - }) - const newTokens = await this.tokenManager.refreshAccessToken() - this.emitTokensNotice(newTokens) - await this.emitReadyNotice() - return - } catch (refreshErr) { - // Refresh failed, fall through to show OAuth URL - } - } - - // All failed, show error - this.emit('notice', { - title: '❌ Tokens invalid', - instructions: [ - 'The tokens in your config are no longer valid.', - 'Please re-authorize using the URL below.', - ], - }) - } - - // No tokens or validation failed - show OAuth URL - this.showAuthorizationUrl() - } catch (err: any) { - this.emit('notice', { - title: '❌ GHL Auth Error', - instructions: [err.message || 'Check credentials'], - }) - this.emit('error', err) - } - } - - private showAuthorizationUrl(): void { - const authUrl = this.getAuthorizationUrl() - this.emit('notice', { - title: '🔐 GHL Authorization Required', - instructions: [ - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - 'Visit this URL to authorize your app:', - '', - authUrl, - '', - 'Docs: https://builderbot.app/en/providers/gohighlevel', - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ], - }) - } - - private emitTokensNotice(tokens: { access_token: string; refresh_token: string }): void { - this.emit('notice', { - title: '🔑 New OAuth Tokens - Copy to your config:', - instructions: [ - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - `accessToken: '${tokens.access_token}',`, - `refreshToken: '${tokens.refresh_token}',`, - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ], - }) - } - - private async emitReadyNotice(): Promise { - if (this.isReady) return - - this.isReady = true - const host = { - locationId: this.globalVendorArgs.locationId, - channelType: this.globalVendorArgs.channelType, - } - this.vendor.emit('host', host) - this.emit('notice', { - title: '✅ GHL Connected', - instructions: [ - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - `Location ID: ${this.globalVendorArgs.locationId}`, - `Channel: ${this.globalVendorArgs.channelType}`, - 'Bot is ready to receive messages!', - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ], - }) - - // List available channels based on channel type - await this.listAvailableChannels() - - this.emit('ready') - } - - private async listAvailableChannels(): Promise { - try { - const token = await this.tokenManager.getValidToken() - if (!token) return - - const channels = await this.channelLister.listByChannelType( - this.globalVendorArgs.channelType, - this.globalVendorArgs.locationId, - token - ) - - if (channels.length === 0) return - - const channelType = this.globalVendorArgs.channelType - const isPhone = channelType === 'SMS' || channelType === 'WhatsApp' - const title = isPhone ? '📱 Available Phone Numbers:' : '📧 Available Email Accounts:' - - const instructions = [ - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ...channels.map((c) => (c.name ? ` ${c.name}: ${c.value}` : ` ${c.value}`)), - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - ] - - this.emit('notice', { title, instructions }) - } catch (error) { - // Don't block bot startup if channel listing fails - } - } - - protected initVendor(): Promise { - const vendor = new GoHighLevelCoreVendor(this.queue, this.tokenManager, this.globalVendorArgs.webhookSecret) - - // NOTE: Event listeners are registered by ProviderClass.listenOnEvents() - // Do NOT register them here to avoid duplicates - - this.server = this.server - .use((req, _, next) => { - req['globalVendorArgs'] = this.globalVendorArgs - return next() - }) - .get('/', vendor.indexHome) - .get('/oauth/callback', vendor.oauthCallback) - .post('/webhook', vendor.incomingMsg) - - this.tokenManager.on('tokens_updated', (tokens) => { - this.globalVendorArgs.accessToken = tokens.access_token - this.globalVendorArgs.refreshToken = tokens.refresh_token - this.emit('tokens_updated', tokens) - // If bot is already running, this is an automatic refresh - show new tokens - if (this.isReady) { - this.emitTokensNotice(tokens) - } - }) - - this.vendor = vendor - return Promise.resolve(this.vendor) - } - - /** Stops the provider and cleans up resources */ - public async stop(): Promise { - this.tokenManager.destroy() - this.contactResolver.clearCache() - await super.stop() - } - - /** Returns the OAuth authorization URL for GoHighLevel */ - public getAuthorizationUrl(): string { - const params = new URLSearchParams({ - response_type: 'code', - redirect_uri: this.globalVendorArgs.redirectUri || '', - client_id: this.globalVendorArgs.clientId, - scope: 'conversations.readonly conversations.write conversations/message.readonly conversations/message.write', - }) - if (this.globalVendorArgs.versionId) { - params.append('version_id', this.globalVendorArgs.versionId) - } - return `https://marketplace.gohighlevel.com/oauth/chooselocation?${params.toString()}` - } - - /** - * Downloads and saves a file from an incoming message - * @param ctx - Message context containing file URL - * @param options - Save options (path) - * @returns Path to saved file or 'ERROR' - */ - saveFile = async (ctx: Partial, options: SaveFileOptions = {}): Promise => { - try { - const url = ctx?.url ?? ctx?.attachments?.[0]?.url - if (!url) throw new Error('No file URL found in context') - const token = await this.tokenManager.getValidToken() - const { buffer, extension } = await downloadFile(url, token) - const fileName = `file-${Date.now()}.${extension}` - const pathFile = join(options?.path ?? tmpdir(), fileName) - await writeFile(pathFile, buffer) - return resolve(pathFile) - } catch (err) { - this.emit('notice', { - title: 'GHL SAVE FILE ERROR', - instructions: [`Failed to save file: ${err.message}`], - }) - return 'ERROR' - } - } - - busEvents = () => [ - { - event: 'auth_failure', - func: (payload: any) => this.emit('auth_failure', payload), - }, - { - event: 'notice', - func: ({ instructions, title }: { instructions: string[]; title: string }) => - this.emit('notice', { instructions, title }), - }, - { - event: 'ready', - func: () => { - if (!this.isReady) { - this.isReady = true - this.emit('ready', true) - } - }, - }, - { - event: 'message', - func: (payload: BotContext) => { - // Cache the channel type for this contact to auto-reply on same channel - const msg = payload as unknown as GHLMessage - if (msg.contactId && msg.channelType) { - this.contactChannelCache.set(msg.contactId, msg.channelType) - } - this.emit('message', payload) - }, - }, - { - event: 'host', - func: (payload: any) => { - this.emit('host', payload) - }, - }, - { - event: 'tokens_updated', - func: (payload: any) => { - this.globalVendorArgs.accessToken = payload.access_token - this.globalVendorArgs.refreshToken = payload.refresh_token - }, - }, - ] - - /** - * Resolves a phone number to a GHL contact ID - * @param phone - Phone number to resolve - * @returns Contact ID or null if not found - */ - resolveContactId = async (phone: string): Promise => { - const token = await this.tokenManager.getValidToken() - return this.contactResolver.resolveContactId(parseGHLNumber(phone), this.globalVendorArgs.locationId, token) - } - - /** - * Checks if a string looks like a GHL contactId rather than a phone number - * ContactIds are alphanumeric strings, while phones are mostly digits - * @param value - String to check - * @returns true if the value appears to be a contactId - */ - private isContactId(value: string): boolean { - if (!value || value.length === 0) return false - // Phone numbers are mostly digits (with optional + prefix, spaces, dashes) - // ContactIds are alphanumeric strings like '8ctXFfVOgXyBgJ4fAqox' - return !/^\+?\d[\d\s-]*$/.test(value) - } - - /** - * Gets the channel type to use for a contact - * Returns the cached channel (from last incoming message) or falls back to global config - * @param contactId - The contact ID to look up - * @returns The channel type to use for sending messages - */ - private getChannelForContact(contactId: string): GHLChannelType { - return this.contactChannelCache.get(contactId) || this.globalVendorArgs.channelType - } - - /** - * Sends a text message to a contact - * @param to - Recipient phone number or contactId - * @param message - Text message content - */ - sendText = async (to: string, message: string): Promise => { - // Debug logs - console.log('[GHL DEBUG] sendText called with to:', to) - console.log('[GHL DEBUG] isContactId(to):', this.isContactId(to)) - - // If 'to' is already a contactId, use it directly; otherwise resolve from phone - const contactId = this.isContactId(to) ? to : await this.resolveContactId(to) - console.log('[GHL DEBUG] resolved contactId:', contactId) - - if (!contactId) throw new Error(`Contact not found for: ${to}`) - - const body: GHLSendMessageBody = { - type: this.getChannelForContact(contactId), - contactId, - message, - } - - if (this.globalVendorArgs.conversationProviderId) { - body.conversationProviderId = this.globalVendorArgs.conversationProviderId - } - - return this.sendMessageGHL(body) - } - - /** - * Sends a media message (image, audio, video, document) - * @param to - Recipient phone number or contactId - * @param text - Optional caption text - * @param mediaInput - URL or path to media file - */ - sendMedia = async (to: string, text: string = '', mediaInput: string): Promise => { - // If 'to' is already a contactId, use it directly; otherwise resolve from phone - const contactId = this.isContactId(to) ? to : await this.resolveContactId(to) - if (!contactId) throw new Error(`Contact not found for: ${to}`) - - const fileDownloaded = await utils.generalDownload(mediaInput) - const mimeType = mime.lookup(fileDownloaded) - - if (mimeType && mimeType.includes('audio')) { - const fileConverted = await utils.convertAudio(fileDownloaded, 'mp3') - mediaInput = fileConverted - } else { - mediaInput = fileDownloaded - } - - const body: GHLSendMessageBody = { - type: this.getChannelForContact(contactId), - contactId, - message: text, - attachments: [mediaInput], - } - - if (this.globalVendorArgs.conversationProviderId) { - body.conversationProviderId = this.globalVendorArgs.conversationProviderId - } - - return this.sendMessageGHL(body) - } - - /** - * Sends a message with buttons (rendered as numbered list) - * @param to - Recipient phone number - * @param buttons - Array of button objects - * @param text - Message text - */ - sendButtons = async (to: string, buttons: Button[] = [], text: string): Promise => { - const buttonText = buttons.map((btn, i) => `${i + 1}. ${btn.body}`).join('\n') - const fullMessage = `${text}\n\n${buttonText}` - return this.sendText(to, fullMessage) - } - - /** - * Sends a message with optional media or buttons - * @param to - Recipient phone number or contactId - * @param message - Message text - * @param options - Optional send options (media, buttons) - */ - sendMessage = async (to: string, message: string, options?: SendOptions): Promise => { - // Only parse phone numbers, not contactIds (contactIds contain letters) - if (!this.isContactId(to)) { - to = parseGHLNumber(to) - } - options = { ...options, ...options?.['options'] } - if (options?.buttons?.length) return this.sendButtons(to, options.buttons, message) - if (options?.media) return this.sendMedia(to, message, options.media) - return this.sendText(to, message) - } - - sendMessageGHL = (body: GHLSendMessageBody): Promise => { - return new Promise((resolve, reject) => - this.queue.add(async () => { - try { - const resp = await this.sendMessageToApi(body) - resolve(resp) - } catch (error) { - reject(error) - } - }) - ) - } - - sendMessageToApi = async (body: GHLSendMessageBody): Promise => { - const token = await this.tokenManager.getValidToken() - const response = await axios.post(`${GHL_API_URL}/conversations/messages`, body, { - headers: { - Authorization: `Bearer ${token}`, - Version: this.globalVendorArgs.apiVersion, - 'Content-Type': 'application/json', - }, - }) - return response.data - } -} - -export { GoHighLevelProvider } diff --git a/packages/provider-gohighlevel/src/index.ts b/packages/provider-gohighlevel/src/index.ts deleted file mode 100644 index 6630c3a55..000000000 --- a/packages/provider-gohighlevel/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { GoHighLevelProvider } from './gohighlevel/provider' -export { GoHighLevelCoreVendor } from './gohighlevel/core' -export { TokenManager } from './utils/tokenManager' -export { ContactResolver } from './utils/contactResolver' -export { verifyWebhookSignature, extractSignatureFromHeaders } from './utils/webhookVerification' -export * from './utils/processIncomingMsg' -export * from './types' diff --git a/packages/provider-gohighlevel/src/interface/gohighlevel.ts b/packages/provider-gohighlevel/src/interface/gohighlevel.ts deleted file mode 100644 index 7ba5e7461..000000000 --- a/packages/provider-gohighlevel/src/interface/gohighlevel.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { SendOptions, BotContext, Button } from '@builderbot/bot/dist/types' - -import type { GHLMessage, GHLSendMessageBody, SaveFileOptions } from '~/types' - -export interface GoHighLevelInterface { - sendText: (to: string, message: string) => Promise - sendMedia: (to: string, text: string, mediaInput: string) => Promise - sendButtons: (to: string, buttons: Button[], text: string) => Promise - sendMessage: (to: string, message: string, options?: SendOptions) => Promise - sendMessageToApi: (body: GHLSendMessageBody) => Promise - saveFile: (ctx: Partial, options?: SaveFileOptions) => Promise - resolveContactId: (phone: string) => Promise -} diff --git a/packages/provider-gohighlevel/src/types.ts b/packages/provider-gohighlevel/src/types.ts deleted file mode 100644 index 8eedd679c..000000000 --- a/packages/provider-gohighlevel/src/types.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { GlobalVendorArgs } from '@builderbot/bot/dist/types' - -export type GHLChannelType = 'SMS' | 'WhatsApp' | 'Email' | 'Live_Chat' | 'Facebook' | 'Instagram' | 'Custom' - -export interface GHLGlobalVendorArgs extends GlobalVendorArgs { - clientId: string - clientSecret: string - locationId: string - channelType: GHLChannelType - apiVersion: string - redirectUri?: string - accessToken?: string - refreshToken?: string - conversationProviderId?: string - /** Optional webhook secret for signature verification (HMAC SHA256) */ - webhookSecret?: string - /** Optional version ID for OAuth authorization URL */ - versionId?: string -} - -export interface GHLOAuthTokens { - access_token: string - refresh_token: string - token_type: string - expires_in: number - scope: string - locationId: string - userId?: string -} - -export interface GHLContact { - id: string - name?: string - firstName?: string - lastName?: string - email?: string - phone?: string - locationId?: string - [key: string]: any -} - -export interface GHLConversation { - id: string - contactId: string - locationId: string - type?: string - [key: string]: any -} - -export interface GHLMessage { - type: string - from: string - to: string - body: string - name: string - pushName: string - message_id?: string - timestamp?: any - url?: string - attachments?: GHLAttachment[] - contactId?: string - conversationId?: string - channelType?: GHLChannelType - direction?: 'inbound' | 'outbound' -} - -export interface GHLAttachment { - url: string - type?: string - name?: string - size?: number -} - -export interface GHLSendMessageBody { - type: GHLChannelType - contactId: string - message?: string - html?: string - subject?: string - attachments?: string[] - conversationProviderId?: string -} - -export interface GHLIncomingWebhook { - type: string - locationId: string - contactId?: string - conversationId?: string - messageId?: string - body?: string - messageType?: string - phone?: string - email?: string - direction?: 'inbound' | 'outbound' - status?: string - attachments?: GHLAttachment[] - dateAdded?: string - [key: string]: any -} - -export interface GHLContactSearchResult { - contacts: GHLContact[] - meta?: { - total: number - currentPage: number - nextPage: number | null - } -} - -export interface SaveFileOptions { - path?: string -} - -export interface GHLPhoneNumber { - id: string - number: string - name?: string - locationId?: string - capabilities?: string[] - status?: string -} - -export interface GHLEmailAccount { - id: string - email: string - name?: string - locationId?: string -} - -export interface GHLChannelInfo { - type: 'phone' | 'email' - id: string - value: string - name?: string -} diff --git a/packages/provider-gohighlevel/src/utils/channelLister.ts b/packages/provider-gohighlevel/src/utils/channelLister.ts deleted file mode 100644 index 5cdeac916..000000000 --- a/packages/provider-gohighlevel/src/utils/channelLister.ts +++ /dev/null @@ -1,144 +0,0 @@ -import axios from 'axios' -import EventEmitter from 'node:events' - -import type { GHLChannelInfo, GHLChannelType, GHLEmailAccount, GHLPhoneNumber } from '~/types' - -const GHL_API_URL = 'https://services.leadconnectorhq.com' - -/** - * Lists available channels (phone numbers, emails) from GoHighLevel - * @emits error - When API calls fail (non-permission errors only) - */ -export class ChannelLister extends EventEmitter { - private apiVersion: string - - constructor(apiVersion: string = '2021-07-28') { - super() - this.apiVersion = apiVersion - } - - /** - * List active phone numbers for a location - */ - async listPhoneNumbers(locationId: string, token: string): Promise { - try { - const response = await axios.get(`${GHL_API_URL}/phone-system/numbers/location/${locationId}`, { - headers: { - Authorization: `Bearer ${token}`, - Version: this.apiVersion, - }, - }) - - const data = response.data - const numbers: GHLPhoneNumber[] = (data.numbers || data.data || []).map((n: any) => ({ - id: n.id || n._id, - number: n.number || n.phoneNumber || n.phone, - name: n.name || n.friendlyName || n.label, - locationId: n.locationId, - capabilities: n.capabilities || [], - status: n.status, - })) - - return numbers - } catch (error: any) { - // Don't throw - just return empty array - // Scope 'phone-system.readonly' may be required - const status = error.response?.status - const errorMsg = error.response?.data?.message || error.message - - // Only emit error if it's not a permission issue (silently fail for missing scopes) - if (status !== 401 && status !== 403) { - this.emit('error', { - title: '📱 GHL Phone Numbers', - instructions: [errorMsg || 'Could not list phone numbers'], - }) - } - return [] - } - } - - /** - * List email accounts/addresses for a location - * Note: GHL may not have a direct endpoint for this, trying conversation providers - */ - async listEmails(locationId: string, token: string): Promise { - try { - // Try to get location info which may include email settings - const response = await axios.get(`${GHL_API_URL}/locations/${locationId}`, { - headers: { - Authorization: `Bearer ${token}`, - Version: this.apiVersion, - }, - }) - - const data = response.data?.location || response.data - const emails: GHLEmailAccount[] = [] - - // Extract email from location settings if available - if (data.email) { - emails.push({ - id: 'location-email', - email: data.email, - name: data.name || 'Location Email', - locationId, - }) - } - - // Check for business email - if (data.business?.email && data.business.email !== data.email) { - emails.push({ - id: 'business-email', - email: data.business.email, - name: 'Business Email', - locationId, - }) - } - - return emails - } catch (error: any) { - // Don't throw - just return empty array - const status = error.response?.status - const errorMsg = error.response?.data?.message || error.message - - // Only emit error if it's not a permission issue - if (status !== 401 && status !== 403) { - this.emit('error', { - title: '📧 GHL Email Accounts', - instructions: [errorMsg || 'Could not list email accounts'], - }) - } - return [] - } - } - - /** - * List channels based on channel type - */ - async listByChannelType(channelType: GHLChannelType, locationId: string, token: string): Promise { - switch (channelType) { - case 'SMS': - case 'WhatsApp': { - const phones = await this.listPhoneNumbers(locationId, token) - return phones.map((p) => ({ - type: 'phone' as const, - id: p.id, - value: p.number, - name: p.name, - })) - } - case 'Email': { - const emails = await this.listEmails(locationId, token) - return emails.map((e) => ({ - type: 'email' as const, - id: e.id, - value: e.email, - name: e.name, - })) - } - default: - // For other channel types (Facebook, Instagram, Live_Chat, Custom) - // we don't have specific listing endpoints - return [] - } - } -} diff --git a/packages/provider-gohighlevel/src/utils/contactResolver.ts b/packages/provider-gohighlevel/src/utils/contactResolver.ts deleted file mode 100644 index 581f43159..000000000 --- a/packages/provider-gohighlevel/src/utils/contactResolver.ts +++ /dev/null @@ -1,68 +0,0 @@ -import axios from 'axios' -import EventEmitter from 'node:events' - -import { parseGHLNumber } from './number' - -import type { GHLContactSearchResult } from '~/types' - -const GHL_API_URL = 'https://services.leadconnectorhq.com' - -export class ContactResolver extends EventEmitter { - private cache: Map = new Map() - private cacheTTL: number = 300000 // 5 minutes - private apiVersion: string - - constructor(apiVersion: string = '2021-07-28', cacheTTL?: number) { - super() - this.apiVersion = apiVersion - if (cacheTTL) this.cacheTTL = cacheTTL - } - - async resolveContactId(phone: string, locationId: string, token: string): Promise { - const normalizedPhone = parseGHLNumber(phone) - const cacheKey = `${locationId}:${normalizedPhone}` - const cached = this.cache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { - return cached.contactId - } - - try { - const response = await axios.get(`${GHL_API_URL}/contacts/`, { - params: { - locationId, - query: normalizedPhone, - }, - headers: { - Authorization: `Bearer ${token}`, - Version: this.apiVersion, - }, - }) - - const contacts = response.data?.contacts ?? [] - if (contacts.length === 0) return null - - const contact = - contacts.find((c) => { - const contactPhone = parseGHLNumber(c.phone ?? '') - return contactPhone === normalizedPhone - }) ?? contacts[0] - - this.cache.set(cacheKey, { - contactId: contact.id, - expiresAt: Date.now() + this.cacheTTL, - }) - - return contact.id - } catch (error) { - this.emit('error', { - title: 'GHL CONTACT RESOLVER ERROR', - instructions: [`Error resolving contactId for ${phone}: ${error.message}`], - }) - return null - } - } - - clearCache(): void { - this.cache.clear() - } -} diff --git a/packages/provider-gohighlevel/src/utils/downloadFile.ts b/packages/provider-gohighlevel/src/utils/downloadFile.ts deleted file mode 100644 index aa04887e7..000000000 --- a/packages/provider-gohighlevel/src/utils/downloadFile.ts +++ /dev/null @@ -1,27 +0,0 @@ -import axios from 'axios' -import type { AxiosResponse } from 'axios' -import mimeTypes from 'mime-types' - -const fileTypeFromResponse = (response: AxiosResponse): { type: string | null; ext: string | false } => { - const type = response.headers['content-type'] ?? '' - const ext = mimeTypes.extension(type) - return { type, ext } -} - -async function downloadFile(url: string, token: string): Promise<{ buffer: Buffer; extension: string }> { - const response: AxiosResponse = await axios.get(url, { - headers: { - Authorization: `Bearer ${token}`, - }, - maxBodyLength: Infinity, - responseType: 'arraybuffer', - }) - const { ext } = fileTypeFromResponse(response) - if (!ext) throw new Error('Unable to determine file extension') - return { - buffer: response.data, - extension: ext, - } -} - -export { downloadFile, fileTypeFromResponse } diff --git a/packages/provider-gohighlevel/src/utils/index.ts b/packages/provider-gohighlevel/src/utils/index.ts deleted file mode 100644 index 200a395c5..000000000 --- a/packages/provider-gohighlevel/src/utils/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { downloadFile, fileTypeFromResponse } from './downloadFile' -export { processIncomingMessage } from './processIncomingMsg' -export { parseGHLNumber } from './number' -export { TokenManager } from './tokenManager' -export { ContactResolver } from './contactResolver' -export { verifyWebhookSignature, extractSignatureFromHeaders } from './webhookVerification' -export { ChannelLister } from './channelLister' diff --git a/packages/provider-gohighlevel/src/utils/number.ts b/packages/provider-gohighlevel/src/utils/number.ts deleted file mode 100644 index 484f568be..000000000 --- a/packages/provider-gohighlevel/src/utils/number.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const parseGHLNumber = (number: string): string => { - if (typeof number !== 'string') return number - // Remove all non-numeric characters: +, spaces, dashes, parentheses, etc. - number = number.replace(/[^\d]/g, '') - return number -} diff --git a/packages/provider-gohighlevel/src/utils/processIncomingMsg.ts b/packages/provider-gohighlevel/src/utils/processIncomingMsg.ts deleted file mode 100644 index 750c57a3f..000000000 --- a/packages/provider-gohighlevel/src/utils/processIncomingMsg.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { utils } from '@builderbot/bot' - -import { parseGHLNumber } from './number' - -import type { GHLMessage, GHLIncomingWebhook } from '~/types' - -export const processIncomingMessage = (webhook: GHLIncomingWebhook): GHLMessage | null => { - if (!webhook || webhook.direction !== 'inbound') return null - - const phone = parseGHLNumber(webhook.phone ?? '') - // PRIORITY: Use contactId when available (more reliable than phone which can be partial/invalid) - const from = webhook.contactId || phone || '' - const name = webhook.contactId ?? phone - - // Debug log - console.log('[GHL DEBUG] processIncomingMessage:', { - 'webhook.phone': webhook.phone, - 'webhook.contactId': webhook.contactId, - 'parsed phone': phone, - 'final from': from, - }) - const hasAttachments = webhook.attachments && webhook.attachments.length > 0 - - let body = webhook.body ?? '' - let type = 'text' - let url: string | undefined - - if (hasAttachments) { - const attachment = webhook.attachments[0] - const attachmentType = attachment.type?.toLowerCase() ?? '' - - if (attachmentType.includes('image')) { - type = 'image' - body = body || utils.generateRefProvider('_event_media_') - url = attachment.url - } else if (attachmentType.includes('video')) { - type = 'video' - body = body || utils.generateRefProvider('_event_media_') - url = attachment.url - } else if (attachmentType.includes('audio')) { - type = 'audio' - body = body || utils.generateRefProvider('_event_voice_note_') - url = attachment.url - } else { - type = 'document' - body = body || utils.generateRefProvider('_event_document_') - url = attachment.url - } - } - - const timestamp = webhook.dateAdded ? new Date(webhook.dateAdded).getTime() : Date.now() - - const message: GHLMessage = { - type, - from, - to: webhook.locationId ?? '', - body, - name, - pushName: name, - message_id: webhook.messageId, - timestamp: isNaN(timestamp) ? Date.now() : timestamp, - contactId: webhook.contactId, - conversationId: webhook.conversationId, - channelType: webhook.messageType as GHLMessage['channelType'], - direction: webhook.direction, - } - - if (url) message.url = url - if (hasAttachments) message.attachments = webhook.attachments - - return message -} diff --git a/packages/provider-gohighlevel/src/utils/tokenManager.ts b/packages/provider-gohighlevel/src/utils/tokenManager.ts deleted file mode 100644 index 6d31fce11..000000000 --- a/packages/provider-gohighlevel/src/utils/tokenManager.ts +++ /dev/null @@ -1,172 +0,0 @@ -import axios from 'axios' -import EventEmitter from 'node:events' - -import type { GHLOAuthTokens } from '~/types' - -const GHL_AUTH_URL = 'https://services.leadconnectorhq.com/oauth/token' -const GHL_API_URL = 'https://services.leadconnectorhq.com' - -/** - * Manages OAuth2 tokens for GoHighLevel API - * Handles token exchange, refresh, validation, and automatic renewal - * @emits tokens_updated - When tokens are refreshed - * @emits token_error - When token operations fail - */ -export class TokenManager extends EventEmitter { - private accessToken: string = '' - private refreshToken: string = '' - private clientId: string - private clientSecret: string - private redirectUri: string - private expiresAt: number = 0 - private refreshTimer: ReturnType | null = null - private refreshPromise: Promise | null = null - - constructor(clientId: string, clientSecret: string, redirectUri: string = '') { - super() - this.clientId = clientId - this.clientSecret = clientSecret - this.redirectUri = redirectUri - } - - getAccessToken(): string { - return this.accessToken - } - - getRefreshToken(): string { - return this.refreshToken - } - - isTokenExpired(): boolean { - return Date.now() >= this.expiresAt - } - - setTokens(tokens: Partial): void { - if (tokens.access_token) this.accessToken = tokens.access_token - if (tokens.refresh_token) this.refreshToken = tokens.refresh_token - if (tokens.expires_in) { - this.expiresAt = Date.now() + tokens.expires_in * 1000 - this.scheduleRefresh(tokens.expires_in) - } - } - - /** Exchanges an authorization code for access and refresh tokens */ - async exchangeAuthorizationCode(code: string): Promise { - const response = await axios.post( - GHL_AUTH_URL, - new URLSearchParams({ - client_id: this.clientId, - client_secret: this.clientSecret, - grant_type: 'authorization_code', - code, - redirect_uri: this.redirectUri, - }).toString(), - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - } - ) - const tokens: GHLOAuthTokens = response.data - this.setTokens(tokens) - this.emit('tokens_updated', tokens) - return tokens - } - - async refreshAccessToken(): Promise { - if (!this.refreshToken) { - throw new Error('No refresh token available') - } - - // Mutex: if a refresh is already in progress, return the same promise - if (this.refreshPromise) { - return this.refreshPromise - } - - this.refreshPromise = this._doRefresh() - try { - return await this.refreshPromise - } finally { - this.refreshPromise = null - } - } - - private async _doRefresh(): Promise { - try { - const response = await axios.post( - GHL_AUTH_URL, - new URLSearchParams({ - client_id: this.clientId, - client_secret: this.clientSecret, - grant_type: 'refresh_token', - refresh_token: this.refreshToken, - }).toString(), - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - } - ) - const tokens: GHLOAuthTokens = response.data - this.setTokens(tokens) - this.emit('tokens_updated', tokens) - return tokens - } catch (error) { - this.emit('token_error', error) - throw error - } - } - - async getValidToken(): Promise { - if (this.isTokenExpired() && this.refreshToken) { - await this.refreshAccessToken() - } - return this.accessToken - } - - /** - * Validates the current access token by making an API call to GHL. - * Returns true if token is valid, false if invalid/expired. - */ - async validateToken(): Promise { - if (!this.accessToken) return false - - try { - const response = await axios.get(`${GHL_API_URL}/users/me`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - Version: '2021-07-28', - }, - }) - return response.status === 200 - } catch (error: any) { - if (error.response?.status === 401) { - return false - } - // Network or other errors - emit but don't throw - this.emit('token_error', error) - return false - } - } - - private scheduleRefresh(expiresIn: number): void { - if (this.refreshTimer) clearTimeout(this.refreshTimer) - // Refresh 5 minutes before expiry, minimum 1 minute - const refreshIn = Math.max((expiresIn - 300) * 1000, 60000) - this.refreshTimer = setTimeout(async () => { - try { - await this.refreshAccessToken() - } catch (error) { - this.emit('token_error', error) - } - }, refreshIn) - // Allow process to exit even if timer is pending - if (this.refreshTimer && typeof this.refreshTimer === 'object' && 'unref' in this.refreshTimer) { - this.refreshTimer.unref() - } - } - - destroy(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer) - this.refreshTimer = null - } - this.refreshPromise = null - } -} diff --git a/packages/provider-gohighlevel/src/utils/webhookVerification.ts b/packages/provider-gohighlevel/src/utils/webhookVerification.ts deleted file mode 100644 index c48206cce..000000000 --- a/packages/provider-gohighlevel/src/utils/webhookVerification.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto' - -/** - * Verifies the webhook signature using HMAC SHA256. - * GoHighLevel sends the signature in the 'x-ghl-signature' header. - * - * @param payload - The raw request body as a string - * @param signature - The signature from the request header - * @param secret - The webhook secret (typically the client secret) - * @returns true if the signature is valid, false otherwise - */ -export const verifyWebhookSignature = (payload: string, signature: string, secret: string): boolean => { - if (!payload || !signature || !secret) { - return false - } - - try { - const expectedSignature = createHmac('sha256', secret).update(payload).digest('hex') - - // Use timing-safe comparison to prevent timing attacks - const signatureBuffer = Buffer.from(signature, 'hex') - const expectedBuffer = Buffer.from(expectedSignature, 'hex') - - if (signatureBuffer.length !== expectedBuffer.length) { - return false - } - - return timingSafeEqual(signatureBuffer, expectedBuffer) - } catch { - return false - } -} - -/** - * Extracts the signature from the request headers. - * Supports common header formats used by GoHighLevel. - * Header names are case-insensitive per HTTP spec. - * - * @param headers - The request headers object - * @returns The signature string or null if not found - */ -export const extractSignatureFromHeaders = (headers: Record): string | null => { - // Normalize headers to lowercase for case-insensitive lookup - const normalizedHeaders: Record = {} - for (const key of Object.keys(headers)) { - normalizedHeaders[key.toLowerCase()] = headers[key] - } - - // GoHighLevel may use different header names - const signatureHeaders = ['x-ghl-signature', 'x-signature', 'x-hub-signature-256', 'x-webhook-signature'] - - for (const headerName of signatureHeaders) { - const value = normalizedHeaders[headerName] - if (value) { - // Handle format "sha256=..." if present - if (value.startsWith('sha256=')) { - return value.slice(7) - } - return value - } - } - - return null -} diff --git a/packages/provider-gohighlevel/tsconfig.json b/packages/provider-gohighlevel/tsconfig.json deleted file mode 100644 index 6b6bd3627..000000000 --- a/packages/provider-gohighlevel/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"], - "paths": { - "~/*": ["./src/*"] - } - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "__tests__/**", "jest.config.ts", "node_modules"] -} diff --git a/packages/provider-gupshup/LICENSE.md b/packages/provider-gupshup/LICENSE.md deleted file mode 100644 index 8144847d7..000000000 --- a/packages/provider-gupshup/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Gupshup Integration - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/provider-gupshup/README.md b/packages/provider-gupshup/README.md deleted file mode 100644 index 789280af9..000000000 --- a/packages/provider-gupshup/README.md +++ /dev/null @@ -1,60 +0,0 @@ -

- -

@builderbot/provider-gupshup

- -

- -## Description - -Gupshup provider for BuilderBot v1. - -## Inbound Webhook Format - -This provider expects Gupshup WhatsApp Cloud (WABA) webhook payloads (`entry[].changes[].value.messages[]`). -Legacy webhook payloads (`type: "message"` with top-level `payload`) are not supported. -Inbound messages and delivery status updates are emitted as `notice` events for runtime visibility. - -## Installation - -```bash -npm install @builderbot/provider-gupshup -``` - -## Quick Start -```typescript -import { createProvider } from '@builderbot/bot' -import { GupshupProvider } from '@builderbot/provider-gupshup' - -const adapterProvider = createProvider(GupshupProvider, { - apiKey: 'YOUR_API_KEY', - srcName: 'YOUR_APP_NAME', - phoneNumber: 'YOUR_SOURCE_NUMBER', - logs: { - inbound: false, - status: 'failed', - outboundErrors: true, - rawOnFailed: false, - }, -}) -``` - -You can also disable provider notices globally when creating the bot: - -```typescript -const { httpServer } = await createBot( - { flow, provider: adapterProvider, database }, - { - logs: { - notices: false, - }, - } -) -``` - -## Location Request Transport - -`sendLocationRequest`, `requestLocation`, and `sendMessage(..., { locationRequest })` send messages through the partner passthrough endpoint (`/partner/app/{appId}/v3/message`). - -- `partner.appId` and `partner.appToken` are required. -- `bodyText` must be non-empty. -- Missing partner config throws: `Partner app config is required. Provide partner.appId and partner.appToken.` diff --git a/packages/provider-gupshup/__tests__/core.test.ts b/packages/provider-gupshup/__tests__/core.test.ts deleted file mode 100644 index 8c2900aa8..000000000 --- a/packages/provider-gupshup/__tests__/core.test.ts +++ /dev/null @@ -1,1064 +0,0 @@ -import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals' - -import { GupshupCoreVendor } from '../src/gupshup/core' -import type { GupshupGlobalVendorArgs } from '../src/types' - -jest.mock('../src/utils/processIncomingMsg') - -const { processIncomingMessage } = jest.requireMock('../src/utils/processIncomingMsg') as { - processIncomingMessage: any -} -const { processIncomingMessage: realProcessIncomingMessage } = jest.requireActual( - '../src/utils/processIncomingMsg' -) as { - processIncomingMessage: any -} - -describe('#GupshupCoreVendor', () => { - let coreVendor: GupshupCoreVendor - - const mockArgs: GupshupGlobalVendorArgs = { - name: 'test-bot', - port: 3000, - apiKey: 'test-api-key', - srcName: 'test-app', - phoneNumber: '15556581240', - webhook: { - verify: async () => true, - }, - logs: { - inbound: true, - status: 'all', - outboundErrors: true, - rawOnFailed: false, - }, - } - - beforeEach(() => { - coreVendor = new GupshupCoreVendor(mockArgs) - jest.clearAllMocks() - processIncomingMessage.mockReset() - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - describe('#incomingMsg', () => { - test('should respond with "OK" when webhook has no entries', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).not.toHaveBeenCalled() - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should process cloud messages and emit "message" event', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - metadata: { - display_phone_number: '15556581240', - phone_number_id: '862813713572372', - }, - contacts: [ - { - profile: { name: 'Juan Giupponi' }, - wa_id: '5493364183950', - }, - ], - messages: [ - { - from: '5493364183950', - id: 'wamid.1', - text: { body: 'hola' }, - timestamp: '1770811963', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const fakeBotContext = { - from: '5493364183950', - name: 'Juan Giupponi', - body: 'hola', - } - - processIncomingMessage.mockResolvedValue(fakeBotContext) - const emitSpy = jest.spyOn(coreVendor, 'emit') - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).toHaveBeenCalledTimes(1) - expect(processIncomingMessage).toHaveBeenCalledWith( - { - message: { - from: '5493364183950', - id: 'wamid.1', - text: { body: 'hola' }, - timestamp: '1770811963', - type: 'text', - }, - contact: { - profile: { name: 'Juan Giupponi' }, - wa_id: '5493364183950', - }, - metadata: { - display_phone_number: '15556581240', - phone_number_id: '862813713572372', - }, - }, - mockArgs - ) - expect(emitSpy).toHaveBeenCalledWith('notice', { - title: '📩 GUPSHUP INBOUND', - instructions: ['From: 5493364183950', 'Type: text', 'Body: hola'], - }) - expect(emitSpy).toHaveBeenCalledWith('message', fakeBotContext) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should integrate with parser canonical dispatch for mixed-case text type', async () => { - processIncomingMessage.mockImplementation(realProcessIncomingMessage) - - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - metadata: { - display_phone_number: '15556581240', - }, - contacts: [ - { - profile: { name: 'Ana' }, - wa_id: '5493364183999', - }, - ], - messages: [ - { - from: '5493364183999', - id: 'wamid.case.1', - text: { body: 'hola canonico' }, - timestamp: '1770811963', - type: ' TEXT ', - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(coreVendor, 'emit') - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).toHaveBeenCalledTimes(1) - expect(emitSpy).toHaveBeenCalledWith( - 'message', - expect.objectContaining({ - from: '5493364183999', - name: 'Ana', - body: 'hola canonico', - type: 'text', - }) - ) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should reject webhook when verify hook returns false', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - webhook: { - verify: async () => false, - }, - }) - const mockReq = { - body: { - entry: [ - { - changes: [], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).not.toHaveBeenCalled() - expect(mockRes.statusCode).toBe(401) - expect(mockRes.end).toHaveBeenCalledWith('Unauthorized') - }) - - test('should respond with 500 when verify hook rejects', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - webhook: { - verify: async () => Promise.reject(new Error('verify rejected')), - }, - }) - const mockReq = { - body: { - entry: [], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) - - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(mockRes.statusCode).toBe(500) - expect(mockRes.end).toHaveBeenCalledWith('Error') - expect(consoleSpy).toHaveBeenCalledWith('Webhook Error:', expect.any(Error)) - - consoleSpy.mockRestore() - }) - - test('should respond with 500 when verify hook throws synchronously', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - webhook: { - verify: () => { - throw new Error('verify sync throw') - }, - }, - }) - const mockReq = { - body: { - entry: [], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) - - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(mockRes.statusCode).toBe(500) - expect(mockRes.end).toHaveBeenCalledWith('Error') - expect(consoleSpy).toHaveBeenCalledWith('Webhook Error:', expect.any(Error)) - - consoleSpy.mockRestore() - }) - - test('should dedupe inbound messages by id before emitting', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - contacts: [{ wa_id: '5493364183950', profile: { name: 'Juan' } }], - messages: [ - { - from: '5493364183950', - id: 'wamid.dup', - text: { body: 'hola 1' }, - timestamp: '1770811963', - type: 'text', - }, - { - from: '5493364183950', - id: 'wamid.dup', - text: { body: 'hola 2' }, - timestamp: '1770811964', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(coreVendor, 'emit') - - processIncomingMessage.mockResolvedValue({ from: '5493364183950', name: 'Juan', body: 'hola 1' }) - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).toHaveBeenCalledTimes(1) - expect(emitSpy).toHaveBeenCalledWith('message', { from: '5493364183950', name: 'Juan', body: 'hola 1' }) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should process duplicated id on retry when first processing fails', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - contacts: [{ wa_id: '5493364183950', profile: { name: 'Juan' } }], - messages: [ - { - from: '5493364183950', - id: 'wamid.retry', - text: { body: 'hola retry' }, - timestamp: '1770811963', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const firstRes = { - end: jest.fn(), - statusCode: 0, - } - const secondRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(coreVendor, 'emit') - - processIncomingMessage.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValueOnce({ - from: '5493364183950', - name: 'Juan', - body: 'hola retry', - }) - - await coreVendor.incomingMsg(mockReq as any, firstRes as any, jest.fn()) - await coreVendor.incomingMsg(mockReq as any, secondRes as any, jest.fn()) - - expect(processIncomingMessage).toHaveBeenCalledTimes(2) - expect(firstRes.statusCode).toBe(200) - expect(secondRes.statusCode).toBe(200) - expect(emitSpy).toHaveBeenCalledWith('message', { - from: '5493364183950', - name: 'Juan', - body: 'hola retry', - }) - }) - - test('should process same message id again after dedupe ttl expires', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - webhook: { - verify: async () => true, - dedupeTtlMs: 50, - }, - }) - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - contacts: [{ wa_id: '5493364183950', profile: { name: 'Juan' } }], - messages: [ - { - from: '5493364183950', - id: 'wamid.expire', - text: { body: 'hola ttl' }, - timestamp: '1770811963', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const firstRes = { - end: jest.fn(), - statusCode: 0, - } - const secondRes = { - end: jest.fn(), - statusCode: 0, - } - - let now = 1_000 - const dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now) - processIncomingMessage.mockResolvedValue({ from: '5493364183950', name: 'Juan', body: 'hola ttl' }) - - await vendor.incomingMsg(mockReq as any, firstRes as any, jest.fn()) - now = 1_051 - await vendor.incomingMsg(mockReq as any, secondRes as any, jest.fn()) - - expect(processIncomingMessage).toHaveBeenCalledTimes(2) - expect(firstRes.statusCode).toBe(200) - expect(secondRes.statusCode).toBe(200) - - dateNowSpy.mockRestore() - }) - - test('should isolate failed message processing and continue batch', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - contacts: [ - { wa_id: '5493364183950', profile: { name: 'Juan' } }, - { wa_id: '5493364183000', profile: { name: 'Maria' } }, - ], - messages: [ - { - from: '5493364183950', - id: 'wamid.fail', - text: { body: 'hola fail' }, - timestamp: '1770811963', - type: 'text', - }, - { - from: '5493364183000', - id: 'wamid.ok', - text: { body: 'hola ok' }, - timestamp: '1770811964', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(coreVendor, 'emit') - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) - - processIncomingMessage - .mockRejectedValueOnce(new Error('message failed')) - .mockResolvedValueOnce({ from: '5493364183000', name: 'Maria', body: 'hola ok' }) - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).toHaveBeenCalledTimes(2) - expect(emitSpy).toHaveBeenCalledWith('message', { - from: '5493364183000', - name: 'Maria', - body: 'hola ok', - }) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - expect(consoleSpy).toHaveBeenCalledWith( - '[Gupshup] Error processing inbound message wamid.fail:', - 'message failed' - ) - - consoleSpy.mockRestore() - }) - - test('should process one concurrent delivery for the same message id', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - contacts: [{ wa_id: '5493364183950', profile: { name: 'Juan' } }], - messages: [ - { - from: '5493364183950', - id: 'wamid.concurrent', - text: { body: 'hola concurrente' }, - timestamp: '1770811963', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const firstRes = { - end: jest.fn(), - statusCode: 0, - } - const secondRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(coreVendor, 'emit') - - let resolveProcessing: ((value: unknown) => void) | undefined - processIncomingMessage.mockImplementation( - () => - new Promise((resolve) => { - resolveProcessing = resolve - }) - ) - - const firstCallPromise = coreVendor.incomingMsg(mockReq as any, firstRes as any, jest.fn()) - await Promise.resolve() - - const secondCallPromise = coreVendor.incomingMsg(mockReq as any, secondRes as any, jest.fn()) - resolveProcessing?.({ from: '5493364183950', name: 'Juan', body: 'hola concurrente' }) - - await firstCallPromise - await secondCallPromise - - expect(processIncomingMessage).toHaveBeenCalledTimes(1) - const messageEmits = emitSpy.mock.calls.filter(([eventName]) => eventName === 'message') - expect(messageEmits).toHaveLength(1) - expect(messageEmits[0][1]).toEqual({ - from: '5493364183950', - name: 'Juan', - body: 'hola concurrente', - }) - expect(firstRes.statusCode).toBe(200) - expect(secondRes.statusCode).toBe(200) - }) - - test('should process multiple messages from same webhook', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - contacts: [ - { - profile: { name: 'Juan' }, - wa_id: '5493364183950', - }, - { - profile: { name: 'Maria' }, - wa_id: '5493364183000', - }, - ], - messages: [ - { - from: '5493364183950', - id: 'wamid.1', - text: { body: 'hola 1' }, - timestamp: '1770811963', - type: 'text', - }, - { - from: '5493364183000', - id: 'wamid.2', - text: { body: 'hola 2' }, - timestamp: '1770811964', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(coreVendor, 'emit') - - processIncomingMessage - .mockResolvedValueOnce({ from: '5493364183950', name: 'Juan', body: 'hola 1' }) - .mockResolvedValueOnce({ from: '5493364183000', name: 'Maria', body: 'hola 2' }) - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).toHaveBeenCalledTimes(2) - expect(emitSpy).toHaveBeenCalledWith('message', { - from: '5493364183950', - name: 'Juan', - body: 'hola 1', - }) - expect(emitSpy).toHaveBeenCalledWith('message', { - from: '5493364183000', - name: 'Maria', - body: 'hola 2', - }) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should skip inbound notice when inbound logs are disabled', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - logs: { - ...mockArgs.logs, - inbound: false, - }, - }) - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - contacts: [ - { - profile: { name: 'Juan' }, - wa_id: '5493364183950', - }, - ], - messages: [ - { - from: '5493364183950', - id: 'wamid.1', - text: { body: 'hola' }, - timestamp: '1770811963', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const fakeBotContext = { - from: '5493364183950', - name: 'Juan', - body: 'hola', - } - - processIncomingMessage.mockResolvedValue(fakeBotContext) - const emitSpy = jest.spyOn(vendor, 'emit') - - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(emitSpy).not.toHaveBeenCalledWith( - 'notice', - expect.objectContaining({ - title: '📩 GUPSHUP INBOUND', - }) - ) - expect(emitSpy).toHaveBeenCalledWith('message', fakeBotContext) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should process status events and emit status notice', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'statuses', - value: { - statuses: [{ id: 'wamid.1', status: 'sent' }], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(coreVendor, 'emit') - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).not.toHaveBeenCalled() - expect(emitSpy).toHaveBeenCalledWith( - 'notice', - expect.objectContaining({ - title: '📨 GUPSHUP STATUS', - instructions: expect.arrayContaining(['Status: sent', 'Recipient: unknown']), - }) - ) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should isolate status listener failures and continue webhook processing', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'statuses', - value: { - statuses: [{ id: 'wamid.1', status: 'sent', recipient_id: '5493364183950' }], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) - - coreVendor.on('status', () => { - throw new Error('status listener failed') - }) - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - expect(consoleSpy).toHaveBeenCalledWith( - '[Gupshup] Error dispatching status event:', - 'status listener failed' - ) - - consoleSpy.mockRestore() - }) - - test('should isolate notice listener failures and continue webhook processing', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'statuses', - value: { - statuses: [{ id: 'wamid.1', status: 'sent', recipient_id: '5493364183950' }], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) - - coreVendor.on('notice', () => { - throw new Error('notice listener failed') - }) - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - expect(consoleSpy).toHaveBeenCalledWith( - '[Gupshup] Error dispatching notice event:', - 'notice listener failed' - ) - - consoleSpy.mockRestore() - }) - - test('should skip non-failed statuses when status log mode is failed', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - logs: { - ...mockArgs.logs, - status: 'failed', - }, - }) - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'statuses', - value: { - statuses: [{ recipient_id: '5493364183950', status: 'sent' }], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(vendor, 'emit') - - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(emitSpy).not.toHaveBeenCalledWith('notice', expect.anything()) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should emit alert notice when status is failed', async () => { - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - statuses: [ - { - recipient_id: '5493364183950', - status: 'failed', - errors: [ - { - title: 'Message failed', - details: 'Rejected by destination number', - }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(coreVendor, 'emit') - - await coreVendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(processIncomingMessage).not.toHaveBeenCalled() - expect(emitSpy).toHaveBeenCalledWith( - 'notice', - expect.objectContaining({ - title: '🔔 GUPSHUP ALERT 🔔', - instructions: expect.arrayContaining([ - 'Status: failed', - 'Recipient: 5493364183950', - 'Rejected by destination number', - ]), - }) - ) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should include raw payload when rawOnFailed is enabled', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - logs: { - ...mockArgs.logs, - status: 'failed', - rawOnFailed: true, - }, - }) - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'statuses', - value: { - statuses: [ - { - recipient_id: '5493364183950', - status: 'failed', - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const emitSpy = jest.spyOn(vendor, 'emit') - - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(emitSpy).toHaveBeenCalledWith( - 'notice', - expect.objectContaining({ - title: '🔔 GUPSHUP ALERT 🔔', - instructions: expect.arrayContaining([expect.stringContaining('Raw: ')]), - }) - ) - expect(mockRes.statusCode).toBe(200) - expect(mockRes.end).toHaveBeenCalledWith('OK') - }) - - test('should respond with 500 on top-level webhook error', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - webhook: { - verify: async () => { - throw new Error('verify failed') - }, - }, - }) - const mockReq = { - body: { - object: 'whatsapp_business_account', - entry: [ - { - changes: [ - { - field: 'messages', - value: { - messages: [ - { - from: '5493364183950', - id: 'wamid.1', - text: { body: 'hola' }, - timestamp: '1770811963', - type: 'text', - }, - ], - }, - }, - ], - }, - ], - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) - - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(mockRes.statusCode).toBe(500) - expect(mockRes.end).toHaveBeenCalledWith('Error') - expect(consoleSpy).toHaveBeenCalled() - - consoleSpy.mockRestore() - }) - - test('should emit security warning once when webhook verification is missing', async () => { - const vendor = new GupshupCoreVendor({ - ...mockArgs, - webhook: undefined, - }) - const emitSpy = jest.spyOn(vendor, 'emit') - const mockReq = { - body: { - object: 'whatsapp_business_account', - }, - } - const mockRes = { - end: jest.fn(), - statusCode: 0, - } - - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - await vendor.incomingMsg(mockReq as any, mockRes as any, jest.fn()) - - expect(emitSpy).toHaveBeenCalledWith( - 'notice', - expect.objectContaining({ - title: '🟠 GUPSHUP SECURITY NOTICE 🟠', - instructions: expect.arrayContaining([ - 'Webhook verification is disabled.', - expect.stringContaining('webhook.verify'), - ]), - }) - ) - const securityNotices = emitSpy.mock.calls.filter( - ([eventName, payload]) => eventName === 'notice' && payload?.title === '🟠 GUPSHUP SECURITY NOTICE 🟠' - ) - expect(securityNotices).toHaveLength(1) - }) - }) -}) diff --git a/packages/provider-gupshup/__tests__/processIncomingMsg.test.ts b/packages/provider-gupshup/__tests__/processIncomingMsg.test.ts deleted file mode 100644 index 801e4aeb7..000000000 --- a/packages/provider-gupshup/__tests__/processIncomingMsg.test.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { beforeEach, describe, expect, jest, test } from '@jest/globals' - -import { GupshupCloudIncomingMessageArgs, GupshupGlobalVendorArgs } from '../src/types' -import { processIncomingMessage } from '../src/utils/processIncomingMsg' - -jest.mock('@builderbot/bot', () => ({ - utils: { - generateRefProvider: jest.fn((type: string) => `__${type}__`), - }, -})) - -describe('#processIncomingMessage', () => { - const mockArgs: GupshupGlobalVendorArgs = { - name: 'test-bot', - port: 3000, - apiKey: 'test-api-key', - srcName: 'test-app', - phoneNumber: '15556581240', - } - - const createMockMessage = ( - type: string, - messagePayload: Record, - senderName: string = 'John Doe', - source: string = '5491155551234' - ): GupshupCloudIncomingMessageArgs => ({ - metadata: { - display_phone_number: '15556581240', - phone_number_id: '862813713572372', - }, - contact: { - profile: { name: senderName }, - wa_id: source, - }, - message: { - from: source, - id: `wamid_${Date.now()}`, - timestamp: '1770811963', - type, - ...messagePayload, - }, - }) - - beforeEach(() => { - jest.clearAllMocks() - }) - - test('should process text message correctly', async () => { - const rawMessage = createMockMessage('text', { text: { body: 'Hola mundo' } }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result).toEqual( - expect.objectContaining({ - from: '5491155551234', - name: 'John Doe', - body: 'Hola mundo', - url: '', - host: { phone: '15556581240' }, - type: 'text', - }) - ) - }) - - test('should dispatch using canonical lowercase type when payload type casing differs', async () => { - const rawMessage = createMockMessage(' TEXT ', { text: { body: 'Hola canonico' } }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result).toEqual( - expect.objectContaining({ - type: 'text', - body: 'Hola canonico', - }) - ) - }) - - test('should process image message correctly', async () => { - const rawMessage = createMockMessage( - 'image', - { image: { url: 'https://example.com/image.jpg' } }, - 'Jane', - '5491155559999' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.from).toBe('5491155559999') - expect(result?.url).toBe('https://example.com/image.jpg') - expect(result?.body).toContain('_event_media_') - }) - - test('should include compatibility media fields for media messages', async () => { - const rawMessage = createMockMessage('image', { - image: { - id: 'media-001', - url: 'https://example.com/image.jpg', - mime_type: 'image/jpeg', - filename: 'image.jpg', - caption: 'Hola', - sha256: 'hash', - }, - }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect((result as any)?.id).toBe(rawMessage.message.id) - expect((result as any)?.message_id).toBe(rawMessage.message.id) - expect((result as any)?.timestamp).toBe(rawMessage.message.timestamp) - expect(result?.type).toBe('image') - expect((result as any)?.fileData).toEqual({ - url: 'https://example.com/image.jpg', - id: 'media-001', - mime_type: 'image/jpeg', - filename: 'image.jpg', - caption: 'Hola', - sha256: 'hash', - }) - }) - - test('should process audio message correctly', async () => { - const rawMessage = createMockMessage( - 'audio', - { audio: { url: 'https://example.com/audio.ogg' } }, - 'Carlos', - '5491155558888' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.from).toBe('5491155558888') - expect(result?.url).toBe('https://example.com/audio.ogg') - expect(result?.body).toContain('_event_voice_note_') - }) - - test('should process document message correctly', async () => { - const rawMessage = createMockMessage( - 'document', - { document: { url: 'https://example.com/doc.pdf' } }, - 'Maria', - '5491155557777' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.from).toBe('5491155557777') - expect(result?.url).toBe('https://example.com/doc.pdf') - expect(result?.body).toContain('_event_document_') - }) - - test('should process location message correctly', async () => { - const rawMessage = createMockMessage( - 'location', - { location: { latitude: '-34.6037', longitude: '-58.3816' } }, - 'Pedro', - '5491155556666' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.from).toBe('5491155556666') - expect(result?.body).toContain('_event_location_') - expect(result?.latitude).toBe('-34.6037') - expect(result?.longitude).toBe('-58.3816') - }) - - test('should process interactive reply messages', async () => { - const rawMessage = createMockMessage( - 'interactive', - { - interactive: { - type: 'button_reply', - button_reply: { - id: 'btn_1', - title: 'Confirmar', - }, - }, - }, - 'Sofia', - '5491155554444' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.from).toBe('5491155554444') - expect(result?.body).toBe('Confirmar') - expect(result?.interactiveId).toBe('btn_1') - expect((result as any)?.title_button_reply).toBe('Confirmar') - }) - - test('should map legacy button payload compatibility fields with payload precedence', async () => { - const rawMessage = createMockMessage('button', { - button: { - payload: 'BTN_PAYLOAD_1', - text: 'Boton visible', - }, - }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.type).toBe('button') - expect(result?.body).toBe('BTN_PAYLOAD_1') - expect((result as any)?.buttonPayload).toBe('BTN_PAYLOAD_1') - expect((result as any)?.payload).toBe('BTN_PAYLOAD_1') - expect((result as any)?.title_button_reply).toBe('BTN_PAYLOAD_1') - }) - - test('should expose list reply compatibility fields', async () => { - const rawMessage = createMockMessage('interactive', { - interactive: { - type: 'list_reply', - list_reply: { - id: 'list_123', - title: 'Plan Premium', - description: 'Detalle', - }, - }, - }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.type).toBe('interactive') - expect((result as any)?.title_list_reply).toBe('Plan Premium') - expect((result as any)?.id_list_reply).toBe('list_123') - }) - - test('should prioritize list reply id over title for interactive body', async () => { - const rawMessage = createMockMessage('interactive', { - interactive: { - type: 'list_reply', - list_reply: { - id: 'list_id_priority', - title: 'Titulo visible', - description: 'Descripcion visible', - }, - }, - }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.body).toBe('list_id_priority') - expect((result as any)?.title_list_reply).toBe('Titulo visible') - expect((result as any)?.id_list_reply).toBe('list_id_priority') - }) - - test('should parse interactive nfm_reply response_json and keep raw interactive payload', async () => { - const rawMessage = createMockMessage('interactive', { - interactive: { - type: 'nfm_reply', - nfm_reply: { - name: 'flow_response', - response_json: '{"lead_id":"123","status":"ok"}', - }, - }, - }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.type).toBe('interactive') - expect(result?.body).toBe('{"lead_id":"123","status":"ok"}') - expect((result as any)?.nfm_reply).toEqual({ - lead_id: '123', - status: 'ok', - }) - expect((result as any)?.message?.interactive?.nfm_reply?.response_json).toBe('{"lead_id":"123","status":"ok"}') - }) - - test('should not throw when interactive nfm_reply response_json is invalid', async () => { - const rawMessage = createMockMessage('interactive', { - interactive: { - type: 'nfm_reply', - nfm_reply: { - response_json: '{invalid json', - }, - }, - }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result).not.toBeNull() - expect(result?.body).toBe('{invalid json') - expect((result as any)?.nfm_reply).toBeUndefined() - }) - - test('should keep button/list precedence over nfm_reply body fallback', async () => { - const rawMessage = createMockMessage('interactive', { - interactive: { - type: 'list_reply', - list_reply: { - id: 'list_priority', - title: 'Visible title', - }, - nfm_reply: { - response_json: '{"ignored":true}', - }, - }, - }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.body).toBe('list_priority') - expect((result as any)?.title_list_reply).toBe('Visible title') - expect((result as any)?.id_list_reply).toBe('list_priority') - expect((result as any)?.nfm_reply).toEqual({ ignored: true }) - }) - - test('should process reaction message correctly', async () => { - const rawMessage = createMockMessage( - 'reaction', - { - reaction: { - emoji: '🔥', - message_id: 'wamid.123', - }, - }, - 'Unknown', - '5491155555555' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.body).toBe('🔥') - expect(result?.reactionToMessageId).toBe('wamid.123') - }) - - test('should keep reaction removed event when emoji is missing', async () => { - const rawMessage = createMockMessage('reaction', { - reaction: { - message_id: 'wamid.456', - }, - }) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.type).toBe('reaction') - expect(result?.body).toContain('_event_reaction_removed_') - expect((result as any)?.reactionEmoji).toBe('') - expect(result?.reactionToMessageId).toBe('wamid.456') - }) - - test('should process contacts message correctly', async () => { - const rawMessage = createMockMessage( - 'contacts', - { - contacts: [ - { - name: { formatted_name: 'Juan Perez' }, - phones: [{ phone: '+5491155511122' }], - }, - ], - }, - 'Agente', - '5491155522222' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.body).toContain('_event_contacts_') - expect(result?.contacts).toHaveLength(1) - }) - - test('should process order message correctly', async () => { - const rawMessage = createMockMessage( - 'order', - { - order: { - catalog_id: 'catalog_123', - product_items: [{ product_retailer_id: 'sku-1', quantity: 2 }], - }, - }, - 'Comprador', - '5491155533333' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.body).toContain('_event_order_') - expect(result?.order?.catalog_id).toBe('catalog_123') - }) - - test('should return null for unknown message type', async () => { - const rawMessage = createMockMessage('unsupported_type', {}, 'Unknown', '5491155555555') - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result).toBeNull() - expect(consoleSpy).toHaveBeenCalledWith('[Gupshup] Unhandled message type: unsupported_type') - - consoleSpy.mockRestore() - }) - - test('should include media id when media url is missing', async () => { - const rawMessage = createMockMessage( - 'image', - { - image: { - id: 'media-123', - }, - }, - 'Jane', - '5491155559999' - ) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.body).toContain('_event_media_') - expect(result?.url).toBe('') - expect(result?.mediaId).toBe('media-123') - }) - - test('should use phone as fallback name if profile name is missing', async () => { - const rawMessage: GupshupCloudIncomingMessageArgs = { - metadata: { - display_phone_number: '15556581240', - }, - contact: { - profile: { name: '' }, - wa_id: '5491155553333', - }, - message: { - from: '5491155553333', - id: 'wamid.404', - timestamp: '1770811963', - type: 'text', - text: { body: 'Sin nombre' }, - }, - } - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result?.name).toBe('5491155553333') - }) - - test('should return null when sender phone is missing', async () => { - const rawMessage: GupshupCloudIncomingMessageArgs = { - message: { - id: 'wamid.404', - timestamp: '1770811963', - type: 'text', - text: { body: 'Sin telefono' }, - }, - } - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result).toBeNull() - expect(consoleSpy).toHaveBeenCalledWith('[Gupshup] Message without sender phone') - - consoleSpy.mockRestore() - }) - - test('should return null when message type is missing', async () => { - const rawMessage: GupshupCloudIncomingMessageArgs = { - metadata: { - display_phone_number: '15556581240', - }, - contact: { - profile: { name: 'John Doe' }, - wa_id: '5491155553333', - }, - message: { - from: '5491155553333', - id: 'wamid.405', - timestamp: '1770811963', - type: undefined as any, - text: { body: 'Sin tipo' }, - }, - } - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) - - const result = await processIncomingMessage(rawMessage, mockArgs) - - expect(result).toBeNull() - expect(consoleSpy).toHaveBeenCalledWith('[Gupshup] Malformed incoming message payload: missing message.type') - - consoleSpy.mockRestore() - }) -}) diff --git a/packages/provider-gupshup/__tests__/provider.test.ts b/packages/provider-gupshup/__tests__/provider.test.ts deleted file mode 100644 index 79f509176..000000000 --- a/packages/provider-gupshup/__tests__/provider.test.ts +++ /dev/null @@ -1,1472 +0,0 @@ -import { utils } from '@builderbot/bot' -import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import axios from 'axios' -import { EventEmitter } from 'node:events' -import * as nodeFs from 'node:fs' - -import { GupshupCoreVendor } from '../src/gupshup/core' -import { GupshupProvider } from '../src/gupshup/provider' -import { GupshupGlobalVendorArgs } from '../src/types' - -jest.mock('axios') - -jest.mock('node:fs', () => { - const actualFs = jest.requireActual('node:fs') as any - - return { - ...actualFs, - createReadStream: jest.fn(actualFs.createReadStream), - } -}) - -jest.mock('@builderbot/bot', () => ({ - ProviderClass: class { - server: any = { - use: jest.fn().mockReturnThis(), - post: jest.fn().mockReturnThis(), - get: jest.fn().mockReturnThis(), - } - emit = jest.fn() - }, - utils: { - generalDownload: jest.fn(), - }, -})) - -describe('#GupshupProvider', () => { - let provider: GupshupProvider - const mockedAxios = axios as jest.Mocked - - const mockArgs: GupshupGlobalVendorArgs = { - name: 'test-bot', - port: 3000, - apiKey: 'test-api-key', - srcName: 'TestApp', - phoneNumber: '1234567890', - appId: 'test-app-id', - logs: { - status: 'all', - }, - } - - const mockHttpPost = (response: any = { data: { messageId: 'abc123' } }) => { - const post = jest.fn() - ;(post as any).mockResolvedValue(response) - ;(provider as any).http = { post } - return post - } - - beforeEach(() => { - jest.clearAllMocks() - mockedAxios.create.mockReturnValue({ post: jest.fn() } as any) - provider = new GupshupProvider(mockArgs) - }) - - describe('#constructor', () => { - test('should initialize with provided arguments', () => { - expect(provider.globalVendorArgs.apiKey).toBe('test-api-key') - expect(provider.globalVendorArgs.srcName).toBe('TestApp') - expect(provider.globalVendorArgs.phoneNumber).toBe('1234567890') - expect(provider.globalVendorArgs.appId).toBe('test-app-id') - expect(provider.globalVendorArgs.logs).toEqual({ - inbound: false, - status: 'all', - outboundErrors: true, - rawOnFailed: false, - }) - }) - - test('should create axios instance with correct baseURL and headers', () => { - expect(axios.create).toHaveBeenCalledWith({ - baseURL: 'https://api.gupshup.io/wa/api/v1', - headers: { - apikey: 'test-api-key', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }) - }) - }) - - describe('#initVendor', () => { - test('should create and return GupshupCoreVendor instance', async () => { - const vendor = await provider['initVendor']() - - expect(vendor).toBeInstanceOf(GupshupCoreVendor) - expect(provider.vendor).toBe(vendor) - expect((vendor as any).args).toEqual(provider.globalVendorArgs) - }) - }) - - describe('#busEvents', () => { - test('should return message, notice and status handlers', () => { - const events = provider['busEvents']() - - expect(events).toHaveLength(3) - expect(events[0].event).toBe('message') - expect(events[1].event).toBe('notice') - expect(events[2].event).toBe('status') - }) - }) - - describe('#sendMessage', () => { - test('should call sendText when no options provided', async () => { - const sendTextSpy = jest.spyOn(provider, 'sendText').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendMessage('5491155551234', 'Hello!') - - expect(sendTextSpy).toHaveBeenCalledWith('5491155551234', 'Hello!', {}) - }) - - test('should call sendButtons when options.buttons is provided', async () => { - const sendButtonsSpy = jest.spyOn(provider, 'sendButtons').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendMessage('5491155551234', 'Choose:', { - buttons: [{ body: 'Option 1' }, { body: 'Option 2' }], - }) - - expect(sendButtonsSpy).toHaveBeenCalledWith( - '5491155551234', - 'Choose:', - [{ body: 'Option 1' }, { body: 'Option 2' }], - expect.objectContaining({ - buttons: [{ body: 'Option 1' }, { body: 'Option 2' }], - }) - ) - }) - - test('should call sendMedia when options.media is provided', async () => { - const sendMediaSpy = jest.spyOn(provider, 'sendMedia').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendMessage('5491155551234', 'Check this image', { - media: 'https://example.com/image.jpg', - }) - - expect(sendMediaSpy).toHaveBeenCalledWith( - '5491155551234', - 'Check this image', - 'https://example.com/image.jpg', - expect.objectContaining({ - media: 'https://example.com/image.jpg', - }) - ) - }) - - test('should merge nested options before routing', async () => { - const sendTextSpy = jest.spyOn(provider, 'sendText').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendMessage('5491155551234', 'Hello!', { - options: { - previewUrl: true, - }, - } as any) - - expect(sendTextSpy).toHaveBeenCalledWith( - '5491155551234', - 'Hello!', - expect.objectContaining({ previewUrl: true }) - ) - }) - - test('should route flow payload to sendFlow when options.flow is provided', async () => { - const sendFlowSpy = jest.spyOn(provider, 'sendFlow').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendMessage('5491155551234', 'Fallback message', { - flow: { - body: 'Start flow', - flowId: 'flow_123', - flowToken: 'token_abc', - flowCta: 'Open flow', - }, - } as any) - - expect(sendFlowSpy).toHaveBeenCalledWith( - '5491155551234', - expect.objectContaining({ - flowId: 'flow_123', - flowToken: 'token_abc', - flowCta: 'Open flow', - }) - ) - }) - - test('should route list payload to sendList', async () => { - const sendListSpy = jest.spyOn(provider, 'sendList').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendMessage('5491155551234', 'Fallback body', { - list: { - items: [{ options: [{ title: 'Option 1' }] }], - }, - } as any) - - expect(sendListSpy).toHaveBeenCalledWith( - '5491155551234', - expect.objectContaining({ body: 'Fallback body' }) - ) - }) - - test('should route location request payload to sendLocationRequest', async () => { - const sendLocationRequestSpy = jest - .spyOn(provider, 'sendLocationRequest') - .mockResolvedValue({ status: 'sent' } as any) - - await provider.sendMessage('5491155551234', 'Share your location', { - locationRequest: 'Please share your location', - } as any) - - expect(sendLocationRequestSpy).toHaveBeenCalledWith('5491155551234', 'Please share your location') - }) - }) - - describe('#sendText', () => { - test('should send text message with correct URLSearchParams payload', async () => { - const mockPost = mockHttpPost() - - await provider.sendText('5491155551234', 'Hello World') - - expect(mockPost).toHaveBeenCalledWith('/msg', expect.any(URLSearchParams)) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - expect(calledParams.get('channel')).toBe('whatsapp') - expect(calledParams.get('source')).toBe('1234567890') - expect(calledParams.get('destination')).toBe('5491155551234') - expect(calledParams.get('src.name')).toBe('TestApp') - - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - expect(messagePayload).toEqual( - expect.objectContaining({ - type: 'text', - text: 'Hello World', - previewUrl: false, - }) - ) - }) - - test('should include context when replyTo is provided', async () => { - const mockPost = mockHttpPost() - - await provider.sendText('5491155551234', 'Hello World', { replyTo: 'wamid.123' }) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - expect(messagePayload.context).toEqual({ msgId: 'wamid.123' }) - }) - - test('should emit notice when outbound request fails', async () => { - const mockPost = jest.fn() - ;(mockPost as any).mockRejectedValue(new Error('upstream timeout')) - ;(provider as any).http = { post: mockPost } - const emitSpy = jest.spyOn(provider, 'emit') - - await expect(provider.sendText('5491155551234', 'Hello World')).rejects.toThrow('upstream timeout') - - expect(emitSpy).toHaveBeenCalledWith( - 'notice', - expect.objectContaining({ - title: '🔔 GUPSHUP ALERT 🔔', - instructions: expect.arrayContaining([ - 'Outbound failed (text)', - 'To: 5491155551234', - 'upstream timeout', - ]), - }) - ) - }) - }) - - describe('#sendMedia', () => { - test('should send image payload when media is image', async () => { - const mockPost = mockHttpPost() - - await provider.sendMedia('5491155551234', 'My caption', 'https://example.com/image.jpg') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('image') - expect(messagePayload.originalUrl).toBe('https://example.com/image.jpg') - expect(messagePayload.caption).toBe('My caption') - }) - - test('should send file payload when media type is file', async () => { - const mockPost = mockHttpPost() - - await provider.sendMedia('5491155551234', 'My caption', 'https://example.com/files/report.pdf') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('file') - expect(messagePayload.filename).toBe('report.pdf') - }) - - test('should keep explicit mediaType as authoritative over URL extension', async () => { - const mockPost = mockHttpPost() - - await provider.sendMedia('5491155551234', 'caption', 'https://example.com/report.pdf', { - mediaType: 'image', - }) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('image') - expect(messagePayload.originalUrl).toBe('https://example.com/report.pdf') - }) - - test('should build provider local-media URL when resolver is not configured', async () => { - const mockPost = mockHttpPost() - - await provider.sendMedia('5491155551234', 'caption', './fixtures/file.png') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - expect(messagePayload.originalUrl).toMatch(/^http:\/\/localhost:3000\/local-media\/[a-z0-9-]+$/i) - }) - - test('should fail with explicit error for local media in production when publicUrl is missing', async () => { - const originalNodeEnv = process.env.NODE_ENV - process.env.NODE_ENV = 'production' - - try { - await expect(provider.sendMedia('5491155551234', 'caption', './fixtures/file.png')).rejects.toThrow( - 'publicUrl is required to serve local media in production/cloud environments' - ) - } finally { - process.env.NODE_ENV = originalNodeEnv - } - }) - - test('should infer local .pdf media as file even when URL is tokenized', async () => { - const mockPost = mockHttpPost() - - await provider.sendMedia('5491155551234', 'caption', './fixtures/report.pdf') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('file') - expect(messagePayload.url).toMatch(/^http:\/\/localhost:3000\/local-media\/[a-z0-9-]+$/i) - }) - - test('should infer local .mp3 media as audio even when URL is tokenized', async () => { - const mockPost = mockHttpPost() - - await provider.sendMedia('5491155551234', 'caption', './fixtures/voice.mp3') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('audio') - expect(messagePayload.url).toMatch(/^http:\/\/localhost:3000\/local-media\/[a-z0-9-]+$/i) - }) - - test('should ignore untrusted inferred host and keep localhost fallback for local media URLs', async () => { - const mockPost = mockHttpPost() - - ;(provider as any).captureInferredBaseUrl({ - headers: { - 'x-forwarded-proto': 'https', - 'x-forwarded-host': 'evil.example.com', - }, - }) - - await provider.sendMedia('5491155551234', 'caption', './fixtures/file.png') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - expect(messagePayload.originalUrl).toMatch(/^http:\/\/localhost:3000\/local-media\/[a-z0-9-]+$/i) - }) - - test('should ignore invalid protocol from headers and keep localhost fallback', async () => { - const mockPost = mockHttpPost() - - ;(provider as any).captureInferredBaseUrl({ - headers: { - host: 'localhost:9000', - 'x-forwarded-proto': 'javascript', - }, - }) - - await provider.sendMedia('5491155551234', 'caption', './fixtures/file.png') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - expect(messagePayload.originalUrl).toMatch(/^http:\/\/localhost:3000\/local-media\/[a-z0-9-]+$/i) - }) - - test('should resolve local media input using resolveMediaUrl hook', async () => { - const providerWithResolver = new GupshupProvider({ - ...mockArgs, - resolveMediaUrl: (input: string) => `https://cdn.example.com/${input}`, - }) - const post = jest.fn(async () => ({ data: { messageId: 'abc123' } })) - ;(providerWithResolver as any).http = { post } - - await providerWithResolver.sendMedia('5491155551234', 'caption', '/tmp/file.png') - - const calledParams = (post as any).mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - expect(messagePayload.originalUrl).toBe('https://cdn.example.com//tmp/file.png') - }) - - test('should throw when resolver returns non-http url', async () => { - const providerWithInvalidResolver = new GupshupProvider({ - ...mockArgs, - resolveMediaUrl: () => 'file:///tmp/file.png', - }) - - await expect( - providerWithInvalidResolver.sendMedia('5491155551234', 'caption', '/tmp/file.png') - ).rejects.toThrow('Gupshup session messages require a public URL for media payloads') - }) - }) - - describe('#sendButtons', () => { - test('should map buttons to quick reply payload', async () => { - const mockPost = mockHttpPost() - - await provider.sendButtons('5491155551234', 'Choose an option', [ - { body: 'One' }, - { body: 'Two' }, - { body: 'Three' }, - { body: 'Four' }, - ] as any) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('quick_reply') - expect(messagePayload.options).toHaveLength(3) - expect(messagePayload.content.text).toBe('Choose an option') - }) - - test('should throw when no valid buttons are provided', async () => { - await expect(provider.sendButtons('5491155551234', 'Choose', [] as any)).rejects.toThrow( - 'Gupshup quick replies require at least one button with text' - ) - }) - }) - - describe('#sendList', () => { - test('should build list payload', async () => { - const mockPost = mockHttpPost() - - await provider.sendList('5491155551234', { - body: 'Select one', - buttonTitle: 'Open', - items: [ - { - title: 'Main section', - options: [{ title: 'Option 1', postbackText: 'option_1' }], - }, - ], - }) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('list') - expect(messagePayload.globalButtons[0].title).toBe('Open') - expect(messagePayload.items[0].options[0].postbackText).toBe('option_1') - }) - - test('should adapt Meta-style list payload', async () => { - const mockPost = mockHttpPost() - - await provider.sendList('5491155551234', { - type: 'list', - header: { - type: 'text', - text: 'Catalogo', - }, - body: { - text: 'Selecciona una opcion', - }, - action: { - button: 'Ver opciones', - sections: [ - { - title: 'Primera', - rows: [{ id: 'row-1', title: 'Producto 1', description: 'Descripcion 1' }], - }, - ], - }, - footer: { - text: 'Footer', - }, - } as any) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.title).toBe('Catalogo') - expect(messagePayload.body).toBe('Selecciona una opcion\nFooter') - expect(messagePayload.globalButtons[0].title).toBe('Ver opciones') - expect(messagePayload.items[0].options[0].postbackText).toBe('row-1') - }) - }) - - describe('#sendImage, #sendFile and #sendButtonUrl', () => { - test('should keep sendImage as alias to sendMedia with image mediaType', async () => { - const sendMediaSpy = jest.spyOn(provider, 'sendMedia').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendImage('5491155551234', 'https://example.com/photo.jpg', 'Photo caption') - - expect(sendMediaSpy).toHaveBeenCalledWith( - '5491155551234', - 'Photo caption', - 'https://example.com/photo.jpg', - expect.objectContaining({ mediaType: 'image' }) - ) - }) - - test('should keep sendFile as alias to sendMedia with file mediaType', async () => { - const sendMediaSpy = jest.spyOn(provider, 'sendMedia').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendFile('5491155551234', 'https://example.com/report.pdf', 'File caption') - - expect(sendMediaSpy).toHaveBeenCalledWith( - '5491155551234', - 'File caption', - 'https://example.com/report.pdf', - expect.objectContaining({ mediaType: 'file' }) - ) - }) - - test('should map sendButtonUrl payload to sendCtaUrl', async () => { - const sendCtaUrlSpy = jest.spyOn(provider, 'sendCtaUrl').mockResolvedValue({ status: 'sent' } as any) - - await provider.sendButtonUrl( - '5491155551234', - { - text: 'Abrir web', - url: 'https://example.com', - }, - ['Linea 1', 'Linea 2'] - ) - - expect(sendCtaUrlSpy).toHaveBeenCalledWith( - '5491155551234', - { - display_text: 'Abrir web', - url: 'https://example.com', - }, - 'Linea 1\nLinea 2' - ) - }) - - test('should force image payload for sendImage even with .pdf extension', async () => { - const mockPost = mockHttpPost() - - await provider.sendImage('5491155551234', 'https://example.com/files/manual.pdf', 'Preview as image') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('image') - expect(messagePayload.originalUrl).toBe('https://example.com/files/manual.pdf') - }) - - test('should force file payload for sendFile even with .jpg extension', async () => { - const mockPost = mockHttpPost() - - await provider.sendFile('5491155551234', 'https://example.com/files/photo.jpg', 'Send as file') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload.type).toBe('file') - expect(messagePayload.url).toBe('https://example.com/files/photo.jpg') - }) - }) - - describe('#sendLocation, #sendLocationRequest and #sendReaction', () => { - test('should send location payload', async () => { - const mockPost = mockHttpPost() - - await provider.sendLocation('5491155551234', { - latitude: '-34.6037', - longitude: '-58.3816', - name: 'CABA', - address: 'Buenos Aires', - }) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload).toEqual( - expect.objectContaining({ - type: 'location', - latitude: '-34.6037', - longitude: '-58.3816', - }) - ) - }) - - test('should send location request payload through partner passthrough endpoint', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'location-pass-1' } } as any) - const sessionPost = jest.fn() - ;(providerWithPartner as any).http = { post: sessionPost } - - await providerWithPartner.sendLocationRequest('5491155551234', 'Please share your current location') - - expect(mockedAxios.post).toHaveBeenCalledWith( - 'https://partner.gupshup.io/partner/app/partner-app-id/v3/message', - expect.any(URLSearchParams), - { - headers: { - Authorization: 'partner-app-token', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ) - expect(sessionPost).not.toHaveBeenCalled() - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('interactive') - expect(calledParams.get('payload')).toBeNull() - - const interactivePayload = JSON.parse(calledParams.get('interactive') || '{}') - expect(interactivePayload).toEqual({ - type: 'location_request_message', - body: { - text: 'Please share your current location', - }, - action: { - name: 'send_location', - }, - }) - }) - - test('should fail when location request text is empty', async () => { - await expect(provider.sendLocationRequest('5491155551234', ' ')).rejects.toThrow( - 'Location request body text is required' - ) - }) - - test('should fail when partner config is missing for location request passthrough', async () => { - await expect(provider.sendLocationRequest('5491155551234', 'Please share your location')).rejects.toThrow( - 'Partner app config is required. Provide partner.appId and partner.appToken.' - ) - }) - - test('should keep compatibility with requestLocation alias', async () => { - const sendLocationRequestSpy = jest - .spyOn(provider, 'sendLocationRequest') - .mockResolvedValue({ status: 'sent' } as any) - - await provider.requestLocation('5491155551234', 'Share your location please') - - expect(sendLocationRequestSpy).toHaveBeenCalledWith('5491155551234', 'Share your location please') - }) - - test('should send reaction payload', async () => { - const mockPost = mockHttpPost() - - await provider.sendReaction('5491155551234', { - msgId: 'wamid.123', - emoji: '✅', - }) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload).toEqual({ - type: 'reaction', - msgId: 'wamid.123', - emoji: '✅', - }) - }) - - test('should normalize reaction payload when message_id alias is provided', async () => { - const mockPost = mockHttpPost() - - await provider.sendReaction('5491155551234', { - message_id: 'wamid.meta.1', - emoji: '✅', - }) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload).toEqual({ - type: 'reaction', - msgId: 'wamid.meta.1', - emoji: '✅', - }) - }) - - test('should normalize reaction payload when messageId alias is provided', async () => { - const mockPost = mockHttpPost() - - await provider.sendReaction('5491155551234', { - messageId: 'wamid.meta.2', - emoji: '✅', - }) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const messagePayload = JSON.parse(calledParams.get('message') || '{}') - - expect(messagePayload).toEqual({ - type: 'reaction', - msgId: 'wamid.meta.2', - emoji: '✅', - }) - }) - }) - - describe('#sendTemplate', () => { - test('should send template payload to template endpoint with request-object signature', async () => { - const mockPost = mockHttpPost({ data: { templateId: 'tpl-1' } }) - - await provider.sendTemplate('5491155551234', { - template: { - id: 'template-id', - params: ['A', 'B'], - }, - }) - - expect(mockPost).toHaveBeenCalledWith('/template/msg', expect.any(URLSearchParams)) - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload.id).toBe('template-id') - }) - - test('should accept Meta-style sendTemplate signature', async () => { - const mockPost = mockHttpPost({ data: { templateId: 'tpl-2' } }) - - await provider.sendTemplate('5491155551234', 'meta-template-id', 'es_AR', [ - { - type: 'body', - parameters: [ - { type: 'text', text: 'Juan' }, - { type: 'text', text: 'Premium' }, - ], - }, - ]) - - expect(mockPost).toHaveBeenCalledWith('/template/msg', expect.any(URLSearchParams)) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload).toEqual({ - id: 'meta-template-id', - languageCode: 'es_AR', - params: ['Juan', 'Premium'], - }) - }) - - test('should accept template id without languageCode', async () => { - const mockPost = mockHttpPost({ data: { templateId: 'tpl-3' } }) - - await provider.sendTemplate('5491155551234', 'template-only-id') - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload).toEqual({ - id: 'template-only-id', - }) - }) - - test('should accept template components without languageCode', async () => { - const mockPost = mockHttpPost({ data: { templateId: 'tpl-4' } }) - - await provider.sendTemplate('5491155551234', 'template-components-only', [ - { - type: 'body', - parameters: [{ type: 'text', text: 'Solo param' }], - }, - ]) - - const calledParams = mockPost.mock.calls[0][1] as URLSearchParams - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload).toEqual({ - id: 'template-components-only', - params: ['Solo param'], - }) - }) - - test('should auto-route flow template components to partner passthrough when partner config exists', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'flow-template-1' } } as any) - - await providerWithPartner.sendTemplate('5491155551234', 'flow_template_name', 'es_AR', [ - { - type: 'button', - parameters: [ - { - type: 'action', - action: { - flow_token: 'flow_token_123', - }, - } as any, - ], - }, - ]) - - expect(mockedAxios.post).toHaveBeenCalledWith( - 'https://partner.gupshup.io/partner/app/partner-app-id/v3/message', - expect.any(URLSearchParams), - { - headers: { - Authorization: 'partner-app-token', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ) - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('template') - expect(calledParams.get('payload')).toBeNull() - - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload).toEqual( - expect.objectContaining({ - name: 'flow_template_name', - language: { code: 'es_AR' }, - }) - ) - }) - - test('should throw clear error when flow template components are present without partner config', async () => { - await expect( - provider.sendTemplate('5491155551234', 'flow_template_name', 'es_AR', [ - { - type: 'button', - parameters: [ - { - type: 'action', - action: { - flow_token: 'flow_token_123', - }, - } as any, - ], - }, - ]) - ).rejects.toThrow('Partner app config is required. Provide partner.appId and partner.appToken.') - }) - - test('should keep legacy non-flow template route on /template/msg', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - const post = jest.fn(async () => ({ data: { templateId: 'legacy-template-1' } })) - ;(providerWithPartner as any).http = { post } - - await providerWithPartner.sendTemplate('5491155551234', 'legacy_template_name', 'es_AR', [ - { - type: 'body', - parameters: [{ type: 'text', text: 'Juan' }], - }, - ]) - - expect(post as any).toHaveBeenCalledWith('/template/msg', expect.any(URLSearchParams)) - expect(mockedAxios.post).not.toHaveBeenCalled() - }) - }) - - describe('#sendFlow and #sendTemplatePassthrough', () => { - test('should send flow payload to partner passthrough v3 endpoint', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'flow-1' } } as any) - - await providerWithPartner.sendFlow('5491155551234', { - header: 'Flow header', - body: 'Flow body', - footer: 'Flow footer', - flowMessageVersion: '3', - flowAction: 'navigate', - flowToken: 'flow-token', - flowId: 'flow-id', - flowCta: 'Open flow', - flowActionPayload: { - screen: 'WELCOME', - }, - isDraftFlow: true, - }) - - expect(mockedAxios.post).toHaveBeenCalledWith( - 'https://partner.gupshup.io/partner/app/partner-app-id/v3/message', - expect.any(URLSearchParams), - { - headers: { - Authorization: 'partner-app-token', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ) - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('interactive') - expect(calledParams.get('payload')).toBeNull() - - const interactivePayload = JSON.parse(calledParams.get('interactive') || '{}') - expect(interactivePayload.type).toBe('flow') - expect(interactivePayload.action.parameters).toEqual( - expect.objectContaining({ - flow_message_version: '3', - flow_token: 'flow-token', - flow_id: 'flow-id', - flow_cta: 'Open flow', - flow_action: 'navigate', - flow_action_payload: { screen: 'WELCOME' }, - mode: 'draft', - }) - ) - }) - - test('should send template passthrough payload to partner v3 endpoint', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - baseUrl: 'https://custom-partner.gupshup.io', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'template-pass-1' } } as any) - - await providerWithPartner.sendTemplatePassthrough('5491155551234', { - name: 'welcome_flow_template', - language: { code: 'es_AR' }, - components: [ - { - type: 'body', - parameters: [{ type: 'text', text: 'Juan' }], - }, - ], - }) - - expect(mockedAxios.post).toHaveBeenCalledWith( - 'https://custom-partner.gupshup.io/partner/app/partner-app-id/v3/message', - expect.any(URLSearchParams), - { - headers: { - Authorization: 'partner-app-token', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ) - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('template') - expect(calledParams.get('payload')).toBeNull() - - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload).toEqual( - expect.objectContaining({ - name: 'welcome_flow_template', - language: { code: 'es_AR' }, - }) - ) - }) - - test('should normalize flow passthrough components missing sub_type and index', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'template-pass-2' } } as any) - - await providerWithPartner.sendTemplatePassthrough('5491155551234', { - name: 'welcome_flow_template', - language: { code: 'es_AR' }, - components: [ - { - parameters: [ - { - type: 'action', - action: { - flow_token: 'flow_token_123', - }, - } as any, - ], - }, - ], - }) - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('template') - expect(calledParams.get('payload')).toBeNull() - - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload.components[0]).toEqual( - expect.objectContaining({ - type: 'button', - sub_type: 'flow', - index: '0', - }) - ) - }) - - test('should stringify numeric flow button index in passthrough payload', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'template-pass-3' } } as any) - - await providerWithPartner.sendTemplatePassthrough('5491155551234', { - name: 'welcome_flow_template', - language: { code: 'es_AR' }, - components: [ - { - type: 'button', - sub_type: 'flow', - index: 2 as any, - parameters: [ - { - type: 'action', - action: { - flow_token: 'flow_token_123', - }, - } as any, - ], - }, - ], - }) - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('template') - expect(calledParams.get('payload')).toBeNull() - - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload.components[0].index).toBe('2') - }) - - test('should force flow sub_type when flow action component has invalid sub_type', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'template-pass-5' } } as any) - - await providerWithPartner.sendTemplatePassthrough('5491155551234', { - name: 'welcome_flow_template', - language: { code: 'es_AR' }, - components: [ - { - type: 'button', - sub_type: 'quick_reply' as any, - index: '0', - parameters: [ - { - type: 'action', - action: { - flow_token: 'flow_token_123', - }, - } as any, - ], - }, - ], - }) - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('template') - expect(calledParams.get('payload')).toBeNull() - - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload.components[0].sub_type).toBe('flow') - }) - - test('should trim string flow button index before passthrough send', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'template-pass-6' } } as any) - - await providerWithPartner.sendTemplatePassthrough('5491155551234', { - name: 'welcome_flow_template', - language: { code: 'es_AR' }, - components: [ - { - type: 'button', - sub_type: 'flow', - index: ' 7 ' as any, - parameters: [ - { - type: 'action', - action: { - flow_token: 'flow_token_123', - }, - } as any, - ], - }, - ], - }) - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('template') - expect(calledParams.get('payload')).toBeNull() - - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload.components[0].index).toBe('7') - }) - - test('should normalize passthrough language string to language.code', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - mockedAxios.post.mockResolvedValue({ data: { messageId: 'template-pass-4' } } as any) - - await providerWithPartner.sendTemplatePassthrough('5491155551234', { - name: 'welcome_flow_template', - language: 'es_AR', - components: [ - { - type: 'button', - parameters: [ - { - type: 'action', - action: { - flow_token: 'flow_token_123', - }, - } as any, - ], - }, - ], - }) - - const calledParams = mockedAxios.post.mock.calls[ - mockedAxios.post.mock.calls.length - 1 - ][1] as URLSearchParams - expect(calledParams.get('messaging_product')).toBe('whatsapp') - expect(calledParams.get('recipient_type')).toBe('individual') - expect(calledParams.get('to')).toBe('5491155551234') - expect(calledParams.get('type')).toBe('template') - expect(calledParams.get('payload')).toBeNull() - - const templatePayload = JSON.parse(calledParams.get('template') || '{}') - expect(templatePayload.language).toEqual({ code: 'es_AR' }) - }) - - test('should throw clear error for uuid-like flow template identifier in passthrough', async () => { - const providerWithPartner = new GupshupProvider({ - ...mockArgs, - partner: { - appId: 'partner-app-id', - appToken: 'partner-app-token', - }, - }) - - await expect( - providerWithPartner.sendTemplatePassthrough('5491155551234', { - name: '123e4567-e89b-42d3-a456-426614174000', - language: 'es_AR', - components: [ - { - type: 'button', - parameters: [ - { - type: 'action', - action: { - flow_token: 'flow_token_123', - }, - } as any, - ], - }, - ], - }) - ).rejects.toThrow( - 'Flow template passthrough expects template name (not a UUID/numeric template id). Use template.name from Meta.' - ) - }) - }) - - describe('#saveFile', () => { - test('should download media with apikey header for trusted gupshup host', async () => { - ;(utils.generalDownload as any).mockResolvedValue('/tmp/file.jpg') - - const result = await provider.saveFile({ url: 'https://api.gupshup.io/media/file.jpg' }) - - expect(utils.generalDownload).toHaveBeenCalledWith('https://api.gupshup.io/media/file.jpg', undefined, { - apikey: 'test-api-key', - }) - expect(result).toBe('/tmp/file.jpg') - }) - - test('should not attach apikey header for trusted host over http', async () => { - ;(utils.generalDownload as any).mockResolvedValue('/tmp/file.jpg') - - const result = await provider.saveFile({ url: 'http://api.gupshup.io/media/file.jpg' }) - - expect(utils.generalDownload).toHaveBeenCalledWith( - 'http://api.gupshup.io/media/file.jpg', - undefined, - undefined - ) - expect(result).toBe('/tmp/file.jpg') - }) - - test('should download media without apikey header for untrusted host', async () => { - ;(utils.generalDownload as any).mockResolvedValue('/tmp/file.jpg') - - const result = await provider.saveFile({ url: 'https://example.com/file.jpg' }) - - expect(utils.generalDownload).toHaveBeenCalledWith('https://example.com/file.jpg', undefined, undefined) - expect(result).toBe('/tmp/file.jpg') - }) - - test('should use mediaId when url is not present', async () => { - const resolveMediaUrlFromIdSpy = jest - .spyOn(provider as any, 'resolveMediaUrlFromId') - .mockResolvedValue('https://example.com/from-id.jpg') - ;(utils.generalDownload as any).mockResolvedValue('/tmp/from-id.jpg') - - const result = await provider.saveFile({ mediaId: 'media-123' }) - - expect(resolveMediaUrlFromIdSpy).toHaveBeenCalledWith('media-123') - expect(result).toBe('/tmp/from-id.jpg') - }) - - test('should return ERROR when media url cannot be resolved', async () => { - const resolveMediaUrlFromIdSpy = jest - .spyOn(provider as any, 'resolveMediaUrlFromId') - .mockResolvedValue(null) - - const result = await provider.saveFile({ mediaId: 'media-404' }) - - expect(resolveMediaUrlFromIdSpy).toHaveBeenCalledWith('media-404') - expect(result).toBe('ERROR') - }) - }) - - describe('#serveRegisteredLocalMedia', () => { - test('should return 404 when token does not exist', async () => { - const mockReq = { - params: { - token: 'missing-token', - }, - } - const mockRes = { - statusCode: 0, - end: jest.fn(), - setHeader: jest.fn(), - headersSent: false, - } - - await (provider as any).serveRegisteredLocalMedia(mockReq, mockRes) - - expect(mockRes.statusCode).toBe(404) - expect(mockRes.end).toHaveBeenCalledWith('Not Found') - }) - - test('should return 404 when token is expired', async () => { - ;(provider as any).localMediaRegistry.set('expired-token', { - absolutePath: __filename, - expiresAt: Date.now() - 1, - }) - - const mockReq = { - params: { - token: 'expired-token', - }, - } - const mockRes = { - statusCode: 0, - end: jest.fn(), - setHeader: jest.fn(), - headersSent: false, - } - - await (provider as any).serveRegisteredLocalMedia(mockReq, mockRes) - - expect(mockRes.statusCode).toBe(404) - expect(mockRes.end).toHaveBeenCalledWith('Not Found') - expect((provider as any).localMediaRegistry.has('expired-token')).toBe(false) - }) - - test('should return 404 when registered file is missing', async () => { - const missingPath = `${__filename}.missing.${Date.now()}` - ;(provider as any).localMediaRegistry.set('missing-file-token', { - absolutePath: missingPath, - expiresAt: Date.now() + 60_000, - }) - - const mockReq = { - params: { - token: 'missing-file-token', - }, - } - const mockRes = { - statusCode: 0, - end: jest.fn(), - setHeader: jest.fn(), - headersSent: false, - } - - await (provider as any).serveRegisteredLocalMedia(mockReq, mockRes) - - expect(mockRes.statusCode).toBe(404) - expect(mockRes.end).toHaveBeenCalledWith('Not Found') - }) - - test('should return 500 when stream emits error', async () => { - const fakeStream = new EventEmitter() as EventEmitter & { pipe: jest.Mock } - fakeStream.pipe = jest.fn(() => { - fakeStream.emit('error', new Error('stream failed')) - }) - const createReadStreamMock = nodeFs.createReadStream as unknown as jest.Mock - createReadStreamMock.mockImplementationOnce(() => fakeStream as any) - ;(provider as any).localMediaRegistry.set('stream-error-token', { - absolutePath: __filename, - expiresAt: Date.now() + 60_000, - }) - - const mockReq = { - params: { - token: 'stream-error-token', - }, - } - const mockRes = { - statusCode: 0, - end: jest.fn(), - setHeader: jest.fn(), - headersSent: false, - } - - await (provider as any).serveRegisteredLocalMedia(mockReq, mockRes) - - expect(createReadStreamMock).toHaveBeenCalled() - expect(mockRes.statusCode).toBe(500) - expect(mockRes.end).toHaveBeenCalledWith('Error') - }) - }) - - describe('#markAsRead and #getMessageStatus', () => { - test('should mark message as read', async () => { - mockedAxios.put.mockResolvedValue({ data: { success: true } } as any) - - await provider.markAsRead('wamid.1') - - expect(mockedAxios.put).toHaveBeenCalledWith( - 'https://api.gupshup.io/wa/app/test-app-id/msg/wamid.1/read', - null, - { - headers: { - apikey: 'test-api-key', - }, - } - ) - }) - - test('should get message status', async () => { - mockedAxios.get.mockResolvedValue({ data: { status: 'read' } } as any) - - await provider.getMessageStatus('wamid.2') - - expect(mockedAxios.get).toHaveBeenCalledWith('https://api.gupshup.io/wa/app/test-app-id/msg/wamid.2', { - headers: { - apikey: 'test-api-key', - }, - }) - }) - - test('should require appId for markAsRead', async () => { - const providerWithoutAppId = new GupshupProvider({ - ...mockArgs, - appId: '', - }) - - await expect(providerWithoutAppId.markAsRead('wamid.3')).rejects.toThrow( - 'appId is required to mark messages as read' - ) - }) - }) - - describe('#afterHttpServerInit', () => { - test('should emit ready and notice events', async () => { - const emitSpy = jest.spyOn(provider, 'emit') - - await provider['afterHttpServerInit']() - - expect(emitSpy).toHaveBeenCalledWith('ready') - expect(emitSpy).toHaveBeenCalledWith( - 'notice', - expect.objectContaining({ - title: '🟢 Gupshup Provider Ready', - }) - ) - }) - }) -}) diff --git a/packages/provider-gupshup/jest.config.ts b/packages/provider-gupshup/jest.config.ts deleted file mode 100644 index 6787d605e..000000000 --- a/packages/provider-gupshup/jest.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, - coverageThreshold: { - global: { - statements: 50, - branches: 40, - functions: 45, - lines: 50, - }, - }, -} - -export default config diff --git a/packages/provider-gupshup/package.json b/packages/provider-gupshup/package.json deleted file mode 100644 index c6743724b..000000000 --- a/packages/provider-gupshup/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@builderbot/provider-gupshup", - "version": "1.4.2-alpha.11", - "description": "Gupshup Provider for BuilderBot", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "files": [ - "./dist/" - ], - "scripts": { - "build": "rimraf dist && rollup --config", - "lint": "eslint .", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "dependencies": { - "axios": "^1.6.0", - "form-data": "^4.0.5", - "mime-types": "^3.0.2", - "polka": "^0.5.2" - }, - "peerDependencies": { - "@builderbot/bot": "workspace:*" - }, - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.0.0", - "@rollup/plugin-commonjs": "^25.0.0", - "@rollup/plugin-json": "^6.0.0", - "@rollup/plugin-node-resolve": "^15.0.0", - "@types/jest": "^30.0.0", - "@types/node": "^20.0.0", - "@types/polka": "^0.5.7", - "jest": "^30.0.0", - "rimraf": "^5.0.0", - "rollup": "^4.0.0", - "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.0.0", - "typescript": "^5.0.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-gupshup/rollup.config.js b/packages/provider-gupshup/rollup.config.js deleted file mode 100644 index ca8969dfe..000000000 --- a/packages/provider-gupshup/rollup.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import commonjs from '@rollup/plugin-commonjs' -import json from '@rollup/plugin-json' -import { nodeResolve } from '@rollup/plugin-node-resolve' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - json(), - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/axios|@builderbot\/bot/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/provider-gupshup/src/gupshup/core.ts b/packages/provider-gupshup/src/gupshup/core.ts deleted file mode 100644 index 0661536ef..000000000 --- a/packages/provider-gupshup/src/gupshup/core.ts +++ /dev/null @@ -1,276 +0,0 @@ -import EventEmitter from 'node:events' -import type polka from 'polka' - -import type { - GupshupCloudChangeValue, - GupshupCloudContact, - GupshupCloudStatus, - GupshupCloudWebhookBody, - GupshupGlobalVendorArgs, -} from '../types' -import { processIncomingMessage } from '../utils/processIncomingMsg' - -const DEFAULT_WEBHOOK_DEDUPE_TTL_MS = 5 * 60 * 1000 -const MISSING_WEBHOOK_VERIFY_WARNING = [ - 'Webhook verification is disabled.', - 'Configure webhook.verify to validate request authenticity and protect this endpoint.', -] - -export class GupshupCoreVendor extends EventEmitter { - private readonly seenInboundMessageIds = new Map() - private readonly inFlightInboundMessageIds = new Set() - private hasEmittedMissingVerifyWarning = false - - constructor(private args: GupshupGlobalVendorArgs) { - super() - } - - private getDedupeTtlMs = (): number => { - const configuredValue = this.args.webhook?.dedupeTtlMs - - if (typeof configuredValue !== 'number' || configuredValue <= 0) { - return DEFAULT_WEBHOOK_DEDUPE_TTL_MS - } - - return configuredValue - } - - private pruneSeenInboundMessages = (now: number): void => { - const dedupeTtlMs = this.getDedupeTtlMs() - - for (const [messageId, expiresAt] of this.seenInboundMessageIds) { - if (expiresAt <= now) { - this.seenInboundMessageIds.delete(messageId) - } - } - } - - private shouldProcessInboundMessage = (messageId?: string): boolean => { - if (!messageId) return true - - const now = Date.now() - this.pruneSeenInboundMessages(now) - - const currentExpiry = this.seenInboundMessageIds.get(messageId) - if (typeof currentExpiry === 'number' && currentExpiry > now) { - return false - } - - if (this.inFlightInboundMessageIds.has(messageId)) { - return false - } - - this.inFlightInboundMessageIds.add(messageId) - - return true - } - - private releaseInboundMessageReservation = (messageId?: string): void => { - if (!messageId) return - this.inFlightInboundMessageIds.delete(messageId) - } - - private markInboundMessageAsSeen = (messageId?: string): void => { - if (!messageId) return - - const now = Date.now() - this.pruneSeenInboundMessages(now) - this.seenInboundMessageIds.set(messageId, now + this.getDedupeTtlMs()) - } - - private emitMissingVerifyWarningOnce = (): void => { - if (this.hasEmittedMissingVerifyWarning) return - if (this.args.webhook?.verify) return - - this.hasEmittedMissingVerifyWarning = true - this.emitNoticeSafely({ - title: '🟠 GUPSHUP SECURITY NOTICE 🟠', - instructions: MISSING_WEBHOOK_VERIFY_WARNING, - }) - } - - private emitStatusSafely = (payload: Record): void => { - try { - this.emit('status', payload) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown status listener error' - console.error('[Gupshup] Error dispatching status event:', errorMessage) - } - } - - private emitNoticeSafely = (payload: Record): void => { - try { - this.emit('notice', payload) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown notice listener error' - console.error('[Gupshup] Error dispatching notice event:', errorMessage) - } - } - - private shouldLogInbound = (): boolean => { - return this.args.logs?.inbound ?? false - } - - private shouldLogStatus = (statusName: string): boolean => { - const statusLogMode = this.args.logs?.status ?? 'failed' - - if (statusLogMode === 'all') return true - if (statusLogMode === 'failed') return statusName === 'failed' - - return false - } - - private shouldIncludeRawFailedStatus = (): boolean => { - return this.args.logs?.rawOnFailed ?? false - } - - private serializeStatus = (status: GupshupCloudStatus): string => { - try { - return JSON.stringify(status) - } catch { - return 'Unable to serialize status payload' - } - } - - private extractStatusReasons = (status: GupshupCloudStatus): string[] => { - if (!status.errors?.length) return ['No additional details'] - - return status.errors - .map((error) => { - return error.error_data?.details ?? error.details ?? error.title ?? error.message - }) - .filter((errorReason): errorReason is string => Boolean(errorReason)) - } - - private processStatuses = (value?: GupshupCloudChangeValue): void => { - if (!value?.statuses?.length) return - - for (const status of value.statuses) { - const statusName = status.status ?? 'unknown' - const recipient = status.recipient_id ?? 'unknown' - - this.emitStatusSafely({ - ...status, - status: statusName, - recipient, - }) - - if (!this.shouldLogStatus(statusName)) continue - - if (statusName === 'failed') { - const instructions = [ - `Status: ${statusName}`, - `Recipient: ${recipient}`, - ...this.extractStatusReasons(status), - ] - - if (this.shouldIncludeRawFailedStatus()) { - instructions.push(`Raw: ${this.serializeStatus(status)}`) - } - - this.emitNoticeSafely({ - title: '🔔 GUPSHUP ALERT 🔔', - instructions, - }) - continue - } - - this.emitNoticeSafely({ - title: '📨 GUPSHUP STATUS', - instructions: [`Status: ${statusName}`, `Recipient: ${recipient}`], - }) - } - } - - private findContact = (contacts: GupshupCloudContact[] = [], from?: string): GupshupCloudContact | undefined => { - if (!contacts.length) return undefined - if (!from) return contacts[0] - - return contacts.find((contact) => contact.wa_id === from) ?? contacts[0] - } - - private processChangeValue = async (value?: GupshupCloudChangeValue): Promise => { - if (!value?.messages?.length) return - - for (const message of value.messages) { - if (!this.shouldProcessInboundMessage(message.id)) continue - - try { - const botContext = await processIncomingMessage( - { - message, - contact: this.findContact(value.contacts, message.from), - metadata: value.metadata, - }, - this.args - ) - - if (botContext) { - if (this.shouldLogInbound()) { - this.emitNoticeSafely({ - title: '📩 GUPSHUP INBOUND', - instructions: [ - `From: ${botContext.from}`, - `Type: ${message.type ?? 'unknown'}`, - `Body: ${botContext.body}`, - ], - }) - } - this.emit('message', botContext) - this.markInboundMessageAsSeen(message.id) - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown inbound processing error' - console.error( - `[Gupshup] Error processing inbound message ${message.id ?? '(without id)'}:`, - errorMessage - ) - } finally { - this.releaseInboundMessageReservation(message.id) - } - } - } - - public incomingMsg: polka.Middleware = async (req: any, res: any) => { - try { - this.emitMissingVerifyWarningOnce() - - const verifyWebhook = this.args.webhook?.verify - if (verifyWebhook) { - const isAllowed = await verifyWebhook(req) - - if (!isAllowed) { - res.statusCode = 401 - res.end('Unauthorized') - return - } - } - - const body = req.body as GupshupCloudWebhookBody | undefined - const entries = body?.entry - - if (!entries?.length) { - res.statusCode = 200 - res.end('OK') - return - } - - for (const entry of entries) { - if (!entry.changes?.length) continue - - for (const change of entry.changes) { - if (change.field && !['messages', 'statuses'].includes(change.field)) continue - this.processStatuses(change.value) - await this.processChangeValue(change.value) - } - } - - res.statusCode = 200 - res.end('OK') - } catch (e) { - console.error('Webhook Error:', e) - res.statusCode = 500 - res.end('Error') - } - } -} diff --git a/packages/provider-gupshup/src/gupshup/provider.ts b/packages/provider-gupshup/src/gupshup/provider.ts deleted file mode 100644 index 4d0367e1c..000000000 --- a/packages/provider-gupshup/src/gupshup/provider.ts +++ /dev/null @@ -1,1261 +0,0 @@ -import { ProviderClass, utils } from '@builderbot/bot' -import { Vendor } from '@builderbot/bot/dist/provider/interface/provider' -import { BotContext, Button, SendOptions } from '@builderbot/bot/dist/types' -import axios, { AxiosInstance } from 'axios' -import mime from 'mime-types' -import { randomUUID } from 'node:crypto' -import { createReadStream } from 'node:fs' -import { stat } from 'node:fs/promises' -import { resolve } from 'node:path' - -import { GupshupCoreVendor } from './core' -import { - GupshupCompatibleListMessage, - GupshupCtaMessage, - GupshupFlowSendRequest, - GupshupGlobalVendorArgs, - GupshupListMessage, - GupshupLocationMessage, - GupshupLocationRequestMessage, - GupshupMetaTemplateComponent, - GupshupMetaTemplatePassthroughRequest, - GupshupReactionMessage, - GupshupSessionSendOptions, - GupshupTemplateLanguageOrComponents, - GupshupTemplateSendRequest, -} from '../types' -import { extractFileNameFromInput, inferSessionMediaTypeFromInput, isHttpUrl } from '../utils/media' - -const SESSION_BASE_URL = 'https://api.gupshup.io/wa/api/v1' -const APP_BASE_URL = 'https://api.gupshup.io/wa/app' -const PARTNER_BASE_URL = 'https://partner.gupshup.io' -const PARTNER_APP_CONFIG_REQUIRED_ERROR = 'Partner app config is required. Provide partner.appId and partner.appToken.' -const GUPSHUP_CHANNEL = 'whatsapp' -const MAX_QUICK_REPLY_OPTIONS = 3 -const LOCAL_MEDIA_ROUTE_BASE = '/local-media' -const DEFAULT_LOCAL_MEDIA_TTL_MS = 5 * 60 * 1000 -const TRUSTED_MEDIA_HOST_SUFFIX = '.gupshup.io' -const CLOUD_ENV_HINTS = [ - 'K_SERVICE', - 'K_REVISION', - 'WEBSITE_SITE_NAME', - 'RENDER', - 'RAILWAY_ENVIRONMENT', - 'DYNO', - 'VERCEL', -] - -type LocalMediaRegistration = { - absolutePath: string - expiresAt: number -} - -type NormalizedSendOptions = SendOptions & GupshupSessionSendOptions - -export class GupshupProvider extends ProviderClass { - public vendor: Vendor - public globalVendorArgs: GupshupGlobalVendorArgs = { - name: 'bot', - port: 3000, - apiKey: '', - srcName: '', - phoneNumber: '', - appId: '', - logs: { - inbound: false, - status: 'failed', - outboundErrors: true, - rawOnFailed: false, - }, - } - private http: AxiosInstance - private readonly localMediaRegistry = new Map() - private inferredPublicBaseUrl: string | null = null - - constructor(args: GupshupGlobalVendorArgs) { - super() - this.globalVendorArgs = { - ...this.globalVendorArgs, - ...args, - logs: { - ...this.globalVendorArgs.logs, - ...args?.logs, - }, - } - - this.http = axios.create({ - baseURL: SESSION_BASE_URL, - headers: { - apikey: this.globalVendorArgs.apiKey, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }) - } - - protected beforeHttpServerInit(): void { - this.server = this.server - .use((req, _, next) => { - req['globalVendorArgs'] = this.globalVendorArgs - this.captureInferredBaseUrl(req) - return next() - }) - .get(`${LOCAL_MEDIA_ROUTE_BASE}/:token`, this.serveRegisteredLocalMedia) - .post('/webhook', this.vendor.incomingMsg) - } - - protected async afterHttpServerInit(): Promise { - try { - this.emit('ready') - this.emit('notice', { - title: '🟢 Gupshup Provider Ready', - instructions: ['Webhook URI: /webhook'], - }) - } catch (error) { - console.error(error) - } - } - - protected initVendor(): Promise { - const vendor = new GupshupCoreVendor(this.globalVendorArgs) - this.vendor = vendor - return Promise.resolve(vendor) - } - - protected busEvents = () => [ - { event: 'message', func: (payload: BotContext) => this.emit('message', payload) }, - { event: 'notice', func: (payload: any) => this.emit('notice', payload) }, - { event: 'status', func: (payload: any) => this.emit('status', payload) }, - ] - - private normalizeSendOptions = (options?: SendOptions): NormalizedSendOptions => { - const rawOptions = options ?? {} - const nestedOptions = - typeof rawOptions.options === 'object' && rawOptions.options !== null - ? (rawOptions.options as Record) - : {} - - return { - ...rawOptions, - ...nestedOptions, - } - } - - private getLocalMediaTtlMs = (): number => { - const configuredTtl = this.globalVendorArgs.localMedia?.ttlMs - if (typeof configuredTtl !== 'number' || configuredTtl <= 0) { - return DEFAULT_LOCAL_MEDIA_TTL_MS - } - - return configuredTtl - } - - private normalizeBaseUrl = (url: string): string => { - return url.replace(/\/+$/, '') - } - - private sanitizeProtocol = (value?: string): 'http' | 'https' | null => { - if (typeof value !== 'string' || !value.trim()) return null - - const normalizedValue = value.split(',')[0].trim().toLowerCase() - if (normalizedValue === 'http' || normalizedValue === 'https') { - return normalizedValue - } - - return null - } - - private sanitizeHost = (value?: string): string | null => { - if (typeof value !== 'string' || !value.trim()) return null - - const normalizedValue = value.split(',')[0].trim() - const hostRegex = /^(\[[a-fA-F0-9:]+\]|[a-zA-Z0-9.-]+)(:\d{1,5})?$/ - - if (!hostRegex.test(normalizedValue)) { - return null - } - - return normalizedValue - } - - private isTrustedInferredHostname = (hostname: string): boolean => { - const normalizedHostname = hostname.toLowerCase() - return normalizedHostname === 'localhost' || normalizedHostname === '127.0.0.1' || normalizedHostname === '::1' - } - - private toSafeBaseUrl = (protocol: 'http' | 'https', host: string): string | null => { - try { - const parsedUrl = new URL(`${protocol}://${host}`) - if (!this.isTrustedInferredHostname(parsedUrl.hostname)) { - return null - } - - return this.normalizeBaseUrl(parsedUrl.origin) - } catch { - return null - } - } - - private getHeader = (req: any, headerName: string): string | undefined => { - const rawValue = req?.headers?.[headerName] - if (typeof rawValue === 'string') return rawValue - if (Array.isArray(rawValue) && typeof rawValue[0] === 'string') return rawValue[0] - return undefined - } - - private inferBaseUrlFromRequest = (req: any): string | null => { - const forwardedHost = this.sanitizeHost(this.getHeader(req, 'x-forwarded-host')) - const directHost = this.sanitizeHost(this.getHeader(req, 'host')) - const host = forwardedHost ?? directHost - if (!host) return null - - const forwardedProtocol = this.sanitizeProtocol(this.getHeader(req, 'x-forwarded-proto')) - const requestProtocol = this.sanitizeProtocol(typeof req?.protocol === 'string' ? req.protocol : undefined) - const protocol = forwardedProtocol ?? requestProtocol - if (!protocol) return null - - return this.toSafeBaseUrl(protocol, host) - } - - private captureInferredBaseUrl = (req: any): void => { - const inferredBaseUrl = this.inferBaseUrlFromRequest(req) - if (inferredBaseUrl) { - this.inferredPublicBaseUrl = inferredBaseUrl - } - } - - private resolvePublicBaseUrl = (): string => { - const configuredPublicUrl = this.globalVendorArgs.publicUrl - if (typeof configuredPublicUrl === 'string' && configuredPublicUrl.trim()) { - try { - const parsedUrl = new URL(configuredPublicUrl.trim()) - const protocol = this.sanitizeProtocol(parsedUrl.protocol.replace(':', '')) - const host = this.sanitizeHost(parsedUrl.host) - if (protocol && host) { - return this.normalizeBaseUrl(parsedUrl.origin) - } - } catch { - // Ignore invalid public URL and fallback to safe defaults - } - } - - if (this.inferredPublicBaseUrl) { - return this.inferredPublicBaseUrl - } - - const isProductionRuntime = process.env.NODE_ENV === 'production' - const hasCloudHint = CLOUD_ENV_HINTS.some( - (envName) => typeof process.env[envName] === 'string' && process.env[envName] - ) - - if (isProductionRuntime || hasCloudHint) { - throw new Error('publicUrl is required to serve local media in production/cloud environments') - } - - return `http://localhost:${this.globalVendorArgs.port}` - } - - private pruneLocalMediaRegistry = (now: number = Date.now()): void => { - for (const [token, registration] of this.localMediaRegistry) { - if (registration.expiresAt <= now) { - this.localMediaRegistry.delete(token) - } - } - } - - private registerLocalMedia = (mediaInput: string): string => { - const absolutePath = resolve(mediaInput) - const token = randomUUID() - const now = Date.now() - - this.pruneLocalMediaRegistry(now) - this.localMediaRegistry.set(token, { - absolutePath, - expiresAt: now + this.getLocalMediaTtlMs(), - }) - - return `${this.resolvePublicBaseUrl()}${LOCAL_MEDIA_ROUTE_BASE}/${token}` - } - - private serveRegisteredLocalMedia = async (req: any, res: any): Promise => { - const token = req?.params?.token - - if (!token || typeof token !== 'string') { - res.statusCode = 404 - res.end('Not Found') - return - } - - this.pruneLocalMediaRegistry() - const registration = this.localMediaRegistry.get(token) - if (!registration) { - res.statusCode = 404 - res.end('Not Found') - return - } - - try { - const fileStats = await stat(registration.absolutePath) - if (!fileStats.isFile()) { - res.statusCode = 404 - res.end('Not Found') - return - } - - const contentType = mime.lookup(registration.absolutePath) - if (typeof contentType === 'string') { - res.setHeader('Content-Type', contentType) - } else { - res.setHeader('Content-Type', 'application/octet-stream') - } - - res.setHeader('Content-Length', String(fileStats.size)) - - const stream = createReadStream(registration.absolutePath) - stream.on('error', () => { - if (!res.headersSent) { - res.statusCode = 500 - res.end('Error') - } - }) - stream.pipe(res) - } catch { - res.statusCode = 404 - res.end('Not Found') - } - } - - private shouldLogOutboundErrors = (): boolean => { - return this.globalVendorArgs.logs?.outboundErrors ?? true - } - - private formatOutboundError = (error: unknown): string => { - if (axios.isAxiosError(error)) { - const status = error.response?.status - const details = JSON.stringify(error.response?.data ?? error.message) - return `Gupshup API error${status ? ` (${status})` : ''}: ${details}` - } - - if (error instanceof Error) return error.message - - return 'Unknown outbound error' - } - - private emitOutboundErrorNotice = (to: string, messageType: string, error: unknown): void => { - if (!this.shouldLogOutboundErrors()) return - - this.emit('notice', { - title: '🔔 GUPSHUP ALERT 🔔', - instructions: [`Outbound failed (${messageType})`, `To: ${to}`, this.formatOutboundError(error)], - }) - } - - private createReplyContext = (replyTo?: string): Record => { - if (!replyTo) return {} - - return { - context: { - msgId: replyTo, - }, - } - } - - private buildSessionBody = ( - to: string, - message: Record, - includeSrcName = true - ): URLSearchParams => { - const body = new URLSearchParams() - body.append('channel', GUPSHUP_CHANNEL) - body.append('source', this.globalVendorArgs.phoneNumber) - body.append('destination', to) - - if (includeSrcName && this.globalVendorArgs.srcName) { - body.append('src.name', this.globalVendorArgs.srcName) - } - - body.append('message', JSON.stringify(message)) - return body - } - - private postSessionMessage = async ( - to: string, - payload: Record, - messageType: string, - includeSrcName = true - ): Promise => { - const body = this.buildSessionBody(to, payload, includeSrcName) - - try { - const response = await this.http.post('/msg', body) - return response.data - } catch (error) { - this.emitOutboundErrorNotice(to, messageType, error) - throw error - } - } - - private resolveMediaUrlFromApiPayload = (payload: any): string | null => { - const candidates = [ - payload?.url, - payload?.mediaUrl, - payload?.downloadUrl, - payload?.originalUrl, - payload?.data?.url, - payload?.data?.mediaUrl, - payload?.payload?.url, - ] - - const foundUrl = candidates.find((value) => typeof value === 'string' && value.length > 0) - return foundUrl ?? null - } - - private resolveMediaUrlFromId = async (mediaId: string): Promise => { - if (!mediaId) return null - - const appId = this.globalVendorArgs.appId - const endpoints = [ - `${SESSION_BASE_URL}/media/${encodeURIComponent(mediaId)}`, - `${SESSION_BASE_URL}/msg/${encodeURIComponent(mediaId)}`, - ...(appId ? [`${APP_BASE_URL}/${encodeURIComponent(appId)}/media/${encodeURIComponent(mediaId)}`] : []), - ] - - for (const endpoint of endpoints) { - try { - const response = await axios.get(endpoint, { - headers: { - apikey: this.globalVendorArgs.apiKey, - }, - }) - - const mediaUrl = this.resolveMediaUrlFromApiPayload(response.data) - if (mediaUrl) return mediaUrl - } catch { - continue - } - } - - return null - } - - private resolveMediaUrlFromContext = async ( - ctx: Partial & { mediaId?: string } - ): Promise => { - const url = - (typeof ctx?.url === 'string' && ctx.url.length > 0 ? ctx.url : null) ?? - ((ctx as any)?.data?.media?.url as string | undefined) ?? - null - - if (url) return url - - const mediaId = - (typeof ctx?.mediaId === 'string' && ctx.mediaId.length > 0 ? ctx.mediaId : null) ?? - ((ctx as any)?.id as string | undefined) ?? - null - - if (!mediaId) return null - - return this.resolveMediaUrlFromId(mediaId) - } - - private assertRemoteMediaUrl = (mediaInput: string): string => { - if (isHttpUrl(mediaInput)) return mediaInput - - throw new Error('Gupshup session messages require a public URL for media payloads') - } - - private resolveMediaInput = async (mediaInput: string): Promise => { - if (isHttpUrl(mediaInput)) return mediaInput - - if (!this.globalVendorArgs.resolveMediaUrl) { - return this.registerLocalMedia(mediaInput) - } - - const resolvedInput = await this.globalVendorArgs.resolveMediaUrl(mediaInput) - - return this.assertRemoteMediaUrl(resolvedInput) - } - - private shouldAttachApiKeyForMediaUrl = (mediaUrl: string): boolean => { - try { - const parsedUrl = new URL(mediaUrl) - if (parsedUrl.protocol !== 'https:') { - return false - } - - const hostname = parsedUrl.hostname.toLowerCase() - return hostname === 'gupshup.io' || hostname.endsWith(TRUSTED_MEDIA_HOST_SUFFIX) - } catch { - return false - } - } - - private isMetaListPayload = ( - list: GupshupCompatibleListMessage - ): list is Extract => { - return (list as any)?.type === 'list' && Array.isArray((list as any)?.action?.sections) - } - - private normalizeListPayload = (list: GupshupCompatibleListMessage): GupshupListMessage => { - if (!this.isMetaListPayload(list)) return list - - const bodyParts = [list.body?.text, list.footer?.text].filter( - (value): value is string => typeof value === 'string' && value.trim().length > 0 - ) - - return { - title: list.header?.text, - body: bodyParts.join('\n'), - buttonTitle: list.action.button, - items: list.action.sections.map((section) => ({ - title: section.title, - options: section.rows.map((row) => ({ - title: row.title, - description: row.description, - postbackText: row.id || row.title, - })), - })), - } - } - - private buildQuickReplyOptions = (buttons: Button[] = []): Array<{ title: string; postbackText: string }> => { - const parsedButtons = buttons - .map((button) => { - const title = String(button?.body ?? '').trim() - const postbackText = String((button as any)?.payload ?? title).trim() - - if (!title) return null - - return { - title, - postbackText, - } - }) - .filter((button): button is { title: string; postbackText: string } => Boolean(button)) - - return parsedButtons.slice(0, MAX_QUICK_REPLY_OPTIONS) - } - - public sendMessage = async (to: string, message: string, options?: SendOptions): Promise => { - const normalizedOptions = this.normalizeSendOptions(options) - - if (normalizedOptions.flow) { - return this.sendFlow(to, normalizedOptions.flow) - } - - if (normalizedOptions.templatePassthrough) { - return this.sendTemplatePassthrough(to, normalizedOptions.templatePassthrough) - } - - if (normalizedOptions.template) { - return this.sendTemplate(to, normalizedOptions.template) - } - - if (normalizedOptions.reaction) { - return this.sendReaction(to, normalizedOptions.reaction) - } - - if (normalizedOptions.locationRequest) { - const locationRequestBody = - typeof normalizedOptions.locationRequest === 'string' - ? normalizedOptions.locationRequest - : normalizedOptions.locationRequest.bodyText - - return this.sendLocationRequest(to, locationRequestBody ?? message) - } - - if (normalizedOptions.location) { - return this.sendLocation(to, normalizedOptions.location) - } - - if (normalizedOptions.list) { - if ((normalizedOptions.list as any)?.type === 'list') { - const metaList = normalizedOptions.list as Extract - - return this.sendList(to, { - ...metaList, - body: { - ...metaList.body, - text: metaList.body?.text ?? message, - }, - }) - } - - return this.sendList(to, { - ...(normalizedOptions.list as GupshupListMessage), - body: (normalizedOptions.list as GupshupListMessage).body ?? message, - }) - } - - if (normalizedOptions.ctaUrl) { - return this.sendCtaUrl(to, normalizedOptions.ctaUrl, message) - } - - if (normalizedOptions.buttons?.length) { - return this.sendButtons(to, message, normalizedOptions.buttons, normalizedOptions) - } - - if (normalizedOptions.media) { - return this.sendMedia(to, message, normalizedOptions.media, normalizedOptions) - } - - return this.sendText(to, message, normalizedOptions) - } - - public saveFile = async ( - ctx: Partial & { mediaId?: string }, - options?: { path: string } - ): Promise => { - try { - const mediaUrl = await this.resolveMediaUrlFromContext(ctx) - if (!mediaUrl) return 'ERROR' - - const requestHeaders = this.shouldAttachApiKeyForMediaUrl(mediaUrl) - ? { - apikey: this.globalVendorArgs.apiKey, - } - : undefined - - const localPath = await utils.generalDownload(mediaUrl, options?.path, requestHeaders) - - return localPath - } catch (error) { - console.error('[Gupshup] Error saving file:', error instanceof Error ? error.message : error) - return 'ERROR' - } - } - - public sendText = async (to: string, text: string, options: GupshupSessionSendOptions = {}): Promise => { - const payload = { - type: 'text', - text, - previewUrl: options.previewUrl ?? false, - ...this.createReplyContext(options.replyTo), - } - - return this.postSessionMessage(to, payload, 'text') - } - - public sendMedia = async ( - to: string, - caption: string, - mediaInput: string, - options: GupshupSessionSendOptions = {} - ): Promise => { - const mediaUrl = await this.resolveMediaInput(mediaInput) - const inferredMediaTypeFromInput = inferSessionMediaTypeFromInput(mediaInput, 'image') - const inferredMediaType = inferSessionMediaTypeFromInput(mediaUrl, inferredMediaTypeFromInput) - const mediaType = options.mediaType ?? inferredMediaType - const context = this.createReplyContext(options.replyTo) - - let payload: Record - - switch (mediaType) { - case 'image': - payload = { - type: 'image', - originalUrl: mediaUrl, - previewUrl: mediaUrl, - caption, - ...context, - } - break - - case 'video': - payload = { - type: 'video', - url: mediaUrl, - previewUrl: mediaUrl, - caption, - ...context, - } - break - - case 'audio': - payload = { - type: 'audio', - url: mediaUrl, - ...context, - } - break - - case 'sticker': - payload = { - type: 'sticker', - url: mediaUrl, - ...context, - } - break - - case 'file': - default: - payload = { - type: 'file', - url: mediaUrl, - filename: options.filename ?? extractFileNameFromInput(mediaUrl), - caption, - ...context, - } - break - } - - return this.postSessionMessage(to, payload, mediaType) - } - - public sendButtons = async ( - to: string, - text: string, - buttons: Button[] = [], - options: GupshupSessionSendOptions = {} - ): Promise => { - const parsedButtons = this.buildQuickReplyOptions(buttons) - if (!parsedButtons.length) { - throw new Error('Gupshup quick replies require at least one button with text') - } - - const payload: Record = { - type: 'quick_reply', - content: { - type: 'text', - text, - }, - options: parsedButtons, - } - - if (options.replyTo) { - payload.msgid = options.replyTo - } - - return this.postSessionMessage(to, payload, 'quick_reply') - } - - public sendImage = async ( - to: string, - mediaInput: string, - caption = '', - options: GupshupSessionSendOptions = {} - ): Promise => { - return this.sendMedia(to, caption, mediaInput, { - ...options, - mediaType: 'image', - }) - } - - public sendFile = async ( - to: string, - mediaInput: string, - caption = '', - options: GupshupSessionSendOptions = {} - ): Promise => { - return this.sendMedia(to, caption, mediaInput, { - ...options, - mediaType: 'file', - }) - } - - public sendButtonUrl = async ( - to: string, - button: { body?: string; text?: string; url: string }, - body?: string | string[] - ): Promise => { - const fallbackBody = Array.isArray(body) ? body.join('\n') : (body ?? '') - - return this.sendCtaUrl( - to, - { - display_text: button.body || button.text || 'Abrir enlace', - url: button.url, - }, - fallbackBody - ) - } - - public sendList = async (to: string, list: GupshupCompatibleListMessage): Promise => { - const normalizedList = this.normalizeListPayload(list) - - if (!normalizedList.items?.length) { - throw new Error('Gupshup list messages require at least one section with options') - } - - const payload: Record = { - type: 'list', - title: normalizedList.title ?? 'Menu', - body: normalizedList.body ?? '', - globalButtons: [ - { - type: 'text', - title: normalizedList.buttonTitle ?? 'Options', - }, - ], - items: normalizedList.items.map((item) => ({ - title: item.title ?? '', - options: item.options.map((option) => ({ - type: 'text', - title: option.title, - description: option.description, - postbackText: option.postbackText ?? option.title, - ...(typeof option.encodeText === 'boolean' ? { encodeText: option.encodeText } : {}), - })), - })), - } - - if (normalizedList.msgid) { - payload.msgid = normalizedList.msgid - } - - return this.postSessionMessage(to, payload, 'list') - } - - public sendLocation = async (to: string, location: GupshupLocationMessage): Promise => { - const payload = { - type: 'location', - longitude: String(location.longitude), - latitude: String(location.latitude), - ...(location.name ? { name: location.name } : {}), - ...(location.address ? { address: location.address } : {}), - } - - return this.postSessionMessage(to, payload, 'location') - } - - public sendLocationRequest = async (to: string, bodyText: string | GupshupLocationRequestMessage): Promise => { - const parsedBodyText = typeof bodyText === 'string' ? bodyText : bodyText.bodyText - - if (!parsedBodyText?.trim()) { - throw new Error('Location request body text is required') - } - - const payload = { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to, - type: 'interactive', - interactive: { - type: 'location_request_message', - body: { - text: parsedBodyText.trim(), - }, - action: { - name: 'send_location', - }, - }, - } - - return this.postPartnerPassthroughMessage(to, payload, 'location_request') - } - - public requestLocation = async (to: string, bodyText: string): Promise => { - return this.sendLocationRequest(to, bodyText) - } - - private normalizeReactionMessageId = (reaction: GupshupReactionMessage): string => { - const messageId = reaction.msgId ?? reaction.messageId ?? reaction.message_id - if (!messageId) { - throw new Error('Reaction message id is required') - } - - return messageId - } - - public sendReaction = async (to: string, reaction: GupshupReactionMessage): Promise => { - const payload = { - type: 'reaction', - emoji: reaction.emoji, - msgId: this.normalizeReactionMessageId(reaction), - } - - return this.postSessionMessage(to, payload, 'reaction') - } - - public sendCtaUrl = async (to: string, ctaMessage: GupshupCtaMessage, fallbackBody = ''): Promise => { - const payload: Record = { - type: 'cta_url', - body: ctaMessage.body ?? fallbackBody, - display_text: ctaMessage.display_text, - url: ctaMessage.url, - } - - if (ctaMessage.footer) { - payload.footer = ctaMessage.footer - } - - if (ctaMessage.header) { - payload.header = ctaMessage.header - } - - return this.postSessionMessage(to, payload, 'cta_url') - } - - private normalizeTemplateRequest = ( - templateInput: GupshupTemplateSendRequest | string, - languageCodeOrComponents?: GupshupTemplateLanguageOrComponents, - componentsInput: GupshupMetaTemplateComponent[] = [] - ): GupshupTemplateSendRequest => { - if (typeof templateInput !== 'string') { - return templateInput - } - - const languageCode = typeof languageCodeOrComponents === 'string' ? languageCodeOrComponents : undefined - const components = Array.isArray(languageCodeOrComponents) ? languageCodeOrComponents : componentsInput - - const params = components - .flatMap((component) => (Array.isArray(component?.parameters) ? component.parameters : [])) - .map((parameter) => { - if (typeof parameter?.text === 'string') return parameter.text - if (typeof parameter?.payload === 'string') return parameter.payload - return '' - }) - .filter((value) => value.length > 0) - - return { - template: { - id: templateInput, - ...(languageCode ? { languageCode } : {}), - ...(params.length ? { params } : {}), - }, - } - } - - private getTemplateComponentsFromSignature = ( - languageCodeOrComponents?: GupshupTemplateLanguageOrComponents, - componentsInput: GupshupMetaTemplateComponent[] = [] - ): GupshupMetaTemplateComponent[] => { - if (Array.isArray(languageCodeOrComponents)) { - return languageCodeOrComponents - } - - return componentsInput - } - - private hasFlowTemplateActionComponent = (components: GupshupMetaTemplateComponent[] = []): boolean => { - return components.some((component) => { - if (!Array.isArray(component?.parameters)) return false - - return component.parameters.some( - (parameter) => - parameter?.type === 'action' && - typeof (parameter as any)?.action === 'object' && - typeof ((parameter as any).action as Record)?.flow_token === 'string' - ) - }) - } - - private normalizeTemplatePassthroughLanguage = ( - language: string | Record - ): Record => { - if (typeof language === 'string') { - return { code: language } - } - - return language - } - - private normalizeTemplatePassthroughComponents = ( - components: GupshupMetaTemplateComponent[] = [] - ): GupshupMetaTemplateComponent[] => { - return components.map((component) => { - const hasFlowActionParameter = Array.isArray(component?.parameters) - ? component.parameters.some( - (parameter) => - parameter?.type === 'action' && - typeof (parameter as any)?.action === 'object' && - typeof ((parameter as any).action as Record)?.flow_token === 'string' - ) - : false - - if (!hasFlowActionParameter) { - return component - } - - const currentIndex = (component as any)?.index - const trimmedStringIndex = typeof currentIndex === 'string' ? currentIndex.trim() : '' - const normalizedIndex = - typeof currentIndex === 'number' - ? String(currentIndex) - : trimmedStringIndex.length > 0 - ? trimmedStringIndex - : '0' - - return { - ...component, - type: 'button', - sub_type: 'flow', - index: normalizedIndex, - } - }) - } - - private isLikelyUuid = (value: string): boolean => { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) - } - - private isLikelyNumericIdentifier = (value: string): boolean => { - return /^\d+$/.test(value) - } - - private assertValidFlowTemplatePassthroughName = (templateName: string): void => { - const normalizedTemplateName = templateName.trim() - - if (this.isLikelyUuid(normalizedTemplateName) || this.isLikelyNumericIdentifier(normalizedTemplateName)) { - throw new Error( - 'Flow template passthrough expects template name (not a UUID/numeric template id). Use template.name from Meta.' - ) - } - } - - private getPartnerConfig = (): { appId: string; appToken: string; baseUrl: string } | null => { - const appId = this.globalVendorArgs.partner?.appId?.trim() - const appToken = this.globalVendorArgs.partner?.appToken?.trim() - - if (!appId || !appToken) { - return null - } - - return { - appId, - appToken, - baseUrl: (this.globalVendorArgs.partner?.baseUrl ?? PARTNER_BASE_URL).replace(/\/+$/, ''), - } - } - - private postPartnerPassthroughMessage = async ( - to: string, - payload: Record, - messageType: string - ): Promise => { - const partnerConfig = this.getPartnerConfig() - - if (!partnerConfig) { - throw new Error(PARTNER_APP_CONFIG_REQUIRED_ERROR) - } - - const body = new URLSearchParams() - - for (const [key, value] of Object.entries(payload)) { - if (value === undefined || value === null) { - continue - } - - if (typeof value === 'object') { - body.append(key, JSON.stringify(value)) - continue - } - - body.append(key, String(value)) - } - - try { - const response = await axios.post( - `${partnerConfig.baseUrl}/partner/app/${encodeURIComponent(partnerConfig.appId)}/v3/message`, - body, - { - headers: { - Authorization: partnerConfig.appToken, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ) - - return response.data - } catch (error) { - this.emitOutboundErrorNotice(to, messageType, error) - throw error - } - } - - public sendFlow = async (to: string, flowRequest: GupshupFlowSendRequest): Promise => { - if (!flowRequest.flowId) { - throw new Error('Flow id is required to send flow messages') - } - - if (!flowRequest.flowToken) { - throw new Error('Flow token is required to send flow messages') - } - - if (!flowRequest.flowCta) { - throw new Error('Flow CTA is required to send flow messages') - } - - const payload: Record = { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to, - type: 'interactive', - interactive: { - type: 'flow', - ...(flowRequest.header - ? { - header: { - type: 'text', - text: flowRequest.header, - }, - } - : {}), - ...(flowRequest.body - ? { - body: { - text: flowRequest.body, - }, - } - : {}), - ...(flowRequest.footer - ? { - footer: { - text: flowRequest.footer, - }, - } - : {}), - action: { - name: 'flow', - parameters: { - flow_message_version: flowRequest.flowMessageVersion ?? '3', - flow_token: flowRequest.flowToken, - flow_id: flowRequest.flowId, - flow_cta: flowRequest.flowCta, - flow_action: flowRequest.flowAction ?? 'navigate', - ...(flowRequest.flowActionPayload - ? { flow_action_payload: flowRequest.flowActionPayload } - : {}), - ...(flowRequest.isDraftFlow ? { mode: 'draft' } : {}), - }, - }, - }, - } - - return this.postPartnerPassthroughMessage(to, payload, 'flow') - } - - public sendTemplatePassthrough = async ( - to: string, - templateRequest: GupshupMetaTemplatePassthroughRequest - ): Promise => { - const normalizedComponents = templateRequest.components - ? this.normalizeTemplatePassthroughComponents(templateRequest.components) - : undefined - const hasFlowTemplateActionComponent = this.hasFlowTemplateActionComponent(normalizedComponents) - - if (hasFlowTemplateActionComponent) { - this.assertValidFlowTemplatePassthroughName(templateRequest.name) - } - - const payload: Record = { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to, - type: 'template', - template: { - name: templateRequest.name, - language: this.normalizeTemplatePassthroughLanguage(templateRequest.language), - ...(normalizedComponents ? { components: normalizedComponents } : {}), - }, - } - - return this.postPartnerPassthroughMessage(to, payload, 'template_passthrough') - } - - public sendTemplate(to: string, templateRequest: GupshupTemplateSendRequest): Promise - public sendTemplate(to: string, template: string, components: GupshupMetaTemplateComponent[]): Promise - public sendTemplate( - to: string, - template: string, - languageCode?: string, - components?: GupshupMetaTemplateComponent[] - ): Promise - public async sendTemplate( - to: string, - templateOrRequest: GupshupTemplateSendRequest | string, - languageCodeOrComponents?: GupshupTemplateLanguageOrComponents, - components: GupshupMetaTemplateComponent[] = [] - ): Promise { - if (typeof templateOrRequest === 'string') { - const parsedComponents = this.getTemplateComponentsFromSignature(languageCodeOrComponents, components) - const hasFlowTemplateActionComponent = this.hasFlowTemplateActionComponent(parsedComponents) - - if (hasFlowTemplateActionComponent) { - const partnerConfig = this.getPartnerConfig() - if (!partnerConfig) { - throw new Error(PARTNER_APP_CONFIG_REQUIRED_ERROR) - } - - const language = - typeof languageCodeOrComponents === 'string' - ? { code: languageCodeOrComponents } - : ({ code: 'en_US' } as Record) - - return this.sendTemplatePassthrough(to, { - name: templateOrRequest, - language, - components: parsedComponents, - }) - } - } - - const templateRequest = this.normalizeTemplateRequest(templateOrRequest, languageCodeOrComponents, components) - const { template, message, postbackTexts } = templateRequest - - if (!template?.id) { - throw new Error('Template id is required to send Gupshup template messages') - } - - const body = new URLSearchParams() - body.append('channel', GUPSHUP_CHANNEL) - body.append('source', this.globalVendorArgs.phoneNumber) - body.append('destination', to) - - if (this.globalVendorArgs.srcName) { - body.append('src.name', this.globalVendorArgs.srcName) - } - - body.append('template', JSON.stringify(template)) - - if (message) { - body.append('message', JSON.stringify(message)) - } - - if (postbackTexts?.length) { - body.append('postbackTexts', JSON.stringify(postbackTexts)) - } - - try { - const response = await this.http.post('/template/msg', body) - return response.data - } catch (error) { - this.emitOutboundErrorNotice(to, 'template', error) - throw error - } - } - - public markAsRead = async (msgId: string, appId = this.globalVendorArgs.appId): Promise => { - if (!appId) { - throw new Error('appId is required to mark messages as read') - } - - const response = await axios.put( - `${APP_BASE_URL}/${encodeURIComponent(appId)}/msg/${encodeURIComponent(msgId)}/read`, - null, - { - headers: { - apikey: this.globalVendorArgs.apiKey, - }, - } - ) - - return response.data - } - - public getMessageStatus = async (msgId: string, appId = this.globalVendorArgs.appId): Promise => { - if (!appId) { - throw new Error('appId is required to fetch message status') - } - - const response = await axios.get( - `${APP_BASE_URL}/${encodeURIComponent(appId)}/msg/${encodeURIComponent(msgId)}`, - { - headers: { - apikey: this.globalVendorArgs.apiKey, - }, - } - ) - - return response.data - } -} diff --git a/packages/provider-gupshup/src/index.ts b/packages/provider-gupshup/src/index.ts deleted file mode 100644 index 98789bde8..000000000 --- a/packages/provider-gupshup/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { GupshupProvider } from './gupshup/provider' -export * from './utils/media' -export * from './utils/processIncomingMsg' -export * from './types' diff --git a/packages/provider-gupshup/src/types.ts b/packages/provider-gupshup/src/types.ts deleted file mode 100644 index 037219b45..000000000 --- a/packages/provider-gupshup/src/types.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { GlobalVendorArgs } from '@builderbot/bot/dist/types' - -export type GupshupStatusLogMode = 'off' | 'failed' | 'all' - -export interface GupshupLogsConfig { - inbound?: boolean - status?: GupshupStatusLogMode - outboundErrors?: boolean - rawOnFailed?: boolean -} - -export interface GupshupLocalMediaConfig { - ttlMs?: number -} - -export interface GupshupGlobalVendorArgs extends GlobalVendorArgs { - apiKey: string - srcName: string // Nombre de la App en Gupshup - phoneNumber: string // Número origen (Source) - appId?: string - partner?: { - appId?: string - appToken?: string - baseUrl?: string - } - publicUrl?: string - logs?: GupshupLogsConfig - localMedia?: GupshupLocalMediaConfig - resolveMediaUrl?: (input: string) => Promise | string - webhook?: { - verify?: (req: any) => boolean | Promise - dedupeTtlMs?: number - } -} - -export type GupshupCloudMessageType = - | 'text' - | 'image' - | 'document' - | 'audio' - | 'video' - | 'sticker' - | 'location' - | 'button' - | 'interactive' - | 'contacts' - | 'order' - | 'reaction' - | string - -export interface GupshupCloudContact { - wa_id?: string - profile?: { - name?: string - } -} - -export interface GupshupCloudMetadata { - display_phone_number?: string - phone_number_id?: string -} - -export interface GupshupCloudMedia { - id?: string - url?: string - mime_type?: string - sha256?: string - filename?: string - caption?: string -} - -export interface GupshupCloudLocation { - latitude?: number | string - longitude?: number | string - name?: string - address?: string -} - -export interface GupshupCloudContactCard { - name?: { - formatted_name?: string - first_name?: string - last_name?: string - } - phones?: Array<{ - phone?: string - wa_id?: string - type?: string - }> -} - -export interface GupshupCloudOrder { - catalog_id?: string - product_items?: Array<{ - product_retailer_id?: string - quantity?: number | string - item_price?: number | string - currency?: string - }> -} - -export interface GupshupCloudReaction { - message_id?: string - emoji?: string -} - -export interface GupshupCloudInteractiveMessage { - type?: 'button_reply' | 'list_reply' | string - button_reply?: { - id?: string - title?: string - } - list_reply?: { - id?: string - title?: string - description?: string - } - nfm_reply?: { - name?: string - body?: string - response_json?: string - } -} - -export interface GupshupCloudMessage { - id?: string - from?: string - timestamp?: string - type?: GupshupCloudMessageType - text?: { - body?: string - } - image?: GupshupCloudMedia - document?: GupshupCloudMedia - audio?: GupshupCloudMedia - video?: GupshupCloudMedia - sticker?: GupshupCloudMedia - location?: GupshupCloudLocation - button?: { - payload?: string - text?: string - } - interactive?: GupshupCloudInteractiveMessage - contacts?: GupshupCloudContactCard[] - order?: GupshupCloudOrder - reaction?: GupshupCloudReaction -} - -export interface GupshupCloudStatusError { - code?: number | string - title?: string - details?: string - message?: string - error_data?: { - details?: string - } -} - -export interface GupshupCloudStatus { - id?: string - gs_id?: string - recipient_id?: string - status?: string - timestamp?: number | string - errors?: GupshupCloudStatusError[] -} - -export interface GupshupCloudChangeValue { - metadata?: GupshupCloudMetadata - contacts?: GupshupCloudContact[] - messages?: GupshupCloudMessage[] - statuses?: GupshupCloudStatus[] -} - -export interface GupshupCloudWebhookBody { - object?: string - gs_app_id?: string - entry?: Array<{ - id?: string - changes?: Array<{ - field?: string - value?: GupshupCloudChangeValue - }> - }> -} - -export interface GupshupCloudIncomingMessageArgs { - message: GupshupCloudMessage - contact?: GupshupCloudContact - metadata?: GupshupCloudMetadata -} - -export type GupshupSessionMediaType = 'image' | 'video' | 'audio' | 'file' | 'sticker' - -export interface GupshupReactionMessage { - msgId?: string - messageId?: string - message_id?: string - emoji: string -} - -export interface GupshupLocationMessage { - longitude: string | number - latitude: string | number - name?: string - address?: string -} - -export interface GupshupLocationRequestMessage { - bodyText: string -} - -export interface GupshupListOption { - title: string - description?: string - postbackText?: string - encodeText?: boolean -} - -export interface GupshupListItem { - title?: string - options: GupshupListOption[] -} - -export interface GupshupListMessage { - title?: string - body?: string - msgid?: string - buttonTitle?: string - items: GupshupListItem[] -} - -export interface GupshupMetaListRow { - id: string - title: string - description?: string -} - -export interface GupshupMetaListSection { - title: string - rows: GupshupMetaListRow[] -} - -export interface GupshupMetaListMessage { - type: 'list' - header?: { - type?: 'text' - text: string - } - body: { - text: string - } - action: { - button: string - sections: GupshupMetaListSection[] - } - footer?: { - text: string - } -} - -export type GupshupCompatibleListMessage = GupshupListMessage | GupshupMetaListMessage - -export interface GupshupCtaHeader { - type?: 'image' | 'video' - image?: { - link?: string - } - video?: { - link?: string - } -} - -export interface GupshupCtaMessage { - display_text: string - url: string - body?: string - footer?: string - header?: GupshupCtaHeader -} - -export interface GupshupTemplatePayload { - id: string - languageCode?: string - params?: string[] -} - -export interface GupshupMetaTemplateComponentParameter { - type?: string - text?: string - payload?: string - [key: string]: unknown -} - -export interface GupshupMetaTemplateComponent { - type?: string - parameters?: GupshupMetaTemplateComponentParameter[] - [key: string]: unknown -} - -export type GupshupTemplateLanguageOrComponents = string | GupshupMetaTemplateComponent[] - -export interface GupshupTemplateMessage { - type: 'image' | 'video' | 'document' | 'location' - image?: { - id?: string - link?: string - } - video?: { - id?: string - link?: string - } - document?: { - id?: string - link?: string - filename?: string - } - location?: { - longitude: string | number - latitude: string | number - name?: string - address?: string - } -} - -export interface GupshupTemplateSendRequest { - template: GupshupTemplatePayload - message?: GupshupTemplateMessage - postbackTexts?: string[] -} - -export interface GupshupFlowSendRequest { - header?: string - body?: string - footer?: string - flowMessageVersion?: string - flowToken?: string - flowId?: string - flowCta?: string - flowAction?: string - flowActionPayload?: Record - isDraftFlow?: boolean -} - -export interface GupshupMetaTemplatePassthroughRequest { - name: string - language: string | Record - components?: GupshupMetaTemplateComponent[] -} - -export interface GupshupSessionSendOptions { - mediaType?: GupshupSessionMediaType - previewUrl?: boolean - replyTo?: string - filename?: string - list?: GupshupCompatibleListMessage - reaction?: GupshupReactionMessage - location?: GupshupLocationMessage - locationRequest?: string | GupshupLocationRequestMessage - ctaUrl?: GupshupCtaMessage - flow?: GupshupFlowSendRequest - template?: GupshupTemplateSendRequest - templatePassthrough?: GupshupMetaTemplatePassthroughRequest - options?: Record -} diff --git a/packages/provider-gupshup/src/utils/media.ts b/packages/provider-gupshup/src/utils/media.ts deleted file mode 100644 index e84c13c26..000000000 --- a/packages/provider-gupshup/src/utils/media.ts +++ /dev/null @@ -1,89 +0,0 @@ -import mime from 'mime-types' -import { basename } from 'path' - -import type { GupshupCloudMedia, GupshupCloudMessage, GupshupSessionMediaType } from '../types' - -const GUPSHUP_CLOUD_MEDIA_KEYS = ['image', 'document', 'audio', 'video', 'sticker'] as const - -type GupshupCloudMediaKey = (typeof GUPSHUP_CLOUD_MEDIA_KEYS)[number] - -export const isHttpUrl = (value: string): boolean => /^https?:\/\//i.test(value) - -const resolvePathFromInput = (mediaInput: string): string => { - if (!isHttpUrl(mediaInput)) return mediaInput - - try { - const url = new URL(mediaInput) - return url.pathname - } catch { - return mediaInput - } -} - -export const inferSessionMediaTypeFromInput = ( - mediaInput: string, - fallback: GupshupSessionMediaType = 'image' -): GupshupSessionMediaType => { - const pathFromInput = resolvePathFromInput(mediaInput) - const mimeType = mime.lookup(pathFromInput) - - if (typeof mimeType !== 'string') return fallback - - if (mimeType.startsWith('image/')) { - const extension = mime.extension(mimeType) - return extension === 'webp' ? 'sticker' : 'image' - } - - if (mimeType.startsWith('video/')) return 'video' - if (mimeType.startsWith('audio/')) return 'audio' - - return 'file' -} - -export const extractFileNameFromInput = (mediaInput: string, fallback = 'file'): string => { - const pathFromInput = resolvePathFromInput(mediaInput) - const fileName = basename(pathFromInput) - return fileName || fallback -} - -const findCloudMedia = ( - message: GupshupCloudMessage -): { - type: GupshupCloudMediaKey - media: GupshupCloudMedia -} | null => { - for (const mediaType of GUPSHUP_CLOUD_MEDIA_KEYS) { - const media = message[mediaType] - if (media?.url || media?.id) { - return { - type: mediaType, - media, - } - } - } - - return null -} - -export const resolveCloudMediaUrl = (message: GupshupCloudMessage): string => { - const mediaEntry = findCloudMedia(message) - return mediaEntry?.media.url ?? '' -} - -export const resolveCloudMediaId = (message: GupshupCloudMessage): string => { - const mediaEntry = findCloudMedia(message) - return mediaEntry?.media.id ?? '' -} - -export const resolveCloudMediaMeta = (message: GupshupCloudMessage): Partial => { - const mediaEntry = findCloudMedia(message) - if (!mediaEntry) return {} - - return { - id: mediaEntry.media.id, - mime_type: mediaEntry.media.mime_type, - filename: mediaEntry.media.filename, - caption: mediaEntry.media.caption, - sha256: mediaEntry.media.sha256, - } -} diff --git a/packages/provider-gupshup/src/utils/processIncomingMsg.ts b/packages/provider-gupshup/src/utils/processIncomingMsg.ts deleted file mode 100644 index 94f75d713..000000000 --- a/packages/provider-gupshup/src/utils/processIncomingMsg.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { utils } from '@builderbot/bot' -import { BotContext } from '@builderbot/bot/dist/types' - -import { resolveCloudMediaId, resolveCloudMediaMeta, resolveCloudMediaUrl } from './media' -import { GupshupCloudIncomingMessageArgs, GupshupCloudMessage, GupshupGlobalVendorArgs } from '../types' - -const SUPPORTED_CANONICAL_TYPES = new Set([ - 'text', - 'image', - 'video', - 'audio', - 'document', - 'location', - 'button', - 'interactive', - 'contacts', - 'order', - 'reaction', - 'sticker', -]) - -const resolveCanonicalType = (messageType?: string): string => { - const normalizedType = typeof messageType === 'string' ? messageType.trim().toLowerCase() : '' - - if (normalizedType && SUPPORTED_CANONICAL_TYPES.has(normalizedType)) { - return normalizedType - } - - return normalizedType || messageType || 'text' -} - -const resolveInteractiveReply = (message: GupshupCloudMessage): string => { - const interactiveReply = - message.interactive?.button_reply?.title ?? - message.interactive?.button_reply?.id ?? - message.interactive?.list_reply?.id ?? - message.interactive?.list_reply?.title ?? - message.interactive?.list_reply?.description ?? - message.interactive?.nfm_reply?.response_json - - return interactiveReply ?? '' -} - -const attachMediaContext = (context: BotContext, message: GupshupCloudMessage): BotContext => { - const mediaMeta = resolveCloudMediaMeta(message) - const mediaId = resolveCloudMediaId(message) - const mediaUrl = resolveCloudMediaUrl(message) - - const mediaContext: BotContext = { - ...context, - url: mediaUrl, - } - - if (mediaId) mediaContext.mediaId = mediaId - if (mediaMeta.mime_type) mediaContext.mimeType = mediaMeta.mime_type - if (mediaMeta.filename) mediaContext.filename = mediaMeta.filename - if (mediaMeta.caption) mediaContext.caption = mediaMeta.caption - if (mediaMeta.sha256) mediaContext.sha256 = mediaMeta.sha256 - ;(mediaContext as any).fileData = { - ...(mediaUrl ? { url: mediaUrl } : {}), - ...(mediaId ? { id: mediaId } : {}), - ...(mediaMeta.mime_type ? { mime_type: mediaMeta.mime_type } : {}), - ...(mediaMeta.filename ? { filename: mediaMeta.filename } : {}), - ...(mediaMeta.caption ? { caption: mediaMeta.caption } : {}), - ...(mediaMeta.sha256 ? { sha256: mediaMeta.sha256 } : {}), - } - - return mediaContext -} - -export const processIncomingMessage = async ( - raw: GupshupCloudIncomingMessageArgs, - args: GupshupGlobalVendorArgs -): Promise => { - const { message, contact, metadata } = raw - - if (!message || typeof message.type !== 'string' || !message.type.trim()) { - console.log('[Gupshup] Malformed incoming message payload: missing message.type') - return null - } - - const from = message.from ?? contact?.wa_id - - if (!from) { - console.log('[Gupshup] Message without sender phone') - return null - } - - const name = contact?.profile?.name || from - - const canonicalType = resolveCanonicalType(message.type) - - let payload: BotContext = { - from, - name, - body: '', - url: '', - ...(message.id ? ({ id: message.id, message_id: message.id } as Record) : {}), - ...(message.timestamp ? ({ timestamp: message.timestamp } as Record) : {}), - type: canonicalType, - host: { - phone: metadata?.display_phone_number ?? args.phoneNumber, - }, - } - - switch (canonicalType) { - case 'text': - payload.body = message.text?.body ?? '' - break - - case 'image': - case 'video': - case 'sticker': - payload.body = utils.generateRefProvider('_event_media_') - payload = attachMediaContext(payload, message) - break - - case 'document': - payload.body = utils.generateRefProvider('_event_document_') - payload = attachMediaContext(payload, message) - break - - case 'audio': - payload.body = utils.generateRefProvider('_event_voice_note_') - payload = attachMediaContext(payload, message) - break - - case 'location': - payload.body = utils.generateRefProvider('_event_location_') - payload.latitude = message.location?.latitude - payload.longitude = message.location?.longitude - payload.locationName = message.location?.name - payload.locationAddress = message.location?.address - break - - case 'button': - payload.body = message.button?.payload ?? message.button?.text ?? '' - payload.buttonPayload = message.button?.payload - ;(payload as any).payload = message.button?.payload - ;(payload as any).title_button_reply = message.button?.payload ?? message.button?.text ?? '' - break - - case 'interactive': - payload.body = resolveInteractiveReply(message) - payload.interactiveId = message.interactive?.button_reply?.id ?? message.interactive?.list_reply?.id - ;(payload as any).title_button_reply = message.interactive?.button_reply?.title - ;(payload as any).title_list_reply = message.interactive?.list_reply?.title - ;(payload as any).id_list_reply = message.interactive?.list_reply?.id - - if (message.interactive?.nfm_reply?.response_json) { - const responseJson = message.interactive.nfm_reply.response_json - - if (!payload.body) { - payload.body = responseJson - } - - ;(payload as any).message = { - interactive: message.interactive, - } - - try { - ;(payload as any).nfm_reply = JSON.parse(responseJson) - } catch { - ;(payload as any).nfm_reply = undefined - } - } - break - - case 'contacts': - payload.body = utils.generateRefProvider('_event_contacts_') - payload.contacts = message.contacts ?? [] - break - - case 'order': - payload.body = utils.generateRefProvider('_event_order_') - payload.order = message.order - break - - case 'reaction': - payload.body = message.reaction?.emoji ?? utils.generateRefProvider('_event_reaction_removed_') - payload.reactionToMessageId = message.reaction?.message_id - ;(payload as any).reactionEmoji = message.reaction?.emoji ?? '' - break - - default: - console.log(`[Gupshup] Unhandled message type: ${canonicalType}`) - return null - } - - if (!payload.body) { - console.log(`[Gupshup] Empty body for message type: ${message.type}`) - return null - } - - return payload -} diff --git a/packages/provider-gupshup/tsconfig.json b/packages/provider-gupshup/tsconfig.json deleted file mode 100644 index 705da99f1..000000000 --- a/packages/provider-gupshup/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"], - "paths": { - "~/*": ["./src/*"] - } - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/provider-instagram/package.json b/packages/provider-instagram/package.json index 439fbf9af..94612995b 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,59 +1,59 @@ { - "name": "@builderbot/provider-instagram", - "version": "1.4.2-alpha.11", - "description": "Provider for Instagram Messaging", - "keywords": [ - "instagram", - "messenger", - "chatbot", - "builderbot" - ], - "author": "", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/form-data": "^2.5.2", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "jest": "^30.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.4.6", - "typescript": "^5.9.3" - }, - "dependencies": { - "axios": "^1.13.2", - "form-data": "^4.0.5", - "mime-types": "^3.0.2", - "polka": "^0.5.2" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" + "name": "@japcon-bot/provider-instagram", + "version": "1.0.0", + "description": "Provider for Instagram Messaging", + "keywords": [ + "instagram", + "messenger", + "chatbot", + "builderbot" + ], + "author": "", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/form-data": "^2.5.2", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/polka": "^0.5.7", + "jest": "^30.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "ts-jest": "^29.4.6", + "typescript": "^5.9.3", + "@japcon-bot/bot": "workspace:^" + }, + "dependencies": { + "axios": "^1.13.2", + "form-data": "^4.0.5", + "mime-types": "^3.0.2", + "polka": "^0.5.2" + }, + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-meta/package.json b/packages/provider-meta/package.json index 8916804ac..a3d2dd327 100644 --- a/packages/provider-meta/package.json +++ b/packages/provider-meta/package.json @@ -1,65 +1,65 @@ { - "name": "@builderbot/provider-meta", - "version": "1.4.2-alpha.11", - "description": "> TODO: description", - "author": "vicente1992 ", - "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "directories": { - "src": "src", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/vicente1992/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "bugs": { - "url": "https://github.com/vicente1992/bot-whatsapp/issues" - }, - "dependencies": { - "axios": "^1.13.2", - "body-parser": "^2.2.1", - "file-type": "^19.0.0", - "form-data": "^4.0.5", - "mime-types": "^3.0.2", - "polka": "^0.5.2", - "queue-promise": "^2.2.1" - }, - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-terser": "^0.4.4", - "@types/cors": "^2.8.17", - "@types/jest": "^30.0.0", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "@types/sinon": "^17.0.3", - "cors": "^2.8.5", - "jest": "^30.2.0", - "kleur": "^4.1.5", - "proxyquire": "^2.1.3", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "supertest": "^6.3.4", - "ts-jest": "^29.4.6", - "tslib": "^2.6.2", - "tsm": "^2.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" + "name": "@japcon-bot/provider-meta", + "version": "1.0.0", + "description": "> TODO: description", + "author": "vicente1992 ", + "homepage": "https://github.com/vicente1992/bot-whatsapp#readme", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "directories": { + "src": "src", + "test": "__tests__" + }, + "files": [ + "./dist/" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/vicente1992/bot-whatsapp.git" + }, + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest", + "test:coverage": "jest --coverage", + "test:watch": "jest --watchAll --coverage" + }, + "bugs": { + "url": "https://github.com/vicente1992/bot-whatsapp/issues" + }, + "dependencies": { + "axios": "^1.13.2", + "body-parser": "^2.2.1", + "file-type": "^19.0.0", + "form-data": "^4.0.5", + "mime-types": "^3.0.2", + "polka": "^0.5.2", + "queue-promise": "^2.2.1" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-terser": "^0.4.4", + "@types/cors": "^2.8.17", + "@types/jest": "^30.0.0", + "@types/node": "^24.10.2", + "@types/polka": "^0.5.7", + "@types/sinon": "^17.0.3", + "cors": "^2.8.5", + "jest": "^30.2.0", + "kleur": "^4.1.5", + "proxyquire": "^2.1.3", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "sinon": "^17.0.1", + "supertest": "^6.3.4", + "ts-jest": "^29.4.6", + "tslib": "^2.6.2", + "tsm": "^2.3.0", + "@japcon-bot/bot": "workspace:^" + }, + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-sherpa/LICENSE.md b/packages/provider-sherpa/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/provider-sherpa/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/provider-sherpa/README.md b/packages/provider-sherpa/README.md deleted file mode 100644 index 6fb0a2be3..000000000 --- a/packages/provider-sherpa/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/provider-sherpa

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) diff --git a/packages/provider-sherpa/__tests__/sherpaProvider.test.ts b/packages/provider-sherpa/__tests__/sherpaProvider.test.ts deleted file mode 100644 index 65880a71c..000000000 --- a/packages/provider-sherpa/__tests__/sherpaProvider.test.ts +++ /dev/null @@ -1,1312 +0,0 @@ -import { utils } from '@builderbot/bot' -import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import fs from 'fs' -import mime from 'mime-types' -import path from 'path' -import { IStickerOptions } from 'wa-sticker-formatter' -import { useMultiFileAuthState } from 'whaileys' - -import { SherpaProvider } from '../src' - -const phoneNumber = '+123456789' - -jest.mock('whaileys', () => ({ - downloadMediaMessage: jest.fn(), - proto: { - Message: { - fromObject: jest.fn().mockReturnValue({}), - create: jest.fn().mockReturnValue({}), - }, - }, - useMultiFileAuthState: jest.fn().mockImplementation(() => ({ - state: { creds: {}, keys: {} }, - saveCreds: jest.fn(), - })), - - makeInMemoryStore: jest.fn().mockReturnValue({ - readFromFile: jest.fn(), - writeToFile: jest.fn(), - bind: jest.fn(), - }), - makeWASocketOther: jest.fn().mockImplementation(() => ({ - ev: { on: jest.fn() }, - authState: { creds: { registered: false } }, - waitForConnectionUpdate: jest.fn(), - requestPairingCode: jest.fn(), - })), - getAggregateVotesInPollMessage: jest.fn().mockReturnValue([{ name: 'Option 1', voters: ['voter1'] }]), -})) - -jest.mock('fs/promises', () => ({ - writeFile: jest.fn(), -})) - -jest.mock('wa-sticker-formatter', () => { - return { - Sticker: jest.fn().mockImplementation(() => ({ - toMessage: jest.fn().mockImplementation(() => Buffer.from('sticker-buffer')), - })), - } -}) - -jest.mock('../src/utils', () => ({ - sherpaCleanNumber: jest.fn().mockImplementation(() => phoneNumber), - sherpaIsValidNumber: jest.fn((number: string) => { - if (!number || number.trim() === '') return false - return !number.includes('@g.us') - }), - sherpaGenerateImage: jest.fn(), - baileyCleanNumber: jest.fn().mockImplementation(() => phoneNumber), - baileyIsValidNumber: jest.fn((number: string) => { - if (!number || number.trim() === '') return false - return !number.includes('@g.us') - }), - baileyGenerateImage: jest.fn(), - emptyDirSessions: jest.fn(), -})) - -const mimeType = 'text/plain' - -jest.mock('mime-types', () => ({ - lookup: jest.fn().mockImplementation(() => mimeType), - extension: jest.fn().mockImplementation(() => '.png'), -})) - -jest.mock('@builderbot/bot') - -const mockSendSuccess = jest.fn().mockImplementation(() => 'success') as any - -describe('#SherpaProvider', () => { - let provider: SherpaProvider - let mockRes: any - let mockReq: any - let mockNext: any - - beforeEach(() => { - const args = { - name: 'test-bot', - gifPlayback: true, - usePairingCode: true, - browser: ['Windows', 'Chrome', 'Chrome 114.0.5735.198'] as any, - phoneNumber: '+123456789', - useBaileysStore: true, - port: 3001, - } - - provider = new SherpaProvider(args) - mockReq = {} - mockRes = { - writeHead: jest.fn(), - end: jest.fn(), - pipe: jest.fn(), - } - mockNext = jest.fn() - provider.vendor = jest.fn() as any - }) - - test('should initialize SherpaProvider correctly with default arguments', () => { - // Arrange - const defaultArgs = { - name: 'bot', - gifPlayback: false, - usePairingCode: false, - browser: ['Windows', 'Chrome', 'Chrome 114.0.5735.198'] as any, - timeRelease: 0, - phoneNumber: null, - useBaileysStore: true, - groupsIgnore: true, - readStatus: false, - port: 3000, - autoRefresh: 0, - writeMyself: 'none', - experimentalStore: false, - experimentalSyncMessage: undefined, - } - // Act - const sherpaProvider = new SherpaProvider({}) - - // Assert - expect(sherpaProvider.globalVendorArgs).toEqual(defaultArgs) - }) - - describe('#beforeHttpServerInit', () => { - test('beforeHttpServerInit - you should configure middleware to handle HTTP requests', () => { - // Arrange - const mockUse = jest.fn().mockReturnThis() - const mockGet = jest.fn() - - const mockPolka = jest.fn(() => ({ - use: mockUse, - get: mockGet, - })) - - provider.server = mockPolka() as any - // Act - provider['beforeHttpServerInit']() - - // Assert - expect(mockUse).toHaveBeenCalled() - const middleware = mockUse.mock.calls[0][0] as any - expect(middleware).toBeInstanceOf(Function) - middleware(mockReq, mockRes, mockNext) - expect(mockReq.globalVendorArgs).toBe(provider.globalVendorArgs) - expect(mockGet).toHaveBeenCalledWith('/', provider.indexHome) - }) - }) - - describe('#getMessage', () => { - test('should return empty message object', async () => { - // Arrange - const mockedKey = { remoteJid: 'exampleRemoteJid', id: 'exampleId' } - - // Act - const result = await provider['getMessage'](mockedKey) - - // Assert - expect(result).toEqual({}) - }) - }) - - describe('#saveFile', () => { - test('should save a file and return the path whit path', async () => { - // Arrange - const ctx: any = { - key: {}, - message: null, - } - const options = { path: '/tmp' } - const getMimeTypeSpy = jest.spyOn(provider, 'getMimeType' as any).mockReturnValue('image/jpeg') - const generateFileNameSpy = jest.spyOn(provider, 'generateFileName' as any).mockReturnValue('file.jpeg') - jest.spyOn(path, 'join').mockImplementation(() => '/tmp/mock-file.jpeg') - - // Act - const filePath = await provider.saveFile(ctx, options) - - // Assert - expect(getMimeTypeSpy).toHaveBeenCalled() - expect(generateFileNameSpy).toHaveBeenCalled() - expect(filePath).toContain('mock-file.jpeg') - expect(path.isAbsolute(filePath)).toBe(true) - }) - - test('should save a file and return the path', async () => { - // Arrange - const ctx: any = { - key: {}, - message: null, - } - const getMimeTypeSpy = jest.spyOn(provider, 'getMimeType' as any).mockReturnValue('image/jpeg') - const generateFileNameSpy = jest.spyOn(provider, 'generateFileName' as any).mockReturnValue('file.jpeg') - jest.spyOn(path, 'join').mockImplementation(() => '/tmp/mock-file.jpeg') - - // Act - const filePath = await provider.saveFile(ctx) - - // Assert - expect(getMimeTypeSpy).toHaveBeenCalled() - expect(generateFileNameSpy).toHaveBeenCalled() - expect(filePath).toContain('mock-file.jpeg') - expect(path.isAbsolute(filePath)).toBe(true) - }) - - test('should throw an error when MIME type is not found', async () => { - // Arrange - const mockContext = { message: {} } - const getMimeTypeSpy = jest.spyOn(provider, 'getMimeType' as any).mockReturnValue(null) - - // Act - const response = provider.saveFile(mockContext) - - // Assert - await expect(response).rejects.toThrow('MIME type not found') - expect(getMimeTypeSpy).toHaveBeenCalled() - }) - }) - - describe('#generateFileName', () => { - test('should generate a unique filename with the provided extension', () => { - // Arrange - const extension = 'jpg' - // Act - const fileName = provider['generateFileName'](extension) - // Assert - expect(fileName).toMatch(/^file-\d+\.(jpg)$/) - }) - }) - - describe('#getMimeType', () => { - test('should return the file type image/jpeg ', () => { - // Arrange - const mockMessage = { - message: { - imageMessage: { - mimetype: 'image/jpeg', - }, - }, - } - - // Act - const mimeType = provider['getMimeType'](mockMessage as any) - - // Assert - expect(mimeType).toBe('image/jpeg') - }) - - test('should return the file type video/mp4 ', () => { - // Arrange - const mockMessage = { - message: { - videoMessage: { - mimetype: 'video/mp4', - }, - }, - } - - // Act - const mimeType = provider['getMimeType'](mockMessage as any) - - // Assert - expect(mimeType).toBe('video/mp4') - }) - - test('should return the file type application/pdf ', () => { - // Arrange - const mockMessage = { - message: { - documentMessage: { - mimetype: 'application/pdf', - }, - }, - } - - // Act - const mimeType = provider['getMimeType'](mockMessage as any) - - // Assert - expect(mimeType).toBe('application/pdf') - }) - - test('should return undefined if message is not available', () => { - // Arrange - const mockMessage = {} - - // Act - const mimeType = provider['getMimeType'](mockMessage as any) - - // Assert - expect(mimeType).toBeUndefined() - }) - }) - - describe('#sendSticker', () => { - test('should send a sticker message', async () => { - // Arrange - const remoteJid = 'recipient@example.com' - const stickerUrl = 'https://example.com/sticker.png' - const stickerOptions: Partial = {} - const messages = 'Hello Word!' - const mockSendMessage = jest.fn() as any - provider.vendor.sendMessage = mockSendMessage - // Act - await provider.sendSticker(remoteJid, stickerUrl, stickerOptions, messages) - - // Assert - expect(mockSendMessage).toHaveBeenCalledWith(remoteJid, expect.any(Buffer), { quoted: messages }) - }) - - test('should send a sticker message null', async () => { - // Arrange - const remoteJid = 'recipient@example.com' - const stickerUrl = 'https://example.com/sticker.png' - const stickerOptions: Partial = {} - const mockSendMessage = jest.fn() as any - provider.vendor.sendMessage = mockSendMessage - // Act - await provider.sendSticker(remoteJid, stickerUrl, stickerOptions) - - // Assert - expect(mockSendMessage).toHaveBeenCalledWith(remoteJid, expect.any(Buffer), { quoted: null }) - }) - }) - - describe('#sendPresenceUpdate', () => { - test('should send a presence update', async () => { - // Arrange - const remoteJid = 'recipient@example.com' - const WAPresence = 'recording' - const mockSendPresenceUpdate = jest.fn() as any - provider.vendor.sendPresenceUpdate = mockSendPresenceUpdate - - // Act - await provider.sendPresenceUpdate(remoteJid, WAPresence) - - // Assert - expect(mockSendPresenceUpdate).toHaveBeenCalledWith(WAPresence, remoteJid) - }) - }) - - describe('#sendContact', () => { - test('should send a contact message', async () => { - // Arrange - const remoteJid = 'recipient@example.com' - const contactNumber = '+1234567890' - const displayName = 'John Doe' - const orgName = 'My Company' - const messages = 'Hello Word!' - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - - // Act - const result = await provider.sendContact( - remoteJid, - { replaceAll: () => contactNumber }, - displayName, - orgName, - messages - ) - - // Assert - expect(result).toEqual({ status: 'success' }) - expect(mockSendMessage).toHaveBeenCalledWith( - remoteJid, - { - contacts: { - displayName: '.', - contacts: [ - { - vcard: `BEGIN:VCARD\nVERSION:3.0\nFN:${displayName}\nORG:${orgName};\nTEL;type=CELL;type=VOICE;waid=${contactNumber.replace( - '+', - '' - )}:${contactNumber}\nEND:VCARD`, - }, - ], - }, - }, - { quoted: messages } - ) - }) - - test('should send a contact message null', async () => { - // Arrange - const remoteJid = 'recipient@example.com' - const contactNumber = '+1234567890' - const displayName = 'John Doe' - const orgName = 'My Company' - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - - // Act - const result = await provider.sendContact( - remoteJid, - { replaceAll: () => contactNumber }, - displayName, - orgName - ) - - // Assert - expect(result).toEqual({ status: 'success' }) - expect(mockSendMessage).toHaveBeenCalledWith( - remoteJid, - { - contacts: { - displayName: '.', - contacts: [ - { - vcard: `BEGIN:VCARD\nVERSION:3.0\nFN:${displayName}\nORG:${orgName};\nTEL;type=CELL;type=VOICE;waid=${contactNumber.replace( - '+', - '' - )}:${contactNumber}\nEND:VCARD`, - }, - ], - }, - }, - { quoted: null } - ) - }) - }) - - describe('#sendLocation', () => { - test('should send a location message', async () => { - // Arrange - const remoteJid = 'recipient@example.com' - const latitude = 123.456 - const longitude = 789.012 - const messages = 'Hello Word!' - - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - - // Act - const result = await provider.sendLocation(remoteJid, latitude, longitude, messages) - - // Assert - expect(result).toEqual({ status: 'success' }) - expect(mockSendMessage).toHaveBeenCalledWith( - remoteJid, - { - location: { - degreesLatitude: latitude, - degreesLongitude: longitude, - }, - }, - { quoted: messages } - ) - }) - - test('should send a location message null', async () => { - // Arrange - const remoteJid = 'recipient@example.com' - const latitude = 123.456 - const longitude = 789.012 - - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - - // Act - const result = await provider.sendLocation(remoteJid, latitude, longitude) - - // Assert - expect(result).toEqual({ status: 'success' }) - expect(mockSendMessage).toHaveBeenCalledWith( - remoteJid, - { - location: { - degreesLatitude: latitude, - degreesLongitude: longitude, - }, - }, - { quoted: null } - ) - }) - }) - - describe('#sendMessage', () => { - test('should send text message if no options provided', async () => { - // Arrange - const numberIn = phoneNumber - const message = 'Hello, world!' - const options = {} - - const mockSendText = mockSendSuccess - provider.sendText = mockSendText - - // Act - const result = await provider.sendMessage(numberIn, message, options) - - // Assert - expect(result).toEqual('success') - expect(mockSendText).toHaveBeenCalledWith(numberIn, message) - }) - - test('should send buttons if options contain buttons', async () => { - // Arrange - const numberIn = phoneNumber - const message = 'Please select an option' - const options = { - buttons: [{ body: 'Option 1' }, { body: 'Option 2' }], - } - - const mockSendButtons = mockSendSuccess - provider.sendButtons = mockSendButtons - - // Act - const result = await provider.sendMessage(numberIn, message, options) - - // Assert - expect(result).toEqual('success') - expect(mockSendButtons).toHaveBeenCalledWith(numberIn, message, options.buttons) - }) - - test('should send media if options contain media', async () => { - // Arrange - const numberIn = phoneNumber - const message = 'Please see the attached media' - const mediaUrl = 'https://example.com/image.jpg' - const options = { - media: mediaUrl, - } - - const mockSendMedia = mockSendSuccess - provider.sendMedia = mockSendMedia - - // Act - const result = await provider.sendMessage(numberIn, message, options) - - // Assert - expect(result).toEqual('success') - expect(mockSendMedia).toHaveBeenCalledWith(numberIn, mediaUrl, message) - }) - }) - - describe.skip('#sendPoll', () => { - test('should send poll message with correct options', async () => { - // Arrange - const numberIn = phoneNumber - const text = 'Please vote' - const poll = { - options: ['Option 1', 'Option 2', 'Option 3'], - multiselect: false, - } - - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - - // Act - const result = await provider.sendPoll(numberIn, text, poll) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalled() - }) - - test('should send poll message with correct options multiselect undefined', async () => { - // Arrange - const numberIn = phoneNumber - const text = 'Please vote' - const poll = { - options: ['Option 1', 'Option 2', 'Option 3'], - multiselect: undefined, - } - - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - - // Act - const result = await provider.sendPoll(numberIn, text, poll) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalled() - }) - - test('should send poll message with correct options multiselect true', async () => { - // Arrange - const numberIn = phoneNumber - const text = 'Please vote' - const poll = { - options: ['Option 1', 'Option 2', 'Option 3'], - multiselect: true, - } - - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendSuccess - - // Act - const result = await provider.sendPoll(numberIn, text, poll) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalled() - }) - - test('should return false if options length is less than 2', async () => { - // Arrange - const numberIn = phoneNumber - const text = 'Please vote' - const poll = { - options: ['Option 1'], - multiselect: false, - } - - // Act - const result = await provider.sendPoll(numberIn, text, poll) - - // Assert - expect(result).toBeFalsy() - }) - }) - - describe('#sendButtons', () => { - test('should emit notice event with correct details', async () => { - // Arrange - const number = phoneNumber - const text = 'Button message' - const buttons = [{ body: 'Button 1' }, { body: 'Button 2' }] - - const mockEmit = jest.fn() - provider.emit = mockEmit - provider.vendor.sendMessage = mockSendSuccess - provider.vendor.ws = { readyState: 1 } as any - // Act - await provider.sendButtons(number, text, buttons) - - // Assert - expect(mockEmit).toHaveBeenCalledWith('notice', { - title: 'DEPRECATED', - instructions: [ - 'Currently sending buttons is not available with this provider', - 'this function is available with Meta or Twilio', - ], - }) - }) - - test('should send button message with correct details', async () => { - // Arrange - const number = phoneNumber - const text = 'Button message' - const buttons = [{ body: 'Button 1' }, { body: 'Button 2' }] - - // Mock del método sendMessage - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - provider.vendor.ws = { readyState: 1 } as any - - // Act - const result = await provider.sendButtons(number, text, buttons) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalledWith(expect.any(String), { - text, - footer: '', - buttons: [ - { buttonId: 'id-btn-0', buttonText: { displayText: 'Button 1' }, type: 1 }, - { buttonId: 'id-btn-1', buttonText: { displayText: 'Button 2' }, type: 1 }, - ], - headerType: 1, - }) - }) - }) - - describe('#sendFile', () => { - test('should send file message with correct MIME type and file name', async () => { - // Arrange - const number = phoneNumber - const filePath = '/path/to/file/example.txt' - const mimeType = 'text/plain' - const fileName = 'example.txt' - const caption = 'Hello Word' - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - provider.vendor.ws = { readyState: 1 } as any - - // Act - const result = await provider.sendFile(number, filePath, caption) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalledWith(expect.any(String), { - document: { url: filePath }, - mimetype: mimeType, - fileName: fileName, - caption, - }) - }) - }) - - describe('#sendText', () => { - test('should send text message with correct content', async () => { - // Arrange - const number = phoneNumber - const message = 'This is a test message' - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - provider.vendor.ws = { readyState: 1 } as any - - // Act - const result = await provider.sendText(number, message) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalledWith(number, { text: message }) - }) - - test('should throw when vendor is undefined', async () => { - // Arrange — simulates the window between old socket teardown and new socket creation - provider.vendor = undefined as any - // Act & Assert - await expect(provider.sendText(phoneNumber, 'hello')).rejects.toThrow('Provider not connected') - }) - - test('should throw when WebSocket is not open (readyState !== 1)', async () => { - // Arrange — simulates a socket that is closed (readyState 3) or connecting (0) - provider.vendor = { ws: { readyState: 3 } } as any - // Act & Assert - await expect(provider.sendText(phoneNumber, 'hello')).rejects.toThrow('Provider not connected') - }) - }) - - describe('#sendAudio ', () => { - test('should send audio message with correct URL', async () => { - // Arrange - const number = phoneNumber - const audioUrl = 'http://example.com/audio.mp3' - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - provider.vendor.ws = { readyState: 1 } as any - - // Act - const result = await provider.sendAudio(number, audioUrl) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalledWith(number, { - audio: { url: audioUrl }, - ptt: true, - }) - }) - }) - - describe('#sendVideo', () => { - test('should send video message with correct file path and text', async () => { - // Arrange - const number = phoneNumber - const filePath = '/path/to/video.mp4' - const text = 'This is a video message' - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - provider.vendor.ws = { readyState: 1 } as any - - jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('sticker-buffer')) - // Act - const result = await provider.sendVideo(number, filePath, text) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalledWith(number, { - video: expect.any(Buffer), - caption: text, - gifPlayback: provider.globalVendorArgs.gifPlayback, - }) - }) - }) - - describe('#sendImage', () => { - test('should send image message with correct file path and text', async () => { - // Arrange - const number = phoneNumber - const filePath = '/path/to/image.jpg' - const text = 'This is an image message' - - const mockSendMessage = mockSendSuccess - provider.vendor.sendMessage = mockSendMessage - provider.vendor.ws = { readyState: 1 } as any - - // Act - const result = await provider.sendImage(number, filePath, text) - - // Assert - expect(result).toEqual('success') - expect(mockSendMessage).toHaveBeenCalledWith(number, { - image: { url: filePath }, - caption: text, - }) - }) - }) - - describe('#sendMedia', () => { - test('should send image when provided with image URL', async () => { - // Arrange - const number = '+123456789' - const imageUrl = 'https://example.com/image.jpg' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/image.jpg' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('image/jpeg') - const sendImageSpy = jest.spyOn(provider, 'sendImage').mockImplementation(async () => undefined) - - // Act - await provider.sendMedia(number, imageUrl, text) - - // Assert - expect(sendImageSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(imageUrl) - }) - - test('should send video when provided with video URL', async () => { - // Arrange - const number = '+123456789' - const videoUrl = 'https://example.com/video.mp4' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/audio.mp3' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('video/mp4') - const sendVideoSpy = jest.spyOn(provider, 'sendVideo').mockImplementation(async () => undefined) - - // Act - await provider.sendMedia(number, videoUrl, text) - // Assert - expect(sendVideoSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(videoUrl) - }) - - test('should send audio when provided with audio URL', async () => { - // Arrange - const number = '+123456789' - const audioUrl = 'https://example.com/audio.mp3' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/audio.mp3' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('audio/mp3') - const sendAudioSpy = jest.spyOn(provider, 'sendAudio').mockImplementation(async () => undefined) - // Act - await provider.sendMedia(number, audioUrl, text) - - // Assert - expect(sendAudioSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(audioUrl) - }) - - test('should send file when provided with file URL', async () => { - // Arrange - const number = '+123456789' - const fileUrl = 'https://example.com/test.pdf' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/test.pdf' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('text/plain') - const sendFileSpy = jest.spyOn(provider, 'sendFile').mockImplementation(async () => undefined) - // Act - await provider.sendMedia(number, fileUrl, text) - - // Assert - expect(sendFileSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(fileUrl) - }) - }) - - describe('#busEvents - messages.upsert ', () => { - test('Should return undefine if the type is different from notify', async () => { - // Arrange - const message = { - messages: [], - type: 'other', - } - // Act - const resul = await provider['busEvents']()[0].func(message) - - // Assert - expect(resul).toBeUndefined() - }) - - test('Should return undefine if the type message is equal from EPHEMERAL_SETTING', async () => { - // Arrange - const message = { - messages: [ - { - message: { - protocolMessage: { - type: 'EPHEMERAL_SETTING', - }, - }, - }, - ], - type: 'notify', - } - // Act - const resul = await provider['busEvents']()[0].func(message) - - // Assert - expect(resul).toBeUndefined() - }) - - test('Detect location in a message', async () => { - // Arrange - const mockMessage = { - message: { - locationMessage: { - degreesLatitude: 40.7128, - degreesLongitude: -74.006, - }, - }, - pushName: 'Sender Name', - key: { - remoteJid: phoneNumber, - }, - } - - // Act - await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(provider.emit).toHaveBeenCalled() - }) - - test('Detect video in a message', async () => { - // Arrange - const mockMessage = { - message: { - videoMessage: { - url: 'https://example.com/video.mp4', - }, - }, - pushName: 'Sender Name', - key: { - remoteJid: 'remoteJid', - }, - } - - // Act - await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - // Assert - expect(provider.emit).toHaveBeenCalled() - }) - - test('Detect sticker in a message', async () => { - // Arrange - const mockMessage = { - message: { - stickerMessage: {}, - }, - pushName: 'Sender Name', - key: { - remoteJid: 'remoteJid', - }, - } - - // Act - await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(provider.emit).toHaveBeenCalled() - }) - - test('Detectar imagen en un mensaje', async () => { - // Arrange - const mockMessage = { - message: { - imageMessage: {}, - }, - pushName: 'Sender Name', - key: { - remoteJid: 'remoteJid', - }, - } - - // Act - await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(provider.emit).toHaveBeenCalled() - }) - - test('Detect file in a message', async () => { - // Arrange - const mockMessage = { - message: { - documentMessage: {}, - }, - pushName: 'Sender Name', - key: { - remoteJid: 'remoteJid', - }, - } - - // Act - await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(provider.emit).toHaveBeenCalled() - }) - - test('Detect voice memo in a message', async () => { - // Arrange - const mockMessage = { - message: { - audioMessage: {}, - }, - pushName: 'Sender Name', - key: { - remoteJid: 'remoteJid', - }, - } - - // Act - await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(provider.emit).toHaveBeenCalled() - }) - - test('Detect broadcast in a message', async () => { - // Arrange - const mockMessage = { - message: {}, - key: { - remoteJid: 'status@broadcast', - }, - } - - // Act - const response = await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(response).toBeUndefined() - }) - - test('Invalid number', async () => { - // Arrange - const mockMessage = { - pushName: 'Usuario1', - key: { - remoteJid: 'remoteJid', - }, - message: { - extendedTextMessage: { - text: 'Hola, ¿cómo estás?', - }, - }, - from: '0987654321', - } - // Act - const response = await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(response).toBeUndefined() - }) - - test('btnCtx definite', async () => { - // Arrange - const mockMessage = { - pushName: 'Usuario1', - key: { - remoteJid: '1234567890', - }, - from: '1234567890', - message: { - buttonsResponseMessage: { - selectedDisplayText: 'Texto del botón', - }, - }, - } - // Act - provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(provider.emit).toHaveBeenCalled() - }) - - test('listRowId definite', async () => { - // Arrange - const mockMessage = { - message: { - listResponseMessage: { - title: 'Título de la lista', - }, - }, - key: { - remoteJid: '1234567890', - }, - from: '1234567890', - } - - // Act - provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(provider.emit).toHaveBeenCalled() - }) - - test('should call fallBackAction when defined and messageStubParameters contains Invalid', async () => { - // Arrange - const mockFallBackAction = jest.fn() as any - provider.globalVendorArgs.fallBackAction = mockFallBackAction - - const mockMessage = { - messageStubParameters: ['Invalid session token'], - key: { - remoteJid: '1234567890', - id: 'message123', - }, - pushName: 'Test User', - } - - // Act - const result = await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(mockFallBackAction).toHaveBeenCalledWith(mockMessage) - expect(result).toBeUndefined() // Should return early - }) - - test('should not call fallBackAction when not defined and messageStubParameters contains Invalid', async () => { - // Arrange - provider.globalVendorArgs.fallBackAction = undefined - provider.globalVendorArgs.experimentalSyncMessage = undefined - - const mockMessage = { - messageStubParameters: ['Invalid MAC signature'], - key: { - remoteJid: '1234567890@s.whatsapp.net', - id: 'message123', - }, - pushName: 'Test User', - } - - // Act - const result = await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(result).toBeUndefined() // Should return early without calling any action - }) - - test('should call experimentalSyncMessage when fallBackAction is not defined but experimentalSyncMessage is defined for Invalid messages', async () => { - // Arrange - provider.globalVendorArgs.fallBackAction = undefined - provider.globalVendorArgs.experimentalSyncMessage = 'Sync message test' - - const remoteJid = '1234567890@s.whatsapp.net' - const mockMessage = { - messageStubParameters: ['Invalid protocol'], - key: { - remoteJid: remoteJid, - id: 'message123', - }, - pushName: 'Test User', - } - - // Ensure mapSet doesn't have the remoteJid already (for fresh test) - if (provider['mapSet'].has(remoteJid)) { - provider['mapSet'].delete(remoteJid) - } - - const mockSendMessage = jest.fn() - provider.vendor = { - sendMessage: mockSendMessage, - } as any - - // Act - const result = await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - // Note: readMessages was removed in Baileys v7 to prevent bans - expect(mockSendMessage).toHaveBeenCalledWith(remoteJid, { text: 'Sync message test' }) - expect(result).toBeUndefined() - }) - - test('should not process Invalid messages when fallBackAction is defined but message is from group', async () => { - // Arrange - const mockFallBackAction = jest.fn() as any - provider.globalVendorArgs.fallBackAction = mockFallBackAction - - const mockMessage = { - messageStubParameters: ['Invalid session'], - key: { - remoteJid: '1234567890@g.us', // Group message - id: 'message123', - }, - pushName: 'Test User', - } - - // Act - const result = await provider['busEvents']()[0].func({ messages: [mockMessage], type: 'notify' }) - - // Assert - expect(mockFallBackAction).toHaveBeenCalledWith(mockMessage) - expect(result).toBeUndefined() - }) - }) - - describe('busEvents - messages.update', () => { - test('Survey update received', async () => { - // Arrange - const mockPollUpdate = { - pollUpdates: [], - } - const mockMessage = { - key: { - remoteJid: 'remoto123', - }, - update: mockPollUpdate, - } - - // Mock getAggregateVotesInPollMessage - const originalGetAggregateVotes = require('whaileys').getAggregateVotesInPollMessage - require('whaileys').getAggregateVotesInPollMessage = jest.fn().mockReturnValue({}) - - try { - // Act - await provider['busEvents']()[1].func([mockMessage]) - - // Assert - expect(provider.emit).toHaveBeenCalled() - } finally { - // Restore original function - require('whaileys').getAggregateVotesInPollMessage = originalGetAggregateVotes - } - }) - }) - - describe('#indexHome', () => { - test('should send the correct image file', () => { - // Arrange - const mockedReadStream = jest.fn() - const mockedFileStream = { pipe: jest.fn() } - mockedReadStream.mockReturnValueOnce(mockedFileStream) - require('fs').createReadStream = mockedReadStream - const req = { params: { idBotName: 'bot123' } } - const res = { writeHead: jest.fn(), end: jest.fn() } - const expectedImagePath = 'ruta/esperada/bot123.qr.png' - const mockedJoin = jest.spyOn(path, 'join') - mockedJoin.mockReturnValueOnce(expectedImagePath) - - // Act - provider['indexHome'](req as any, res as any, mockNext) - // Assert - expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'image/png' }) - }) - }) - - describe('#busEvents - call event', () => { - test('should emit message with @lid from preserved when call arrives from LID JID', async () => { - // Arrange — the bug was that @lid was stripped and @s.whatsapp.net was added, - // routing the reply to a completely different (random) phone number - const emitSpy = jest.spyOn(provider, 'emit') - const { baileyCleanNumber } = require('../src/utils') - baileyCleanNumber.mockImplementation((n: string) => n) - const callEvent = provider['busEvents']().find((e: any) => e.event === 'call') - const mockCall = { - from: '16424005304394@lid', - status: 'offer', - id: 'test-id', - chatId: '16424005304394@lid', - } - - // Act - await callEvent!.func([mockCall]) - - // Assert - expect(emitSpy).toHaveBeenCalledWith('message', expect.objectContaining({ from: '16424005304394@lid' })) - }) - - test('should not emit message when call status is not offer', async () => { - // Arrange - const emitSpy = jest.spyOn(provider, 'emit') - const callEvent = provider['busEvents']().find((e: any) => e.event === 'call') - const mockCall = { from: '16424005304394@lid', status: 'ringing', id: 'test-id' } - - // Clear any accumulated calls from setup or previous tests before the action - emitSpy.mockClear() - - // Act - await callEvent!.func([mockCall]) - - // Assert - expect(emitSpy).not.toHaveBeenCalledWith('message', expect.anything()) - }) - }) - - describe('#initVendor', () => { - test('should initialize when useBaileysStore is true', async () => { - // Arrange - jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) - provider.globalVendorArgs.usePairingCode = true - provider.globalVendorArgs.phoneNumber = phoneNumber - - // Act - await provider['initVendor']() - - // Assert - expect(useMultiFileAuthState).toHaveBeenCalled() - // Store is no longer used, so we don't check for makeInMemoryStore - }) - }) -}) diff --git a/packages/provider-sherpa/__tests__/utils.test.ts b/packages/provider-sherpa/__tests__/utils.test.ts deleted file mode 100644 index 7d9a7d2e9..000000000 --- a/packages/provider-sherpa/__tests__/utils.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { utils } from '@builderbot/bot' -import { expect, describe, test, jest } from '@jest/globals' -import { createWriteStream } from 'fs' -import fsExtra from 'fs-extra' -import { join } from 'path' -import * as qr from 'qr-image' - -import { sherpaCleanNumber, sherpaGenerateImage, sherpaIsValidNumber, emptyDirSessions } from '../src/utils' - -jest.mock('qr-image', () => ({ - image: jest.fn(() => ({ - pipe: jest.fn(), - })), -})) - -jest.mock('fs-extra', () => ({ - emptyDir: jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(null)), -})) - -jest.mock('@builderbot/bot', () => ({ - utils: { - cleanImage: jest.fn(), - }, -})) - -jest.mock('fs', () => ({ - createWriteStream: jest.fn().mockReturnValue({ - on: jest.fn(), - }), -})) - -describe('sherpaCleanNumber', () => { - test('should remove @s.whatsapp.net and + when full is true', () => { - // Arrange - const originalNumber = '+1234567890@s.whatsapp.net' - // Act - const cleanedNumber = sherpaCleanNumber(originalNumber, true) - // Assert - expect(cleanedNumber).toEqual('1234567890') - }) - - test('should preserve @lid JIDs as-is when full is true', () => { - // Arrange — LID JIDs must not be stripped or converted to @s.whatsapp.net - // because stripping @lid and adding @s.whatsapp.net routes to the wrong number - const lidNumber = '16424005304394@lid' - // Act - const result = sherpaCleanNumber(lidNumber, true) - // Assert - expect(result).toEqual('16424005304394@lid') - }) - - test('should preserve @lid JIDs as-is when full is false', () => { - // Arrange - const lidNumber = '16424005304394@lid' - // Act - const result = sherpaCleanNumber(lidNumber) - // Assert - expect(result).toEqual('16424005304394@lid') - }) -}) - -describe('#sherpaIsValidNumber', () => { - test('should return true if the number is valid', () => { - // Arrange - const validNumber = '+1234567890@s.whatsapp.net' - - // Act - const isValid = sherpaIsValidNumber(validNumber) - - // Assert - expect(isValid).toBe(true) - }) - - test('should return false if the number is invalid', () => { - // Arrange - const invalidNumber = '+1234567890@g.us' - - // Act - const isValid = sherpaIsValidNumber(invalidNumber) - - // Assert - expect(isValid).toBeFalsy() - }) - - test('should return true if the number does not contain @g.us', () => { - // Arrange - const numberWithoutGroup = '+1234567890@s.whatsapp.net' - - // Act - const isValid = sherpaIsValidNumber(numberWithoutGroup) - - // Assert - expect(isValid).toBeTruthy() - }) - - test('should return false if the number is empty', () => { - // Arrange - const emptyNumber = '' - - // Act - const isValid = sherpaIsValidNumber(emptyNumber) - - // Assert - // Updated for Baileys v7.0.0+ LID system - empty strings are invalid - expect(isValid).toBeFalsy() - }) -}) - -describe('#sherpaGenerateImage', () => { - test('should generate an image file from a base64 string', () => { - // Arrange - const base64 = 'yourBase64String' - const imageName = 'test_image.png' - const imagePath = join(process.cwd(), imageName) - const mockWriteStream = { - on: jest.fn(), - write: jest.fn(), - end: jest.fn(), - } - const mockPipe = jest.fn().mockReturnValue(mockWriteStream) - const mockQrSvg = { pipe: mockPipe } - ;(qr.image as jest.Mock).mockReturnValue(mockQrSvg) - ;(createWriteStream as jest.Mock).mockReturnValue(jest.fn()) - - // Act - sherpaGenerateImage(base64, imageName).then((result) => { - // Assert - expect(result).toBeTruthy() - expect(qr.image).toHaveBeenCalledWith(base64, { type: 'png', margin: 4 }) - expect(utils.cleanImage).toHaveBeenCalledWith(imagePath) - expect(createWriteStream).toHaveBeenCalledWith(imagePath) - expect(mockWriteStream.on).toHaveBeenCalledWith('finish', expect.any(Function)) - }) - }) -}) - -describe('#mockEmptyDir', () => { - test('should empty the directory correctly', async () => { - // Arrange - const pathBase = '/path/to/directory' - const mockEmptyDir = jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(null)) - - jest.spyOn(fsExtra, 'emptyDir').mockImplementation(mockEmptyDir as any) - - // Act - await emptyDirSessions(pathBase) - - // Assert - expect(mockEmptyDir).toHaveBeenCalledWith(pathBase, expect.any(Function)) - }) - - test('should handle errors when emptying the directory', async () => { - // Arrange - const pathBase = '/path/to/directory' - const error = new Error('Failed to empty directory') - const mockEmptyDir = jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(error)) - - jest.spyOn(fsExtra, 'emptyDir').mockImplementation(mockEmptyDir as any) - - // Act & Assert - await expect(emptyDirSessions(pathBase)).rejects.toEqual(error) - }) -}) diff --git a/packages/provider-sherpa/config/api-extractor.json b/packages/provider-sherpa/config/api-extractor.json deleted file mode 100644 index 3199a2fac..000000000 --- a/packages/provider-sherpa/config/api-extractor.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - - "mainEntryPointFilePath": "/dist/index.d.ts", - - "apiReport": { - "enabled": true, - "reportFileName": "api.md", - "reportFolder": "/" - }, - - "docModel": { - "enabled": true - }, - - "dtsRollup": { - "enabled": true - }, - - "messages": { - "compilerMessageReporting": { - "default": { - "logLevel": "warning" - } - }, - - "extractorMessageReporting": { - "default": { - "logLevel": "warning" - } - }, - - "tsdocMessageReporting": { - "default": { - "logLevel": "warning" - } - } - } -} diff --git a/packages/provider-sherpa/jest.config.ts b/packages/provider-sherpa/jest.config.ts deleted file mode 100644 index 7a23a6c6c..000000000 --- a/packages/provider-sherpa/jest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, - testEnvironment: 'node', -} - -export default config diff --git a/packages/provider-sherpa/package.json b/packages/provider-sherpa/package.json deleted file mode 100644 index e516b6702..000000000 --- a/packages/provider-sherpa/package.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "name": "@builderbot/provider-sherpa", - "version": "1.4.2-alpha.11", - "description": "Provider Sherpa for BuilderBot - WhatsApp integration using Whaileys", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --forceExit", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@hapi/boom": "^10.0.1", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/cors": "^2.8.17", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "body-parser": "^2.2.1", - "cors": "^2.8.5", - "jest": "^30.2.0", - "mime-types": "^3.0.2", - "pino": "^10.1.0", - "polka": "^0.5.2", - "qr-image": "^3.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1", - "ts-jest": "^29.4.6", - "ts-node": "^10.9.2", - "wa-sticker-formatter": "^4.4.4", - "wtfnode": "^0.10.1" - }, - "dependencies": { - "@adiwajshing/keyed-db": "^0.2.4", - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "@types/polka": "^0.5.7", - "fluent-ffmpeg": "^2.1.2", - "fs-extra": "^11.3.2", - "jimp": "^1.6.0", - "node-cache": "^5.1.2", - "qrcode-terminal": "^0.12.0", - "rollup": "^4.53.3", - "sharp": "0.33.3", - "tslib": "^2.8.1", - "typescript": "^5.9.3", - "whaileys": "6.3.8" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-sherpa/rollup.config.js b/packages/provider-sherpa/rollup.config.js deleted file mode 100644 index f6cfc4f66..000000000 --- a/packages/provider-sherpa/rollup.config.js +++ /dev/null @@ -1,33 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import json from '@rollup/plugin-json' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - { - dir: 'dist', - entryFileNames: '[name].mjs', - format: 'es', - exports: 'named', - }, - ], - plugins: [ - json(), - commonjs(), - nodeResolve({ - resolveOnly: (module) => - !/ffmpeg|@adiwajshing|link-preview-js|@leifermendez\/baileys|baileys|@builderbot\/bot|sharp|qrcode-terminal/i.test( - module - ), - }), - typescript(), - // terser() - ], -} diff --git a/packages/provider-sherpa/src/index.ts b/packages/provider-sherpa/src/index.ts deleted file mode 100644 index 869da1f99..000000000 --- a/packages/provider-sherpa/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { baileyCleanNumber } from './utils' - -export * from './sherpa' -export { baileyCleanNumber } diff --git a/packages/provider-sherpa/src/releaseTmp.ts b/packages/provider-sherpa/src/releaseTmp.ts deleted file mode 100644 index 320aeb7e1..000000000 --- a/packages/provider-sherpa/src/releaseTmp.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { existsSync } from 'fs' -import { readdir, unlink } from 'fs/promises' -import { join } from 'path' - -const keepFiles = ['creds.json', 'sherpa_store.json', 'app-state-sync', 'session'] - -/** - * @alpha - * @param sessionName - */ -export const releaseTmp = async (sessionName: string, ms: number): Promise => { - const PATH_SRC = join(process.cwd(), sessionName) - - if (!existsSync(PATH_SRC)) { - return - } - - const filesToClean = await readdir(PATH_SRC) - - const deleteFiles = async () => { - for (const iterator of filesToClean) { - const checkFile = keepFiles.some((i) => iterator.includes(i)) - if (!checkFile) { - try { - const fileToDelete = join(PATH_SRC, iterator) - if (!existsSync(fileToDelete)) { - return - } - await unlink(fileToDelete) - console.log(`🏷️ Clean:`, iterator) - } catch (e) { - console.log(`Error:`, e) - } - } - } - } - - const idTimer = setInterval(deleteFiles, ms) - return idTimer -} diff --git a/packages/provider-sherpa/src/sherpa.ts b/packages/provider-sherpa/src/sherpa.ts deleted file mode 100644 index 8f0a2ee59..000000000 --- a/packages/provider-sherpa/src/sherpa.ts +++ /dev/null @@ -1,1292 +0,0 @@ -import { ProviderClass, utils } from '@builderbot/bot' -import type { BotContext, Button, SendOptions } from '@builderbot/bot/dist/types' -import type { Boom } from '@hapi/boom' -import { Console } from 'console' -import type { PathOrFileDescriptor } from 'fs' -import { createReadStream, createWriteStream, readFileSync } from 'fs' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import NodeCache from 'node-cache' -import { tmpdir } from 'os' -import { join, basename, resolve } from 'path' -import pino from 'pino' -import type polka from 'polka' -import type { IStickerOptions } from 'wa-sticker-formatter' -import { Sticker } from 'wa-sticker-formatter' -import { WAVersion, WABrowserDescription } from 'whaileys' - -import { releaseTmp } from './releaseTmp' -import { - AnyMediaMessageContent, - AnyMessageContent, - BaileysEventMap, - WAMessage, - WASocket, - MessageUpsertType, - isJidGroup, - isJidBroadcast, - DisconnectReason, - downloadMediaMessage, - getAggregateVotesInPollMessage, - makeCacheableSignalKeyStore, - makeWASocketOther, - proto, - useMultiFileAuthState, -} from './sherpaWrapper' -import type { WALogger } from './sherpaWrapper' -import type { SherpaGlobalVendorArgs } from './type' -import { - baileyGenerateImage, - baileyCleanNumber, - baileyCleanNumber as sherpaCleanNumber, - baileyIsValidNumber as sherpaIsValidNumber, - emptyDirSessions, -} from './utils' - -class SherpaProvider extends ProviderClass { - public globalVendorArgs: SherpaGlobalVendorArgs = { - name: `bot`, - gifPlayback: false, - usePairingCode: false, - browser: ['Windows', 'Chrome', 'Chrome 114.0.5735.198'] as WABrowserDescription, - phoneNumber: null, - useBaileysStore: true, - port: 3000, - timeRelease: 0, //21600000 - writeMyself: 'none', - groupsIgnore: true, - readStatus: false, - experimentalStore: false, - autoRefresh: 0, - experimentalSyncMessage: undefined, - fallBackAction: undefined, - } - - private reconnectAttempts = 0 - private maxReconnectAttempts = 50 - private reconnectDelay = 1000 // 1 segundo inicial - private healthCheckInterval?: ReturnType - private presenceInterval?: ReturnType - private periodicCleanupInterval?: ReturnType - private badSessionCount = 0 - - msgRetryCounterCache?: NodeCache - userDevicesCache?: NodeCache - - private logger: Console - private logStream: NodeJS.WritableStream - - private idsDuplicates = [] - private mapSet = new Set() - - constructor(args: Partial) { - super() - - this.logStream = createWriteStream(`${process.cwd()}/sherpa.log`, { - flags: 'a', - autoClose: true, - emitClose: true, - }) - - this.logger = new Console({ - stdout: this.logStream, - stderr: this.logStream, - }) - - this.msgRetryCounterCache = new NodeCache({ - stdTTL: 1800, // 30 minutos (más tiempo para reintentos) - checkperiod: 300, // Limpieza cada 5 minutos (menos frecuente) - maxKeys: 50000, // 50K entradas (más espacio) - deleteOnExpire: true, - useClones: false, - forceString: false, - errorOnMissing: false, - }) - - this.userDevicesCache = new NodeCache({ - stdTTL: 7200, // 2 horas (dispositivos cambian poco) - checkperiod: 600, // Limpieza cada 10 minutos - maxKeys: 5000, // Más dispositivos - deleteOnExpire: true, - useClones: false, - forceString: false, - errorOnMissing: false, - }) - - this.globalVendorArgs = { ...this.globalVendorArgs, ...args } - - this.setupCleanupHandlers() - this.setupPeriodicCleanup() - } - - /** - * Setup cleanup handlers - * @description - * - Remove existing listeners to prevent duplicates - * - Add new listeners - * - Add cleanup function to all listeners - * - Add cleanup function to uncaughtException and unhandledRejection - * - Add cleanup function to SIGINT, SIGTERM, SIGUSR1, SIGUSR2 - * - Add cleanup function to process.exit - */ - private setupCleanupHandlers() { - const cleanup = () => { - this.logger.log(`[${new Date().toISOString()}] Iniciando limpieza de recursos...`) - this.cleanup() - } - - // Remove existing listeners to prevent duplicates - process.removeAllListeners('SIGINT') - process.removeAllListeners('SIGTERM') - process.removeAllListeners('SIGUSR1') - process.removeAllListeners('SIGUSR2') - process.removeAllListeners('uncaughtException') - process.removeAllListeners('unhandledRejection') - - process.on('SIGINT', cleanup) - process.on('SIGTERM', cleanup) - process.on('SIGUSR1', cleanup) - process.on('SIGUSR2', cleanup) - - process.on('uncaughtException', (error) => { - this.logger.log(`[${new Date().toISOString()}] Uncaught Exception:`, error) - this.cleanup() - process.exit(1) - }) - - process.on('unhandledRejection', (reason, promise) => { - this.logger.log(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason) - }) - } - - private setupPeriodicCleanup() { - // Limpiar duplicados cada 10 minutos para evitar memory leaks - this.periodicCleanupInterval = setInterval(() => { - const maxSize = 1000 - if (this.idsDuplicates.length > maxSize) { - this.logger.log( - `[${new Date().toISOString()}] Cleaning duplicates array: ${ - this.idsDuplicates.length - } -> ${maxSize}` - ) - this.idsDuplicates = this.idsDuplicates.slice(-maxSize) // Mantener solo los últimos 1000 - } - - // Limpiar mapSet si tiene demasiadas entradas - if (this.mapSet.size > maxSize) { - this.logger.log(`[${new Date().toISOString()}] Cleaning mapSet: ${this.mapSet.size} -> 0`) - this.mapSet.clear() - } - }, 600000) // 10 minutos - } - - private cleanup() { - try { - this.stopHealthCheck() - if (this.periodicCleanupInterval) { - clearInterval(this.periodicCleanupInterval) - this.periodicCleanupInterval = undefined - } - if (this.msgRetryCounterCache) { - this.msgRetryCounterCache.close() - this.msgRetryCounterCache = undefined - } - - if (this.userDevicesCache) { - this.userDevicesCache.close() - this.userDevicesCache = undefined - } - - this.mapSet.clear() - this.idsDuplicates.length = 0 - - if (this.logStream && typeof this.logStream.end === 'function') { - this.logStream.end() - } - - this.logger.log(`[${new Date().toISOString()}] Recursos limpiados correctamente`) - } catch (error) { - console.error('Error durante cleanup:', error) - } - } - - public async releaseSessionFiles() { - const NAME_DIR_SESSION = `${this.globalVendorArgs.name}_sessions` - const idTimer = await releaseTmp(NAME_DIR_SESSION, 0) - clearInterval(idTimer) - } - - protected beforeHttpServerInit(): void { - this.server = this.server - .use((req: any, _: any, next: () => any) => { - req['globalVendorArgs'] = this.globalVendorArgs - return next() - }) - .get('/', this.indexHome) - } - - protected afterHttpServerInit(): void {} - - public indexHome: polka.Middleware = (req, res) => { - try { - const botName = req[this.idBotName] - const qrPath = join(process.cwd(), `${botName}.qr.png`) - const fileStream = createReadStream(qrPath) - res.writeHead(200, { 'Content-Type': 'image/png' }) - fileStream.pipe(res) - } catch (e) { - res.writeHead(404, { 'Content-Type': 'text/html' }) - res.end(` - - - - - QR Not Ready - - -

QR code is not ready yet. The page will automatically refresh in 5 seconds.

- - - `) - } - } - - protected getMessage = async (key: { remoteJid: string; id: string }) => { - // only if store is present - return proto.Message.create({}) - } - - protected saveCredsGlobal: (() => Promise) | null = null - - /** - * Iniciar todo Sherpa - */ - protected initVendor = async () => { - const NAME_DIR_SESSION = `${this.globalVendorArgs.name}_sessions` - const { state, saveCreds } = await useMultiFileAuthState(NAME_DIR_SESSION) - const loggerSherpa = pino({ level: 'fatal' }) as unknown as WALogger - - this.saveCredsGlobal = saveCreds - - try { - if (this.globalVendorArgs.useBaileysStore) { - if (this.globalVendorArgs.timeRelease > 0) { - await releaseTmp(NAME_DIR_SESSION, this.globalVendorArgs.timeRelease) - } - } - } catch (e) { - this.logger.log(e) - this.initVendor().then((v) => this.listenOnEvents(v)) - } - - try { - // Close previous socket to prevent leaked connections and event listeners - if (this.vendor) { - try { - // Remove all event listeners by event type (whaileys requires event argument) - const events: (keyof BaileysEventMap)[] = [ - 'connection.update', - 'creds.update', - 'messages.upsert', - 'messages.update', - 'call', - ] - events.forEach((event) => { - try { - this.vendor?.ev?.removeAllListeners(event) - } catch { - // Ignore if event doesn't exist - } - }) - this.vendor.ws?.close() - this.vendor.end(undefined) - } catch (e) { - this.logger.log(`[${new Date().toISOString()}] Error closing previous socket:`, e) - } - this.vendor = undefined - } - - const waVersion = this.globalVendorArgs.version ?? [2, 3000, 1023223821] - - const sock = makeWASocketOther({ - logger: loggerSherpa, - version: waVersion as WAVersion, - printQRInTerminal: false, - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, loggerSherpa), - }, - browser: this.globalVendorArgs.browser as WABrowserDescription, - syncFullHistory: false, - markOnlineOnConnect: false, - generateHighQualityLinkPreview: true, - getMessage: this.getMessage, - msgRetryCounterMap: {}, - userDevicesCache: this.userDevicesCache as any, - retryRequestDelayMs: 2000, // Delay entre reintentos (2s para evitar rate-limit) - connectTimeoutMs: 60_000, // 1 minuto timeout conexión - keepAliveIntervalMs: 15_000, // Keep alive cada 15 segundos (recomendado por comunidad) - qrTimeout: 40_000, // 40 segundos para QR - defaultQueryTimeoutMs: 60_000, // 1 minuto para queries - emitOwnEvents: false, // No emitir eventos propios - shouldIgnoreJid: (jid: string) => { - if (this.globalVendorArgs.groupsIgnore) { - return isJidGroup(jid) || isJidBroadcast(jid) - } - return false - }, - ...this.globalVendorArgs, - }) - - this.vendor = sock - if (this.globalVendorArgs.usePairingCode && !sock.authState.creds.registered) { - if (this.globalVendorArgs.phoneNumber) { - const phoneNumberClean = utils.removePlus(this.globalVendorArgs.phoneNumber) - await utils.delay(2000) - const code = await this.vendor.requestPairingCode(phoneNumberClean) - this.emit('require_action', { - title: '⚡⚡ ACTION REQUIRED ⚡⚡', - instructions: [ - `Accept the WhatsApp notification from ${this.globalVendorArgs.phoneNumber} on your phone 👌`, - `The pairing code is: ${code}`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ], - payload: { qr: null, code: code }, - }) - } else { - this.emit('auth_failure', [ - `The phone number has not been defined, please add it`, - `Restart the BOT`, - `You can also check a log that has been created baileys.log`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ]) - } - } - - sock.ev.on('connection.update', async (update: { connection: any; lastDisconnect: any; qr: any }) => { - const { connection, lastDisconnect, qr } = update - - this.logger.log(`[${new Date().toISOString()}] Connection update: ${connection}`) - - const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode - const reason = lastDisconnect?.error?.message - - /** Connection closed for various reasons */ - if (connection === 'close') { - this.stopHealthCheck() - this.logger.log( - `[${new Date().toISOString()}] Connection closed. Status: ${statusCode}, Reason: ${reason}` - ) - - // Check if device was removed - const errorData = lastDisconnect?.error?.data - const isDeviceRemoved = errorData?.content?.some( - (item: any) => item?.attrs?.type === 'device_removed' - ) - - if (isDeviceRemoved) { - console.log( - `[${new Date().toISOString()}] ⚠️ Device removed - Session was deleted from WhatsApp` - ) - this.logger.log(`[${new Date().toISOString()}] Device removed detected, clearing session...`) - const PATH_BASE = join(process.cwd(), `${this.globalVendorArgs.name}_sessions`) - await emptyDirSessions(PATH_BASE) - this.reconnectAttempts = 0 - this.emit('auth_failure', [ - `Something unexpected has occurred, do not panic`, - `Restart the BOT`, - `You can also check a log that has been created sherpa.log`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ]) - return - } - - // Casos donde NO debemos reconectar - if (statusCode === DisconnectReason.loggedOut) { - this.logger.log(`[${new Date().toISOString()}] Logged out, clearing session and restarting...`) - const PATH_BASE = join(process.cwd(), `${this.globalVendorArgs.name}_sessions`) - await emptyDirSessions(PATH_BASE) - this.reconnectAttempts = 0 - await this.delayedReconnect() - return - } - - // badSession: try reconnecting first, but if it repeats too many times, clear session - if (statusCode === DisconnectReason.badSession) { - this.badSessionCount++ - if (this.badSessionCount >= 3) { - this.logger.log( - `[${new Date().toISOString()}] Bad session repeated ${this.badSessionCount} times, clearing session...` - ) - const PATH_BASE = join(process.cwd(), `${this.globalVendorArgs.name}_sessions`) - await emptyDirSessions(PATH_BASE) - this.badSessionCount = 0 - this.reconnectAttempts = 0 - } - await this.delayedReconnect() - return - } - - // Casos donde debemos reconectar con backoff - if (this.shouldReconnect(statusCode)) { - await this.delayedReconnect() - return - } - - // If statusCode is undefined/unknown, attempt reconnect rather than dying - if (statusCode === undefined || statusCode === null) { - this.logger.log( - `[${new Date().toISOString()}] Unknown disconnect (no status code), attempting reconnect...` - ) - await this.delayedReconnect() - return - } - - // Casos críticos - emitir error - this.logger.log(`[${new Date().toISOString()}] Critical error, stopping reconnection attempts`) - this.emit('auth_failure', [ - `Critical connection error: ${reason}`, - `Status code: ${statusCode}`, - `Check sherpa.log for details`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ]) - } - - /** Connection opened successfully */ - if (connection === 'open') { - this.logger.log(`[${new Date().toISOString()}] Connection opened successfully`) - this.reconnectAttempts = 0 // Reset counter on successful connection - this.reconnectDelay = 1000 // Reset delay - this.badSessionCount = 0 // Reset bad session counter - this.startHealthCheck() - - const parseNumber = `${sock?.user?.id}`.split(':').shift() - const host = { ...sock?.user, phone: parseNumber } - this.globalVendorArgs.host = host - this.emit('ready', true) - this.emit('host', host) - } - - /** QR Code */ - if (qr && !this.globalVendorArgs.usePairingCode) { - this.logger.log(`[${new Date().toISOString()}] QR Code received`) - this.emit('require_action', { - title: '⚡⚡ ACTION REQUIRED ⚡⚡', - instructions: [ - `You must scan the QR Code`, - `Remember that the QR code updates every minute`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ], - payload: { qr }, - }) - await baileyGenerateImage(qr, `${this.globalVendorArgs.name}.qr.png`) - } - }) - - sock.ev.on('creds.update', async () => { - await saveCreds() - }) - - return sock.ev - } catch (e) { - this.logger.log(e) - this.emit('auth_failure', [ - `Something unexpected has occurred, do not panic`, - `Restart the BOT`, - `You can also check a log that has been created sherpa.log`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ]) - } - } - - /** - * Extrae el número de teléfono (from) de un mensaje - * @param messageCtx - Contexto del mensaje - * @returns El número de teléfono extraído - */ - private extractFromFromMessage(messageCtx: WAMessage): string | undefined { - const remoteJid = (messageCtx?.key as any)?.remoteJid - const remoteJidAlt = (messageCtx?.key as any)?.remoteJidAlt - // Si remoteJid contiene @lid, usar remoteJidAlt si existe, sino extraer el número de remoteJid - const fromParse = remoteJid?.includes('@lid') ? remoteJidAlt || remoteJid?.split('@')[0] : remoteJid - return fromParse - } - - /** - * Map native events that the Provider class expects - * to have a standard set of events - * @returns - */ - protected busEvents = (): { - event: keyof BaileysEventMap - func: (arg?: any, arg2?: any) => any - }[] => [ - { - event: 'messages.upsert', - func: async (argFromProvider) => { - const { messages, type } = argFromProvider as { - type: MessageUpsertType - messages: WAMessage[] - } - if (type !== 'notify') return - - const pingMessageSync = async (_messageCtx: proto.IWebMessageInfo) => { - if (!this.mapSet.has(_messageCtx?.key?.remoteJid)) { - try { - this.mapSet.add(_messageCtx?.key?.remoteJid) - const jid = _messageCtx?.key?.remoteJid - - // Removed readMessages() call - Baileys v7 no longer sends ACKs to prevent bans - await this.vendor.sendMessage(jid, { - text: this.globalVendorArgs.experimentalSyncMessage, - }) - } catch (e) { - this.logger.log(e) - } - } - } - - for (const messageCtx of messages) { - if ( - messageCtx?.messageStubParameters?.length && - messageCtx.messageStubParameters[0].includes('absent') - ) - continue - if ( - messageCtx?.messageStubParameters?.length && - messageCtx.messageStubParameters[0].includes('No session') - ) - continue - if ( - messageCtx?.messageStubParameters?.length && - messageCtx.messageStubParameters[0].includes('Bad MAC') - ) - continue - if ( - messageCtx?.messageStubParameters?.length && - messageCtx.messageStubParameters[0].includes('Invalid') - ) { - if (this.globalVendorArgs.fallBackAction) { - try { - await this.globalVendorArgs.fallBackAction(messageCtx) - } catch (error) { - continue - } - continue - } - - if ( - this.globalVendorArgs.experimentalSyncMessage && - this.globalVendorArgs.experimentalSyncMessage.length - ) { - if (sherpaIsValidNumber(messageCtx?.key?.remoteJid)) { - await pingMessageSync(messageCtx) - } - continue - } - continue - } - // if (((messageCtx?.message?.protocolMessage?.type) as unknown as string) === 'EPHEMERAL_SETTING') continue - - const textToBody = - messageCtx?.message?.ephemeralMessage?.message?.extendedTextMessage?.text ?? - messageCtx?.message?.extendedTextMessage?.text ?? - messageCtx?.message?.conversation - - if (textToBody) { - if (textToBody === 'requestPlaceholder' && !(messageCtx as any).requestId) { - try { - if (this.vendor.requestPlaceholderResend) { - const messageId = await this.vendor.requestPlaceholderResend([ - { messageKey: messageCtx.key }, - ]) - this.logger.log( - `[${new Date().toISOString()}] Requested placeholder resync, id=${messageId}` - ) - } - continue // No procesar como mensaje normal - } catch (e) { - this.logger.log(`[${new Date().toISOString()}] Error requesting placeholder resync:`, e) - } - } - - if (textToBody === 'onDemandHistSync') { - try { - if (this.vendor.fetchMessageHistory) { - const messageId = await this.vendor.fetchMessageHistory( - 50, - messageCtx.key, - messageCtx.messageTimestamp - ) - this.logger.log( - `[${new Date().toISOString()}] Requested on-demand sync, id=${messageId}` - ) - } - continue // No procesar como mensaje normal - } catch (e) { - this.logger.log(`[${new Date().toISOString()}] Error requesting history sync:`, e) - } - } - - if ((messageCtx as any).requestId) { - this.logger.log( - `[${new Date().toISOString()}] Message received from phone, id=${ - (messageCtx as any).requestId - }`, - messageCtx - ) - } - } - - // Extraer el número de teléfono del mensaje - const from = this.extractFromFromMessage(messageCtx) - - let payload = { - ...messageCtx, - body: textToBody, - name: messageCtx?.pushName, - from: baileyCleanNumber(from), - } - - if (messageCtx.message?.locationMessage) { - const { degreesLatitude, degreesLongitude } = messageCtx.message.locationMessage - if (typeof degreesLatitude === 'number' && typeof degreesLongitude === 'number') { - payload = { - ...payload, - body: utils.generateRefProvider('_event_location_'), - } - } - } - - if (messageCtx.message?.videoMessage) { - payload = { - ...payload, - body: utils.generateRefProvider('_event_media_'), - } - } - - if (messageCtx.message?.stickerMessage) { - payload = { - ...payload, - body: utils.generateRefProvider('_event_media_'), - } - } - - if (messageCtx.message?.imageMessage) { - payload = { - ...payload, - body: utils.generateRefProvider('_event_media_'), - } - } - - if (messageCtx.message?.documentMessage || messageCtx.message?.documentWithCaptionMessage) { - payload = { - ...payload, - body: utils.generateRefProvider('_event_document_'), - } - } - - if (messageCtx.message?.audioMessage) { - payload = { - ...payload, - body: utils.generateRefProvider('_event_voice_note_'), - } - } - - if (messageCtx.message?.orderMessage) { - payload = { - ...payload, - body: utils.generateRefProvider('_event_order_'), - } - } - - if (payload.from === 'status@broadcast') continue - payload.from = sherpaCleanNumber(payload.from, true) - - if (this.globalVendorArgs.writeMyself === 'none' && payload?.key?.fromMe) continue - if ( - this.globalVendorArgs.host?.phone !== payload.from && - payload?.key?.fromMe && - !['both'].includes(this.globalVendorArgs.writeMyself) - ) - continue - if ( - this.globalVendorArgs.host?.phone === payload.from && - !['both', 'host'].includes(this.globalVendorArgs.writeMyself) - ) - continue - - if (!sherpaIsValidNumber(payload.from)) { - continue - } - - const btnCtx = payload?.message?.buttonsResponseMessage?.selectedDisplayText - if (btnCtx) payload.body = btnCtx - - const listRowId = payload?.message?.listResponseMessage?.title - if (listRowId) payload.body = listRowId - - const processDuplicate = () => { - if (messageCtx?.key?.id) { - const idWs = `${messageCtx.key.id}__${payload.from}` - const isDuplicate = this.idsDuplicates.includes(idWs) - if (isDuplicate) { - this.idsDuplicates = [] - return false - } - if (this.idsDuplicates.length > 10) { - this.idsDuplicates = [] - } - this.idsDuplicates.push(idWs) - } - return true - } - - if (processDuplicate()) { - this.emit('message', payload) - } - } - }, - }, - { - event: 'messages.update', - func: async (message) => { - for (const { key, update } of message) { - if (update.pollUpdates) { - const pollCreation = await this.getMessage(key) - if (pollCreation) { - const pollMessage = getAggregateVotesInPollMessage({ - message: pollCreation, - pollUpdates: update.pollUpdates, - }) - const [messageCtx] = message - - if ( - !messageCtx || - !messageCtx.update || - !messageCtx.update.pollUpdates || - messageCtx.update.pollUpdates.length === 0 - ) { - continue - } - - const payload = { - ...messageCtx, - body: pollMessage.find((poll) => poll.voters.length > 0)?.name || '', - from: baileyCleanNumber(key.remoteJid, true), - voters: pollCreation, - type: 'poll', - } - this.emit('message', payload) - } - } - } - }, - }, - { - event: 'call', - func: async ([call]) => { - if (call.status === 'offer') { - const payload = { - from: baileyCleanNumber(call.from, true), - body: utils.generateRefProvider('_event_call_'), - call, - } - - this.emit('message', payload) - // Opcional: Rechazar automáticamente la llamada - // await this.vendor.rejectCall(call.id, call.from) - } - }, - }, - ] - - /** - * @param {string} orderId - * @param {string} orderToken - * @example await getOrderDetails('order-id', 'order-token') - */ - getOrderDetails = async (orderId: string, orderToken: string) => { - const orderDetails = await this.vendor.getOrderDetails(orderId, orderToken) - return orderDetails - } - - /** - * @deprecated sendPoll is not available in whaileys provider - * @param {string} number - * @param {string} text - * @param {object} poll - * @returns {Promise} - */ - sendPoll = async ( - numberIn: string, - text: string, - poll: { options: string[]; multiselect: any } - ): Promise => { - this.emit('notice', { - title: 'METHOD NOT AVAILABLE', - instructions: [ - `sendPoll is not available with whaileys provider`, - `This feature is only available with baileys provider`, - ], - }) - console.log(`[SherpaProvider] ⚠️ sendPoll method is not available in whaileys. Use baileys provider instead.`) - return false - } - - /** - * Obtener LID (Local Identifier) para un número de teléfono (PN) - * @param {string} phoneNumber - Número de teléfono en formato JID (e.g., '1234567890@s.whatsapp.net') - * @returns {Promise} - El LID correspondiente o null si no se encuentra - * @example await getLIDForPN('1234567890@s.whatsapp.net') - */ - getLIDForPN = async (phoneNumber: string) => { - try { - const vendor = this.vendor as any - if (vendor?.signalRepository?.lidMapping?.getLIDForPN) { - return await vendor.signalRepository.lidMapping.getLIDForPN(phoneNumber) - } - return null - } catch (e) { - this.logger.log(`[${new Date().toISOString()}] Error getting LID for PN:`, e) - return null - } - } - - /** - * Obtener número de teléfono (PN) para un LID (Local Identifier) - * @param {string} lid - Local Identifier - * @returns {Promise} - El número de teléfono correspondiente o null si no se encuentra - * @example await getPNForLID('lid:xxxxxx') - */ - getPNForLID = async (lid: string) => { - try { - const vendor = this.vendor as any - if (vendor?.signalRepository?.lidMapping?.getPNForLID) { - return await vendor.signalRepository.lidMapping.getPNForLID(lid) - } - return null - } catch (e) { - this.logger.log(`[${new Date().toISOString()}] Error getting PN for LID:`, e) - return null - } - } - - /** - * @param {string} number - * @param {string} message - * @example await sendMessage('+XXXXXXXXXXX', 'https://dominio.com/imagen.jpg' | 'img/imagen.jpg') - */ - - sendMedia = async (number: string, imageUrl: string, text: string) => { - const fileDownloaded = await utils.generalDownload(imageUrl) - const mimeType = mime.lookup(fileDownloaded) - if (`${mimeType}`.includes('image')) return this.sendImage(number, fileDownloaded, text) - if (`${mimeType}`.includes('video')) return this.sendVideo(number, fileDownloaded, text) - if (`${mimeType}`.includes('audio')) { - const fileOpus = await utils.convertAudio(fileDownloaded) - return this.sendAudio(number, fileOpus) - } - return this.sendFile(number, fileDownloaded, text) - } - - /** - * Returns true only when the underlying WebSocket is fully open (readyState === 1). - * Used as a guard before any send call to avoid silent message loss during reconnects. - */ - private isVendorReady(): boolean { - return !!(this.vendor && this.vendor.ws?.readyState === 1) - } - - /** - * Enviar imagen - * @param {*} number - * @param {*} imageUrl - * @param {*} text - * @returns - */ - sendImage = async (number: string, filePath: string, text: any) => { - if (!this.isVendorReady()) throw new Error('Provider not connected. Cannot send image.') - const payload: AnyMediaMessageContent = { - image: { url: filePath }, - caption: text, - } - return this.vendor.sendMessage(number, payload) - } - - /** - * Enviar video - * @param {*} number - * @param {*} imageUrl - * @param {*} text - * @returns - */ - sendVideo = async (number: string, filePath: PathOrFileDescriptor, text: any) => { - if (!this.isVendorReady()) throw new Error('Provider not connected. Cannot send video.') - const payload: AnyMediaMessageContent = { - video: readFileSync(filePath), - caption: text, - gifPlayback: this.globalVendorArgs.gifPlayback, - } - return this.vendor.sendMessage(number, payload) - } - - /** - * Enviar audio - * @alpha - * @param {string} number - * @param {string} message - * @param {boolean} voiceNote optional - * @example await sendMessage('+XXXXXXXXXXX', 'audio.mp3') - */ - - sendAudio = async (number: string, audioUrl: string) => { - if (!this.isVendorReady()) throw new Error('Provider not connected. Cannot send audio.') - const payload: AnyMediaMessageContent = { - audio: { url: audioUrl }, - ptt: true, - } - return this.vendor.sendMessage(number, payload) - } - - /** - * - * @param {string} number - * @param {string} message - * @returns - */ - sendText = async (number: string, message: string) => { - if (!this.isVendorReady()) throw new Error('Provider not connected. Cannot send text.') - const payload: AnyMessageContent = { text: message } - return this.vendor.sendMessage(number, payload) - } - - /** - * - * @param {string} number - * @param {string} filePath - * @example await sendMessage('+XXXXXXXXXXX', './document/file.pdf') - */ - - sendFile = async (number: string, filePath: string, text: string) => { - if (!this.isVendorReady()) throw new Error('Provider not connected. Cannot send file.') - const mimeType = mime.lookup(filePath) - const fileName = basename(filePath) - - const payload: AnyMessageContent = { - document: { url: filePath }, - mimetype: `${mimeType}`, - fileName: fileName, - caption: text, - } - - return this.vendor.sendMessage(number, payload) - } - - /** - * @deprecated Buttons are not available in this provider, please use sendButtons instead - * @private - * @param {string} number - * @param {string} text - * @param {string} footer - * @param {Array} buttons - * @example await sendMessage("+XXXXXXXXXXX", "Your Text", "Your Footer", [{"buttonId": "id", "buttonText": {"displayText": "Button"}, "type": 1}]) - */ - - sendButtons = async (number: string, text: string, buttons: Button[]) => { - if (!this.isVendorReady()) throw new Error('Provider not connected. Cannot send buttons.') - this.emit('notice', { - title: 'DEPRECATED', - instructions: [ - `Currently sending buttons is not available with this provider`, - `this function is available with Meta or Twilio`, - ], - }) - const numberClean = baileyCleanNumber(number) - const templateButtons = buttons.map((btn: { body: any }, i: any) => ({ - buttonId: `id-btn-${i}`, - buttonText: { displayText: btn.body }, - type: 1, - })) - - const buttonMessage = { - text, - footer: '', - buttons: templateButtons, - headerType: 1, - } - - return this.vendor.sendMessage(numberClean, buttonMessage) - } - - /** - * TODO: Necesita terminar de implementar el sendMedia y sendButton guiarse: - * https://github.com/leifermendez/bot-whatsapp/blob/4e0fcbd8347f8a430adb43351b5415098a5d10df/packages/provider/src/web-whatsapp/index.js#L165 - * @param {string} number - * @param {string} message - * @example await sendMessage('+XXXXXXXXXXX', 'Hello World') - */ - - sendMessage = async (numberIn: string, message: string, options?: SendOptions): Promise => { - options = { ...options, ...options['options'] } - const number = sherpaCleanNumber(`${numberIn}`) - if (options.buttons?.length) return this.sendButtons(number, message, options.buttons) - if (options.media) return this.sendMedia(number, options.media, message) - return this.sendText(number, message) - } - - /** - * @param {string} remoteJid - * @param {string} latitude - * @param {string} longitude - * @param {any} messages - * @example await sendLocation("xxxxxxxxxxx@c.us" || "xxxxxxxxxxxxxxxxxx@g.us", "xx.xxxx", "xx.xxxx", messages) - */ - - sendLocation = async (remoteJid: string, latitude: any, longitude: any, messages: any = null) => { - await this.vendor.sendMessage( - remoteJid, - { - location: { - degreesLatitude: latitude, - degreesLongitude: longitude, - }, - }, - { quoted: messages } - ) - - return { status: 'success' } - } - - /** - * @param {string} remoteJid - * @param {string} contactNumber - * @param {string} displayName - * @param {string} orgName - * @param {any} messages - optional - * @example await sendContact("xxxxxxxxxxx@c.us" || "xxxxxxxxxxxxxxxxxx@g.us", "+xxxxxxxxxxx", "Robin Smith", messages) - */ - - sendContact = async ( - remoteJid: any, - contactNumber: { replaceAll: (arg0: string, arg1: string) => any }, - displayName: string, - orgName: string, - messages: any = null - ) => { - const cleanContactNumber = contactNumber.replaceAll(' ', '') - const waid = cleanContactNumber.replace('+', '') - - const vcard = - 'BEGIN:VCARD\n' + - 'VERSION:3.0\n' + - `FN:${displayName}\n` + - `ORG:${orgName};\n` + - `TEL;type=CELL;type=VOICE;waid=${waid}:${cleanContactNumber}\n` + - 'END:VCARD' - - await this.vendor.sendMessage( - remoteJid, - { - contacts: { - displayName: '.', - contacts: [{ vcard }], - }, - }, - { quoted: messages } - ) - - return { status: 'success' } - } - - /** - * @param {string} remoteJid - * @param {string} WAPresence - * @example await sendPresenceUpdate("xxxxxxxxxxx@c.us" || "xxxxxxxxxxxxxxxxxx@g.us", "recording") - */ - sendPresenceUpdate = async (remoteJid: any, WAPresence: any) => { - await this.vendor.sendPresenceUpdate(WAPresence, remoteJid) - } - - /** - * @param {string} remoteJid - * @param {string} url - * @param {object} stickerOptions - * @param {any} messages - optional - * @example await sendSticker("xxxxxxxxxxx@c.us" || "xxxxxxxxxxxxxxxxxx@g.us", "https://dn/image.png" || "https://dn/image.gif" || "https://dn/image.mp4", {pack: 'User', author: 'Me'} messages) - */ - - sendSticker = async ( - remoteJid: any, - url: string | Buffer, - stickerOptions: Partial, - messages: any = null - ) => { - const sticker = new Sticker(url, { - ...stickerOptions, - quality: 50, - type: 'crop', - }) - - const buffer = await sticker.toMessage() - - await this.vendor.sendMessage(remoteJid, buffer, { quoted: messages }) - } - - private getMimeType = (ctx: WAMessage): string | undefined => { - const { message } = ctx - if (!message) return undefined - - const { imageMessage, videoMessage, documentMessage, audioMessage, documentWithCaptionMessage } = message - return ( - imageMessage?.mimetype ?? - audioMessage?.mimetype ?? - videoMessage?.mimetype ?? - documentMessage?.mimetype ?? - documentWithCaptionMessage?.message?.documentMessage?.mimetype - ) - } - - private generateFileName = (extension: string): string => `file-${Date.now()}.${extension}` - - /** - * Return Path absolute - * @param ctx - * @param options - * @returns - */ - saveFile = async (ctx: Partial, options?: { path: string }): Promise => { - const mimeType = this.getMimeType(ctx as WAMessage) - if (!mimeType) throw new Error('MIME type not found') - const extension = mime.extension(mimeType) as string - const buffer = await downloadMediaMessage(ctx as WAMessage, 'buffer', {}) - const fileName = this.generateFileName(extension) - - const pathFile = join(options?.path ?? tmpdir(), fileName) - await writeFile(pathFile, buffer) - return resolve(pathFile) - } - - /** - * Starts a periodic health check that verifies the WebSocket connection is alive. - * If the connection is detected as dead (zombie), it triggers a reconnect. - * Also sends periodic presence updates as an application-level heartbeat - * to prevent WhatsApp from considering the connection inactive. - */ - private startHealthCheck() { - this.stopHealthCheck() - - // WebSocket state check every 30 seconds - this.healthCheckInterval = setInterval(() => { - try { - const sock = this.vendor - if (!sock) return - - const wsState = sock?.ws?.readyState - // WebSocket.OPEN = 1, if it's not open and not connecting, connection is dead - if (wsState !== undefined && wsState !== 1 && wsState !== 0) { - this.logger.log( - `[${new Date().toISOString()}] Health check: WebSocket dead (state=${wsState}), triggering reconnect...` - ) - this.stopHealthCheck() - this.delayedReconnect() - } - } catch (error) { - this.logger.log(`[${new Date().toISOString()}] Health check error:`, error) - } - }, 30_000) // Check every 30 seconds - - // Presence update with random interval (3-8 min) to mimic human behavior - const schedulePresenceHeartbeat = () => { - // Guard: don't schedule if health check was stopped (prevents orphaned timers) - if (!this.healthCheckInterval) return - - const minDelay = 180_000 // 3 minutes - const maxDelay = 480_000 // 8 minutes - const randomDelay = minDelay + Math.floor(Math.random() * (maxDelay - minDelay)) - - this.presenceInterval = setTimeout(async () => { - try { - const sock = this.vendor - if (!sock) return - await sock.sendPresenceUpdate('available') - this.logger.log( - `[${new Date().toISOString()}] Presence heartbeat sent (next in ~${Math.round(randomDelay / 60_000)}min)` - ) - } catch (error) { - this.logger.log(`[${new Date().toISOString()}] Presence heartbeat error:`, error) - } - // Schedule the next one with a new random delay - schedulePresenceHeartbeat() - }, randomDelay) - } - schedulePresenceHeartbeat() - } - - private stopHealthCheck() { - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval) - this.healthCheckInterval = undefined - } - if (this.presenceInterval) { - clearTimeout(this.presenceInterval) - this.presenceInterval = undefined - } - } - - private shouldReconnect(statusCode: number): boolean { - // Lista de códigos donde SÍ debemos reconectar - const reconnectableCodes = [ - DisconnectReason.connectionClosed, - DisconnectReason.connectionLost, - DisconnectReason.connectionReplaced, - DisconnectReason.timedOut, - DisconnectReason.badSession, - DisconnectReason.restartRequired, - 429, // Rate limited - 500, // Server error - 502, // Bad gateway - 503, // Service unavailable - 504, // Gateway timeout - ] - - return reconnectableCodes.includes(statusCode) && this.reconnectAttempts < this.maxReconnectAttempts - } - - private async delayedReconnect(): Promise { - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - this.logger.log( - `[${new Date().toISOString()}] Max reconnection attempts reached (${this.maxReconnectAttempts})` - ) - this.emit('auth_failure', [ - `Maximum reconnection attempts reached`, - `Please check your internet connection`, - `Check baileys.log for details`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ]) - return - } - - this.reconnectAttempts++ - const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000) // Max 30 segundos - - this.logger.log( - `[${new Date().toISOString()}] Reconnection attempt ${this.reconnectAttempts}/${ - this.maxReconnectAttempts - } in ${delay}ms` - ) - - setTimeout(() => { - this.initVendor() - .then((v) => this.listenOnEvents(v)) - .catch((error) => { - this.logger.log(`[${new Date().toISOString()}] Reconnection failed:`, error) - if (this.reconnectAttempts < this.maxReconnectAttempts) { - this.delayedReconnect() - } - }) - }, delay) - } -} - -export { SherpaProvider } diff --git a/packages/provider-sherpa/src/sherpaWrapper.ts b/packages/provider-sherpa/src/sherpaWrapper.ts deleted file mode 100644 index 6e0f60772..000000000 --- a/packages/provider-sherpa/src/sherpaWrapper.ts +++ /dev/null @@ -1,36 +0,0 @@ -import makeWASocketOther, { - useMultiFileAuthState, - DisconnectReason, - makeCacheableSignalKeyStore, - getAggregateVotesInPollMessage, - WASocket, - BaileysEventMap, - AnyMediaMessageContent, - AnyMessageContent, - downloadMediaMessage, - WAMessage, - MessageUpsertType, - isJidGroup, - isJidBroadcast, - SocketConfig, -} from 'whaileys' -import { proto } from 'whaileys/WAProto' - -export type WALogger = SocketConfig['logger'] -export { - makeWASocketOther, - useMultiFileAuthState, - DisconnectReason, - proto, - makeCacheableSignalKeyStore, - getAggregateVotesInPollMessage, - WASocket, - BaileysEventMap, - AnyMediaMessageContent, - AnyMessageContent, - downloadMediaMessage, - WAMessage, - MessageUpsertType, - isJidGroup, - isJidBroadcast, -} diff --git a/packages/provider-sherpa/src/type.ts b/packages/provider-sherpa/src/type.ts deleted file mode 100644 index 9363c22b0..000000000 --- a/packages/provider-sherpa/src/type.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { GlobalVendorArgs } from '@builderbot/bot/dist/types' -import { proto, WABrowserDescription, WAVersion } from 'whaileys' -export interface SherpaGlobalVendorArgs extends GlobalVendorArgs { - gifPlayback: boolean - usePairingCode: boolean - phoneNumber: string | null - browser: WABrowserDescription - experimentalSyncMessage?: string - fallBackAction?: (ctx: proto.IWebMessageInfo) => Promise - useBaileysStore: boolean - timeRelease?: number - experimentalStore?: boolean - groupsIgnore: boolean - readStatus: boolean - version?: WAVersion // - autoRefresh?: number - host?: any -} diff --git a/packages/provider-sherpa/src/utils.ts b/packages/provider-sherpa/src/utils.ts deleted file mode 100644 index 749b81133..000000000 --- a/packages/provider-sherpa/src/utils.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { utils } from '@builderbot/bot' -import type { WriteStream } from 'fs' -import { createWriteStream } from 'fs' -import { emptyDir } from 'fs-extra' -import * as qr from 'qr-image' - -const emptyDirSessions = async (pathBase: string) => - new Promise((resolve, reject) => { - emptyDir(pathBase, (err) => { - if (err) reject(err) - resolve(true) - }) - }) -/** - * Cleans the WhatsApp number format. - * @param number The WhatsApp number to be cleaned. - * @param full Whether to return the full number format or not. - * @returns The cleaned number. - */ -const baileyCleanNumber = (number: string, full: boolean = false): string => { - if (!number || typeof number !== 'string') { - return '' - } - const regexGroup: RegExp = /\@g.us\b/gm - const exist = number.match(regexGroup) - if (exist) return number - if (number.includes('@lid')) return number - number = number.replace('@s.whatsapp.net', '').replace('+', '').replace(/\s/g, '') - number = !full ? `${number}@s.whatsapp.net` : number - return number -} - -/** - * Generates an image from a base64 string. - * @param base64 The base64 string to generate the image from. - * @param name The name of the file to write the image to. - */ -const baileyGenerateImage = async (base64: string, name: string = 'qr.png'): Promise => { - const PATH_QR: string = `${process.cwd()}/${name}` - const qr_svg = qr.image(base64, { type: 'png', margin: 4 }) - - const writeFilePromise = (): Promise => - new Promise((resolve, reject) => { - const file: WriteStream = qr_svg.pipe(createWriteStream(PATH_QR)) - file.on('finish', () => resolve(true)) - file.on('error', reject) - }) - - await writeFilePromise() - await utils.cleanImage(PATH_QR) -} - -/** - * Validates if the given number is a valid WhatsApp number and not a group ID. - * @param rawNumber The number to validate. - * @returns True if it's a valid number, false otherwise. - */ -const baileyIsValidNumber = (rawNumber: string): boolean => { - if (!rawNumber || rawNumber.trim() === '') return false - const regexGroup: RegExp = /\@g.us\b/gm - const exist = rawNumber.match(regexGroup) - return !exist -} - -// Alias for sherpa compatibility -const sherpaCleanNumber = baileyCleanNumber -const sherpaGenerateImage = baileyGenerateImage -const sherpaIsValidNumber = baileyIsValidNumber - -export { - baileyCleanNumber, - baileyGenerateImage, - baileyIsValidNumber, - emptyDirSessions, - sherpaCleanNumber, - sherpaGenerateImage, - sherpaIsValidNumber, -} diff --git a/packages/provider-sherpa/tsconfig.json b/packages/provider-sherpa/tsconfig.json deleted file mode 100644 index 17e6ad6ec..000000000 --- a/packages/provider-sherpa/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": [ - "node", - "jest" - ] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json index 119792207..518688c32 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,53 +1,53 @@ { - "name": "@builderbot/provider-telegram", - "version": "1.4.2-alpha.11", - "description": "Provider for Telegram", - "keywords": [], - "author": "", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest --forceExit", - "test:coverage": "jest --coverage" - }, - "files": [ - "./dist/" - ], - "directories": { - "src": "src", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@hapi/boom": "^10.0.1", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/cors": "^2.8.17", - "@types/jest": "^30.0.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "jest": "^30.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.4.6" - }, - "dependencies": { - "telegram": "^2.23.10" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" + "name": "@japcon-bot/provider-telegram", + "version": "1.0.0", + "description": "Provider for Telegram", + "keywords": [], + "author": "", + "license": "ISC", + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "rimraf dist && rollup --config", + "test": "jest --forceExit", + "test:coverage": "jest --coverage" + }, + "files": [ + "./dist/" + ], + "directories": { + "src": "src", + "test": "__tests__" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" + }, + "bugs": { + "url": "https://github.com/codigoencasa/bot-whatsapp/issues" + }, + "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", + "devDependencies": { + "@hapi/boom": "^10.0.1", + "@jest/globals": "^30.2.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/cors": "^2.8.17", + "@types/jest": "^30.0.0", + "@types/mime-types": "^2.1.4", + "@types/node": "^24.10.2", + "@types/qr-image": "^3.2.9", + "@types/sinon": "^17.0.3", + "jest": "^30.2.0", + "rimraf": "^6.1.2", + "rollup-plugin-typescript2": "^0.36.0", + "ts-jest": "^29.4.6", + "@japcon-bot/bot": "workspace:^" + }, + "dependencies": { + "telegram": "^2.23.10" + }, + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-twilio/CHANGELOG.md b/packages/provider-twilio/CHANGELOG.md deleted file mode 100644 index 2b8170d3d..000000000 --- a/packages/provider-twilio/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [0.1.0-alpha.18](https://github.com/codigoencasa/bot-whatsapp/compare/v0.1.0-alpha.0...v0.1.0-alpha.18) (2024-01-19) - -**Note:** Version bump only for package @builderbot/twilio diff --git a/packages/provider-twilio/LICENSE.md b/packages/provider-twilio/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/provider-twilio/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/provider-twilio/README.md b/packages/provider-twilio/README.md deleted file mode 100644 index ae10934d4..000000000 --- a/packages/provider-twilio/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/provider-twilio

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/provider-twilio/__tests__/core.test.ts b/packages/provider-twilio/__tests__/core.test.ts deleted file mode 100644 index 2e2fece29..000000000 --- a/packages/provider-twilio/__tests__/core.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { utils } from '@builderbot/bot' -import { describe, expect, test, jest, beforeEach, afterEach } from '@jest/globals' -import fs from 'fs' -import mime from 'mime-types' - -import { TwilioCoreVendor } from '../src/twilio/core' -import { ITwilioProviderARgs, TwilioPayload, TwilioRequestBody } from '../src/types' - -jest.mock('twilio', () => ({ - __esModule: true, - default: jest.fn().mockReturnValue({ - messages: { - create: jest.fn(), - }, - }), -})) - -jest.mock('@builderbot/bot') -jest.mock('../src/utils', () => ({ - parseNumber: jest.fn().mockImplementation(() => '+123456789'), -})) - -describe('#TwilioCoreVendor', () => { - let twilioCoreVendor: TwilioCoreVendor - let mockRequest: any - let mockResponse: any - let mockNext: any - - afterEach(() => { - jest.clearAllMocks() - }) - - beforeEach(() => { - const mockTwilioArgs: ITwilioProviderARgs = { - accountSid: 'mockAccountSid', - authToken: 'mockAuthToken', - vendorNumber: 'mockVendorNumber', - } - twilioCoreVendor = new TwilioCoreVendor(mockTwilioArgs) - mockRequest = { - body: { - From: 'mockFromNumber', - To: 'mockToNumber', - Body: 'Hello word!', - NumMedia: '0', - } as TwilioRequestBody, - } - mockResponse = { - end: jest.fn(), - writeHead: jest.fn(), - } - mockNext = jest.fn() - }) - - describe('#constructor', () => { - test('should initialize twilio property', () => { - expect(twilioCoreVendor.twilio).toBeDefined() - }) - }) - - describe('#indexHome', () => { - test('should respond with "running ok"', () => { - // Arrange - const mockResponse = { - end: jest.fn(), - } - // Act - twilioCoreVendor.indexHome(null as any, mockResponse as any, mockNext) - - // Assert - expect(mockResponse.end).toHaveBeenCalledWith('running ok') - }) - }) - - describe('incomingMsg', () => { - test('should handle incoming message correctly', () => { - // Arrange - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - twilioCoreVendor.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - twilioCoreVendor.incomingMsg(mockRequest, mockResponse, mockNext) - - // Assert - expect(mockEmit).toHaveBeenCalledWith('message', { - ...mockRequest.body, - from: '+123456789', - to: '+123456789', - host: '+123456789', - body: 'Hello word!', - name: 'undefined', - } as TwilioPayload) - - expect(mockResponse.end).toHaveBeenCalledWith(expect.any(String)) - }) - - test('should handle audio media type', () => { - // Arrange - const mockRequest = { - body: { - From: 'mockFromNumber', - To: 'mockToNumber', - Body: 'mockMessageBody', - NumMedia: '1', - MediaContentType0: 'audio/mpeg', - } as TwilioRequestBody, - } - const mockResponse = { - end: jest.fn(), - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - const refProvider = '_event_voice_note_test' - twilioCoreVendor.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - - // Act - twilioCoreVendor.incomingMsg(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(mockEmit).toHaveBeenCalledWith('message', { - ...mockRequest.body, - from: '+123456789', - to: '+123456789', - host: '+123456789', - body: refProvider, - name: 'undefined', - } as TwilioPayload) - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_voice_note_') - expect(mockResponse.end).toHaveBeenCalledWith(expect.any(String)) - }) - - test('should handle image media type', () => { - // Arrange - const mockRequest = { - body: { - From: 'mockFromNumber', - To: 'mockToNumber', - Body: 'mockMessageBody', - NumMedia: '1', - MediaContentType0: 'image/jpeg', - } as TwilioRequestBody, - } - const mockResponse = { - end: jest.fn(), - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - const refProvider = '_event_media__test' - twilioCoreVendor.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - - // Act - twilioCoreVendor.incomingMsg(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(mockEmit).toHaveBeenCalledWith('message', { - ...mockRequest.body, - from: '+123456789', - to: '+123456789', - host: '+123456789', - body: refProvider, - name: 'undefined', - } as TwilioPayload) - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_media_') - expect(mockResponse.end).toHaveBeenCalledWith(expect.any(String)) - }) - - test('should handle application media type', () => { - // Arrange - const mockRequest = { - body: { - From: 'mockFromNumber', - To: 'mockToNumber', - Body: 'mockMessageBody', - NumMedia: '1', - MediaContentType0: 'application/pdf', - } as TwilioRequestBody, - } - const mockResponse = { - end: jest.fn(), - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - const refProvider = '_event_document_test' - twilioCoreVendor.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - - // Act - twilioCoreVendor.incomingMsg(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(mockEmit).toHaveBeenCalledWith('message', { - ...mockRequest.body, - from: '+123456789', - to: '+123456789', - host: '+123456789', - body: refProvider, - name: 'undefined', - } as TwilioPayload) - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_document_') - expect(mockResponse.end).toHaveBeenCalledWith(expect.any(String)) - }) - - test('should handle application text type', () => { - // Arrange - const mockRequest = { - body: { - From: 'mockFromNumber', - To: 'mockToNumber', - Body: 'mockMessageBody', - NumMedia: '1', - MediaContentType0: 'text/text', - } as TwilioRequestBody, - } - const mockResponse = { - end: jest.fn(), - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - const refProvider = '_event_contacts_test' - twilioCoreVendor.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - - // Act - twilioCoreVendor.incomingMsg(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(mockEmit).toHaveBeenCalledWith('message', { - ...mockRequest.body, - from: '+123456789', - to: '+123456789', - host: '+123456789', - body: refProvider, - name: 'undefined', - } as TwilioPayload) - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_contacts_') - expect(mockResponse.end).toHaveBeenCalledWith(expect.any(String)) - }) - - test('should handle default case', () => { - // Arrange - const mockRequest = { - body: { - From: 'mockFromNumber', - To: 'mockToNumber', - Body: 'mockMessageBody', - NumMedia: '1', - MediaContentType0: 'other/other', - } as TwilioRequestBody, - } - const mockResponse = { - end: jest.fn(), - } - // Act - twilioCoreVendor.incomingMsg(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(utils.generateRefProvider).not.toHaveBeenCalled() - }) - - test('should handle location media type when no media but has location coordinates', () => { - // Arrange - const mockRequest = { - body: { - From: 'mockFromNumber', - To: 'mockToNumber', - Body: 'mockMessageBody', - Latitude: '123', // Simular coordenadas de ubicación - Longitude: '456', - } as TwilioRequestBody, - } - const mockResponse = { - end: jest.fn(), - } - - // Act - twilioCoreVendor.incomingMsg(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_location_') - }) - }) - - describe('#handlerLocalMedia', () => { - test('should stream the file with correct Content-Type if the file exists', () => { - // Arrange - const validFilePath = 'valid/file/path' - const mockRequest: any = { - query: {}, - } - mockRequest.query.path = validFilePath - const mockMimeType = 'image/jpeg' - const mockFileStream = { pipe: jest.fn() } as any - - const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true) - const createReadStreamSpy = jest.spyOn(fs, 'createReadStream').mockReturnValue(mockFileStream) - jest.spyOn(mime, 'lookup').mockReturnValue(mockMimeType) - - // Act - twilioCoreVendor.handlerLocalMedia(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(existsSyncSpy).toHaveBeenCalled() - expect(createReadStreamSpy).toHaveBeenCalled() - expect(mime.lookup).toHaveBeenCalled() - expect(mockResponse.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': mockMimeType }) - expect(mockFileStream.pipe).toHaveBeenCalledWith(mockResponse) - }) - - test('should respond with "path: invalid" if no file path is provided in the query', () => { - // Act - twilioCoreVendor.handlerLocalMedia(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(mockResponse.end).toHaveBeenCalledWith('path: invalid') - }) - - test('should respond with "not exists: {file path}" if the file does not exist', () => { - // Arrange - const validFilePath = 'valid/file/path' - const mockRequest: any = { - query: {}, - } - mockRequest.query.path = validFilePath - const mockMimeType = 'image/jpeg' - const mockFileStream = { pipe: jest.fn() } as any - - const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false) - const createReadStreamSpy = jest.spyOn(fs, 'createReadStream').mockReturnValue(mockFileStream) - jest.spyOn(mime, 'lookup').mockReturnValue(mockMimeType) - - // Act - twilioCoreVendor.handlerLocalMedia(mockRequest as any, mockResponse as any, mockNext) - - // Assert - expect(existsSyncSpy).toHaveBeenCalled() - expect(createReadStreamSpy).not.toHaveBeenCalled() - expect(mime.lookup).not.toHaveBeenCalled() - expect(mockResponse.end).toHaveBeenCalledWith('not exits: undefined') - }) - }) -}) diff --git a/packages/provider-twilio/__tests__/provider.test.ts b/packages/provider-twilio/__tests__/provider.test.ts deleted file mode 100644 index 2d914f462..000000000 --- a/packages/provider-twilio/__tests__/provider.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { utils } from '@builderbot/bot' -import { describe, expect, test, jest, beforeEach } from '@jest/globals' - -import { TwilioCoreVendor } from '../src/twilio/core' -import { TwilioProvider } from '../src/twilio/provider' - -jest.mock('twilio', () => ({ - __esModule: true, - default: jest.fn().mockReturnValue({ - messages: { - create: jest.fn(), - }, - }), -})) - -jest.mock('@builderbot/bot') - -describe('#TwilioProvider', () => { - let twilioProvider: TwilioProvider - let mockRes: any - let mockReq: any - let mockNext: any - - beforeEach(() => { - const globalVendorArgs = { - accountSid: 'mockAccountSid', - authToken: 'mockAuthToken', - vendorNumber: 'mockVendorNumber', - } - twilioProvider = new TwilioProvider(globalVendorArgs) - - mockReq = {} - mockRes = { - writeHead: jest.fn(), - end: jest.fn(), - pipe: jest.fn(), - } - mockNext = jest.fn() - }) - - describe('#constructor', () => { - test('constructor should initialize globalVendorArgs', () => { - // Assert - expect(twilioProvider.globalVendorArgs).toEqual({ - accountSid: 'mockAccountSid', - authToken: 'mockAuthToken', - vendorNumber: 'mockVendorNumber', - name: 'bot', - port: 3000, - writeMyself: 'none', - }) - }) - }) - - describe('#saveFile', () => { - test('should save a file received via Twilio', async () => { - // Arrange - const ctx = { MediaUrl0: 'mockMediaUrl' } - const options = { path: 'mockPath' } - const fileDownloaded = 'path/to/downloaded/image.jpg' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - - // Act - const result = await twilioProvider.saveFile(ctx, options) - - // Assert - expect(result).toEqual(fileDownloaded) - expect(utils.generalDownload).toHaveBeenCalled() - }) - - test('should save a file in tmp directory', async () => { - // Arrange - const ctx = { MediaUrl0: 'mockMediaUrl' } - const fileDownloaded = 'path/to/downloaded/image.jpg' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - - // Act - const result = await twilioProvider.saveFile(ctx) - - // Assert - expect(result).toEqual(fileDownloaded) - }) - - test('should handle error when generalDownload fails', async () => { - // Arrange - const ctx = { MediaUrl0: 'mockMediaUrl' } - const mockError = new Error('Download failed') - ;(utils.generalDownload as jest.MockedFunction).mockRejectedValue(mockError) - const consoleLogSpy = jest.spyOn(console, 'log') - // Act - const result = await twilioProvider.saveFile(ctx) - - // Assert - expect(result).toBe('ERROR') - expect(consoleLogSpy).toHaveBeenCalledWith('[Error]:', mockError) - }) - }) - - describe('#sendMessage', () => { - test('should parse the recipient number and send media message', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Here is a media file' - const fakeMedia = 'path/to/media.jpg' - const fakeOptions = { media: fakeMedia } - jest.spyOn(twilioProvider, 'sendButtons') - jest.spyOn(twilioProvider, 'sendMedia').mockResolvedValue(() => null) - - // Act - await twilioProvider.sendMessage(fakeRecipient, fakeMessage, fakeOptions) - - // Assert - expect(twilioProvider.sendMedia).toHaveBeenCalledWith(fakeRecipient, fakeMessage, fakeMedia) - expect(twilioProvider.sendButtons).not.toHaveBeenCalled() - }) - - test('should send a message without media or buttons via Twilio', async () => { - // Arrange - const number = '123456789' - const message = 'Test message' - const mockTwilioResponse = {} - jest.spyOn(twilioProvider, 'sendButtons') - const fakeButtons = [{ body: 'Option 1' }, { body: 'Option 2' }] - const fakeOptions = { buttons: fakeButtons } - const mockCreate = jest.fn().mockImplementation(() => mockTwilioResponse) - const mockTwilio = { - twilio: { messages: { create: mockCreate } }, - } - twilioProvider.vendor = mockTwilio as any - - // Act - const result = await twilioProvider.sendMessage(number, message, fakeOptions) - - // Assert - expect(result).toEqual(mockTwilioResponse) - expect(mockCreate).toHaveBeenCalled() - expect(twilioProvider.sendButtons).toHaveBeenCalled() - }) - }) - - describe('#sendButtons ', () => { - test('should emit a notice event with button instructions', async () => { - // Arrange - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - twilioProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - await twilioProvider.sendButtons() - - // Assert - expect(mockEmit).toHaveBeenCalledWith('notice', { - title: '📃 INFO 📃', - instructions: [ - `Twilio presents a different way to implement buttons and lists`, - `To understand more about how it works, I recommend you check the following URLs`, - `https://builderbot.app/en/providers/twilio/uses-cases`, - ], - }) - }) - }) - - describe('#sendMedia ', () => { - test('should send media via Twilio and emit a notice for local media', async () => { - // Arrange - const number = '123456789' - const message = 'Test message' - const mediaInput = 'http://localhost:3000/mockMediaUrl' - const mockCreate = jest.fn().mockImplementation(() => undefined) - const mockTwilio = { - twilio: { messages: { create: mockCreate } }, - } - twilioProvider.vendor = mockTwilio as any - ;(utils.encryptData as jest.MockedFunction).mockImplementation( - (data) => `mockEncrypted${data}` - ) - - // Act - const result = await twilioProvider.sendMedia(number, message, mediaInput) - - // Assert - expect(result).toEqual(undefined) - expect(utils.encryptData).toHaveBeenCalledWith(encodeURIComponent(mediaInput)) - expect(twilioProvider.emit).toHaveBeenCalledWith('notice', { - title: '🟠 WARNING 🟠', - instructions: expect.arrayContaining([ - expect.stringContaining('You are trying to send a file that is local.'), - expect.stringContaining('For this to work with Twilio, the file needs to be in a public URL.'), - expect.stringContaining('https://builderbot.app/en/twilio/uses-cases'), - expect.stringContaining('This is the URL that will be sent to Twilio (must be public)'), - ]), - }) - }) - - test('should send media via Twilio and emit a notice for local media 127.0.0.1', async () => { - // Arrange - const number = '123456789' - const message = 'Test message' - const mediaInput = 'http://127.0.0.1:3000/mockMediaUrl' - const mockCreate = jest.fn().mockImplementation(() => undefined) - const mockTwilio = { - twilio: { messages: { create: mockCreate } }, - } - twilioProvider.vendor = mockTwilio as any - ;(utils.encryptData as jest.MockedFunction).mockImplementation( - (data) => `mockEncrypted${data}` - ) - - // Act - const result = await twilioProvider.sendMedia(number, message, mediaInput) - - // Assert - expect(result).toEqual(undefined) - expect(utils.encryptData).toHaveBeenCalledWith(encodeURIComponent(mediaInput)) - expect(twilioProvider.emit).toHaveBeenCalledWith('notice', { - title: '🟠 WARNING 🟠', - instructions: expect.arrayContaining([ - expect.stringContaining('You are trying to send a file that is local.'), - expect.stringContaining('For this to work with Twilio, the file needs to be in a public URL.'), - expect.stringContaining('https://builderbot.app/en/twilio/uses-cases'), - expect.stringContaining('This is the URL that will be sent to Twilio (must be public)'), - ]), - }) - }) - - test('should send media via Twilio and emit a notice for local media 0.0.0.0', async () => { - // Arrange - const number = '123456789' - const message = 'Test message' - const mediaInput = 'http://0.0.0.0:3000/mockMediaUrl' - const mockCreate = jest.fn().mockImplementation(() => undefined) - const mockTwilio = { - twilio: { messages: { create: mockCreate } }, - } - twilioProvider.vendor = mockTwilio as any - ;(utils.encryptData as jest.MockedFunction).mockImplementation( - (data) => `mockEncrypted${data}` - ) - - // Act - const result = await twilioProvider.sendMedia(number, message, mediaInput) - - // Assert - expect(result).toEqual(undefined) - expect(utils.encryptData).toHaveBeenCalledWith(encodeURIComponent(mediaInput)) - expect(twilioProvider.emit).toHaveBeenCalledWith('notice', { - title: '🟠 WARNING 🟠', - instructions: expect.arrayContaining([ - expect.stringContaining('You are trying to send a file that is local.'), - expect.stringContaining('For this to work with Twilio, the file needs to be in a public URL.'), - expect.stringContaining('https://builderbot.app/en/twilio/uses-cases'), - expect.stringContaining('This is the URL that will be sent to Twilio (must be public)'), - ]), - }) - }) - - test('should throw an error if mediaInput is null', async () => { - // Arrange - const number = '123456789' - const message = 'Test message' - const mediaInput: any = null - - // Act & Assert - await expect(twilioProvider.sendMedia(number, message, mediaInput)).rejects.toThrow('Media cannot be null') - }) - - test('should handle synchronous error when Twilio API throws', async () => { - // Arrange - const number = '123456789' - const message = 'Test message' - const mediaInput = 'https://example.com/media.jpg' - const mockError = new Error('Twilio API error') - const mockCreate = jest.fn().mockImplementation(() => { - throw mockError - }) - const mockTwilio = { - twilio: { messages: { create: mockCreate } }, - } - twilioProvider.vendor = mockTwilio as any - const consoleLogSpy = jest.spyOn(console, 'log') - - // Act - const result = await twilioProvider.sendMedia(number, message, mediaInput) - - // Assert - expect(result).toBeUndefined() - expect(consoleLogSpy).toHaveBeenCalledWith('Error Twilio:', mockError) - }) - }) - - describe('#send', () => { - test('should send a message directly via Twilio', async () => { - // Arrange - const number = '123456789' - const message = 'Direct message' - const mockResponse = { sid: 'SM123456' } - const mockCreate = jest.fn().mockResolvedValue(mockResponse) - const mockTwilio = { - twilio: { messages: { create: mockCreate } }, - } - twilioProvider.vendor = mockTwilio as any - - // Act - const result = await twilioProvider.send(number, message) - - // Assert - expect(result).toEqual(mockResponse) - expect(mockCreate).toHaveBeenCalledWith({ - body: message, - from: expect.any(String), - to: expect.any(String), - }) - }) - - test('should send a message with options via Twilio', async () => { - // Arrange - const number = '123456789' - const message = 'Message with options' - const options = { statusCallback: 'https://example.com/callback' } - const mockResponse = { sid: 'SM789012' } - const mockCreate = jest.fn().mockResolvedValue(mockResponse) - const mockTwilio = { - twilio: { messages: { create: mockCreate } }, - } - twilioProvider.vendor = mockTwilio as any - - // Act - const result = await twilioProvider.send(number, message, options as any) - - // Assert - expect(result).toEqual(mockResponse) - expect(mockCreate).toHaveBeenCalledWith({ - ...options, - body: message, - from: expect.any(String), - to: expect.any(String), - }) - }) - }) - - describe('#busEvents', () => { - test('#auth_failure - should emit the correct events with payloads', async () => { - // Arrange - const payload: any = { - message: 'Test', - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - twilioProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - twilioProvider['busEvents']()[0].func(payload) - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('auth_failure', payload) - }) - - test('#ready - should emit the correct events with payloads', async () => { - // Arrange - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - twilioProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - twilioProvider['busEvents']()[1].func({} as any) - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('ready', true) - }) - - test('#message - should emit the correct events with payloads', async () => { - // Arrange - const payload: any = { - body: 'Hellow Word!!', - from: '123456789', - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - twilioProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - twilioProvider['busEvents']()[2].func(payload) - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('message', payload) - }) - - test('#host - should emit the correct events with payloads', async () => { - // Arrange - const payload: any = { - message: 'Test', - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - twilioProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - twilioProvider['busEvents']()[3].func(payload) - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('host', payload) - }) - }) - - describe('#beforeHttpServerInit', () => { - test('beforeHttpServerInit - you should configure middleware to handle HTTP requests', () => { - // Arrange - const mockUse = jest.fn().mockReturnThis() - const mockGet = jest.fn().mockReturnThis() - const mockPost = jest.fn().mockReturnThis() - - const mockPolka = jest.fn(() => ({ - use: mockUse, - get: mockGet, - post: mockPost, - })) - const mockIndexHome = jest.fn() - const mockTwilio = { - indexHome: mockIndexHome, - } - twilioProvider.vendor = mockTwilio as any - - twilioProvider.server = mockPolka() as any - // Act - twilioProvider['beforeHttpServerInit']() - - // Assert - expect(mockUse).toHaveBeenCalled() - const middleware = mockUse.mock.calls[0][0] as any - expect(middleware).toBeInstanceOf(Function) - middleware(mockReq, mockRes, mockNext) - expect(mockReq.globalVendorArgs).toBe(twilioProvider.globalVendorArgs) - }) - }) - - describe('#initVendor', () => { - test('should initialize vendor correctly', async () => { - // Act - await twilioProvider['initVendor']() - - // Assert - expect(twilioProvider.vendor).toBeInstanceOf(TwilioCoreVendor) - }) - }) -}) diff --git a/packages/provider-twilio/__tests__/util.test.ts b/packages/provider-twilio/__tests__/util.test.ts deleted file mode 100644 index d238bd0bc..000000000 --- a/packages/provider-twilio/__tests__/util.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, test } from '@jest/globals' - -import { parseNumber, parseNumberFrom } from '../src/utils' - -describe('#parseNumber ', () => { - test('should parse number correctly by removing "whatsapp:" and spaces', () => { - // Arrange - const phoneNumber = 'whatsapp: +123 456 789' - - // Act - const result = parseNumber(phoneNumber) - - // Assert - expect(result).toBe('+123456789') - }) - - test('should handle numbers without spaces correctly', () => { - // Arrange - const phoneNumber = 'whatsapp:+111222333' - - // Act - const result = parseNumber(phoneNumber) - - // Assert - expect(result).toBe('+111222333') - }) -}) - -describe('#parseNumberFrom ', () => { - test('should parse number correctly by removing "whatsapp:", "+" and spaces', () => { - // Arrange - const phoneNumber = 'whatsapp: +123 456 789' - - // Act - const result = parseNumberFrom(phoneNumber) - - // Assert - expect(result).toBe('whatsapp:+123456789') - }) - - test('should handle numbers without "whatsapp:" correctly', () => { - // Arrange - const phoneNumber = '+987 654 321' - - // Act - const result = parseNumberFrom(phoneNumber) - - // Assert - expect(result).toBe('whatsapp:+987654321') - }) - - test('should handle numbers without spaces correctly', () => { - // Arrange - const phoneNumber = 'whatsapp:+111222333' - - // Act - const result = parseNumberFrom(phoneNumber) - - // Assert - expect(result).toBe('whatsapp:+111222333') - }) -}) diff --git a/packages/provider-twilio/config/api-extractor.json b/packages/provider-twilio/config/api-extractor.json deleted file mode 100644 index 3199a2fac..000000000 --- a/packages/provider-twilio/config/api-extractor.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - - "mainEntryPointFilePath": "/dist/index.d.ts", - - "apiReport": { - "enabled": true, - "reportFileName": "api.md", - "reportFolder": "/" - }, - - "docModel": { - "enabled": true - }, - - "dtsRollup": { - "enabled": true - }, - - "messages": { - "compilerMessageReporting": { - "default": { - "logLevel": "warning" - } - }, - - "extractorMessageReporting": { - "default": { - "logLevel": "warning" - } - }, - - "tsdocMessageReporting": { - "default": { - "logLevel": "warning" - } - } - } -} diff --git a/packages/provider-twilio/jest.config.ts b/packages/provider-twilio/jest.config.ts deleted file mode 100644 index ca2c6a76f..000000000 --- a/packages/provider-twilio/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, -} - -export default config diff --git a/packages/provider-twilio/package.json b/packages/provider-twilio/package.json deleted file mode 100644 index dfe20bc49..000000000 --- a/packages/provider-twilio/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@builderbot/provider-twilio", - "version": "1.4.2-alpha.11", - "description": "> TODO: description", - "author": "Leifer Mendez ", - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "directories": { - "lib": "dist", - "test": "__tests__" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/body-parser": "^1.19.5", - "@types/cors": "^2.8.17", - "@types/fluent-ffmpeg": "^2.1.24", - "@types/follow-redirects": "^1.14.4", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "@types/proxyquire": "^1.3.31", - "@types/sinon": "^17.0.3", - "cors": "^2.8.5", - "proxyquire": "^2.1.3", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1" - }, - "dependencies": { - "body-parser": "^2.2.1", - "fluent-ffmpeg": "^2.1.2", - "mime-types": "^3.0.2", - "polka": "^0.5.2", - "twilio": "~5.10.7" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-twilio/rollup.config.js b/packages/provider-twilio/rollup.config.js deleted file mode 100644 index a3d4a75b0..000000000 --- a/packages/provider-twilio/rollup.config.js +++ /dev/null @@ -1,24 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import json from '@rollup/plugin-json' -import commonjs from '@rollup/plugin-commonjs' -import { nodeResolve } from '@rollup/plugin-node-resolve' - -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - json(), - nodeResolve({ - resolveOnly: (module) => !/ffmpeg|@builderbot\/bot|twilio|sharp/i.test(module), - }), - commonjs(), - typescript(), - ], -} diff --git a/packages/provider-twilio/src/index.ts b/packages/provider-twilio/src/index.ts deleted file mode 100644 index c69ee189f..000000000 --- a/packages/provider-twilio/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TwilioProvider } from './twilio/provider' diff --git a/packages/provider-twilio/src/interface/twilio.ts b/packages/provider-twilio/src/interface/twilio.ts deleted file mode 100644 index 20c0cd10c..000000000 --- a/packages/provider-twilio/src/interface/twilio.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SendOptions, BotContext } from '@builderbot/bot/dist/types' - -import type { TwilioRequestBody } from '../types' - -export interface TwilioInterface { - sendMedia: (number: string, message: string, mediaInput: string) => Promise - sendMessage: (number: string, message: string, options?: SendOptions) => Promise - saveFile: (ctx: Partial, options?: { path: string }) => Promise -} diff --git a/packages/provider-twilio/src/twilio/core.ts b/packages/provider-twilio/src/twilio/core.ts deleted file mode 100644 index 7d457e7a7..000000000 --- a/packages/provider-twilio/src/twilio/core.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { utils } from '@builderbot/bot' -import mime from 'mime-types' -import EventEmitter from 'node:events' -import { existsSync, createReadStream } from 'node:fs' -import type polka from 'polka' -import twilio from 'twilio' - -import type { ITwilioProviderARgs, TwilioPayload, TwilioRequestBody } from '../types' -import { parseNumber } from '../utils' - -/** - * Class representing TwilioCoreVendor, a vendor class for meta core functionality. - * @extends EventEmitter - */ -export class TwilioCoreVendor extends EventEmitter { - public twilio: twilio.Twilio - - constructor(globalVendorArgs: ITwilioProviderARgs) { - super() - this.twilio = twilio(globalVendorArgs.accountSid, globalVendorArgs.authToken) - const host = { - phone: parseNumber(globalVendorArgs.vendorNumber), - } - this.emit('host', host) - } - - /** - * Middleware function for indexing home. - * @type {polka.Middleware} - */ - public indexHome: polka.Middleware = (_, res) => { - res.end('running ok') - } - /** - * Middleware function for handling incoming messages. - * @type {polka.Middleware} - */ - public incomingMsg: polka.Middleware = (req, res) => { - const body = req.body as TwilioRequestBody - const payload: TwilioPayload = { - ...req.body, - from: parseNumber(body.From), - to: parseNumber(body.To), - host: parseNumber(body.To), - body: body.Body, - name: `${body?.ProfileName}`, - } - - if (body?.NumMedia !== '0' && body?.MediaContentType0) { - const type = body?.MediaContentType0.split('/')[0] - switch (type) { - case 'audio': - payload.body = utils.generateRefProvider('_event_voice_note_') - break - case 'image': - case 'video': - payload.body = utils.generateRefProvider('_event_media_') - break - case 'application': - payload.body = utils.generateRefProvider('_event_document_') - break - case 'text': - payload.body = utils.generateRefProvider('_event_contacts_') - break - default: - break - } - } else { - if (body.Latitude && body.Longitude) { - payload.body = utils.generateRefProvider('_event_location_') - } - } - - this.emit('message', payload) - const jsonResponse = JSON.stringify({ body }) - res.end(jsonResponse) - } - - /** - * Manejar los local media como - * C:\\Projects\\bot-restaurante\\tmp\\menu.png - * para que puedas ser llevar a una url online - * @param req - * @param res - */ - public handlerLocalMedia: polka.Middleware = (req, res) => { - const query = req.query as { path?: string } - const file = query?.path - if (!file) { - res.end(`path: invalid`) - return - } - const decryptPath = utils.decryptData(file) - const decodeFile = decodeURIComponent(decryptPath) - if (!existsSync(decodeFile)) { - res.end(`not exits: ${decodeFile}`) - return - } - const fileStream = createReadStream(decodeFile) - const mimeType = mime.lookup(decodeFile) || 'application/octet-stream' - res.writeHead(200, { 'Content-Type': mimeType }) - fileStream.pipe(res) - } -} diff --git a/packages/provider-twilio/src/twilio/provider.ts b/packages/provider-twilio/src/twilio/provider.ts deleted file mode 100644 index 9c3276635..000000000 --- a/packages/provider-twilio/src/twilio/provider.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { ProviderClass, utils } from '@builderbot/bot' -import type { BotContext, SendOptions } from '@builderbot/bot/dist/types' -import { tmpdir } from 'os' -import { join } from 'path' -import type { MessageListInstanceCreateOptions } from 'twilio/lib/rest/api/v2010/account/message' - -import { TwilioCoreVendor } from './core' -import type { TwilioInterface } from '../interface/twilio' -import type { ITwilioProviderARgs, TwilioRequestBody } from '../types' -import { parseNumberFrom } from '../utils' -/** - * A class representing a TwilioProvider for interacting with Twilio messaging service. - * @extends ProviderClass - * @implements {TwilioInterface} - */ -class TwilioProvider extends ProviderClass implements TwilioInterface { - globalVendorArgs: ITwilioProviderARgs - - constructor(args: ITwilioProviderARgs) { - super() - this.globalVendorArgs = { - accountSid: undefined, - authToken: undefined, - vendorNumber: undefined, - name: 'bot', - port: 3000, - writeMyself: 'none', - } - this.globalVendorArgs = { ...this.globalVendorArgs, ...args } - } - - /** - * Initialize the vendor for TwilioProvider. - * @returns {Promise} A Promise that resolves when vendor is initialized. - * @protected - */ - protected async initVendor(): Promise { - const vendor = new TwilioCoreVendor(this.globalVendorArgs) - this.vendor = vendor - return Promise.resolve(vendor) - } - - protected beforeHttpServerInit(): void { - this.server = this.server - .use((req, _, next) => { - req['globalVendorArgs'] = this.globalVendorArgs - return next() - }) - .post('/', this.vendor.indexHome) - .post('/webhook', this.vendor.incomingMsg) - .get('/tmp', this.vendor.handlerLocalMedia) - } - - protected afterHttpServerInit(): void {} - - /** - * Event handlers for bus events. - */ - busEvents = () => [ - { - event: 'auth_failure', - func: (payload: any) => this.emit('auth_failure', payload), - }, - { - event: 'ready', - func: () => this.emit('ready', true), - }, - { - event: 'message', - func: (payload: BotContext) => { - this.emit('message', payload) - }, - }, - { - event: 'host', - func: (payload: any) => { - this.emit('host', payload) - }, - }, - ] - - /** - * Sends media content via Twilio. - * @param {string} number - The recipient's phone number. - * @param {string} [message=''] - The message to be sent. - * @param {string} mediaInput - The media input to be sent. - * @returns {Promise} A Promise that resolves when the media is sent. - */ - sendMedia = async (number: string, message: string = '', mediaInput: string): Promise => { - const entryPointUrl = this.globalVendorArgs?.publicUrl ?? `http://localhost:${this.globalVendorArgs.port}` - if (!mediaInput) throw new Error(`Media cannot be null`) - const encryptPath = utils.encryptData(encodeURIComponent(mediaInput)) - const urlEncode = `${entryPointUrl}/tmp?path=${encryptPath}` - const regexUrl = /^(?!https?:\/\/)[^\s]+$/ - const instructions = [ - `You are trying to send a file that is local.`, - `For this to work with Twilio, the file needs to be in a public URL.`, - `More information here https://builderbot.app/en/twilio/uses-cases`, - `This is the URL that will be sent to Twilio (must be public)`, - ``, - `${urlEncode}`, - ] - - if ( - mediaInput.includes('localhost') || - mediaInput.includes('127.0.0.1') || - mediaInput.includes('0.0.0.0') || - regexUrl.test(mediaInput) - ) { - mediaInput = urlEncode - - this.emit('notice', { - title: '🟠 WARNING 🟠', - instructions, - }) - } - - try { - const twilioQueue = this.vendor.twilio.messages.create({ - mediaUrl: [`${mediaInput}`], - body: message, - from: parseNumberFrom(this.globalVendorArgs.vendorNumber), - to: parseNumberFrom(number), - }) - - return twilioQueue - } catch (err) { - console.log(`Error Twilio:`, err) - } - } - - /** - * Sends buttons via Twilio. - * @returns {Promise} A Promise that resolves when buttons are sent. - */ - sendButtons = async (): Promise => { - this.emit('notice', { - title: '📃 INFO 📃', - instructions: [ - `Twilio presents a different way to implement buttons and lists`, - `To understand more about how it works, I recommend you check the following URLs`, - `https://builderbot.app/en/providers/twilio/uses-cases`, - ], - }) - } - - /** - * - * @param number - * @param message - * @returns - */ - send = async (number: string, message: string, options?: MessageListInstanceCreateOptions): Promise => { - const response = await this.vendor.twilio.messages.create({ - ...options, - body: message, - from: parseNumberFrom(this.globalVendorArgs.vendorNumber), - to: parseNumberFrom(number), - }) - return response - } - - /** - * Sends a message via Twilio. - * @param {string} number - The recipient's phone number. - * @param {string} message - The message to be sent. - * @param {SendOptions} [options] - The options for sending the message. - * @returns {Promise} A Promise that resolves when the message is sent. - */ - sendMessage = async (number: string, message: string, options?: SendOptions): Promise => { - options = { ...options, ...options['options'] } - if (options?.buttons?.length) await this.sendButtons() - if (options?.media) return this.sendMedia(number, message, options.media) - const response = this.vendor.twilio.messages.create({ - body: message, - from: parseNumberFrom(this.globalVendorArgs.vendorNumber), - to: parseNumberFrom(number), - }) - return response - } - - /** - * Saves a file received via Twilio. - * @param {Partial} ctx - The context containing the received file. - * @param {{ path: string }} [options] - The options for saving the file. - * @returns {Promise} A Promise that resolves with the saved file path. - */ - saveFile = async (ctx: Partial, options?: { path: string }): Promise => { - try { - const basicAuthToken = Buffer.from( - `${this.globalVendorArgs.accountSid}:${this.globalVendorArgs.authToken}` - ).toString('base64') - const twilioHeaders = { - Authorization: `Basic ${basicAuthToken}`, - } - const pathFile = join(options?.path ?? tmpdir()) - const localPath = await utils.generalDownload(`${ctx?.MediaUrl0}`, pathFile, twilioHeaders) - return localPath - } catch (err) { - console.log(`[Error]:`, err) - return 'ERROR' - } - } -} - -export { TwilioProvider } diff --git a/packages/provider-twilio/src/types.ts b/packages/provider-twilio/src/types.ts deleted file mode 100644 index 5dbec4211..000000000 --- a/packages/provider-twilio/src/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Button, GlobalVendorArgs } from '@builderbot/bot/dist/types' - -export interface ITwilioProviderARgs extends GlobalVendorArgs { - accountSid: string - authToken: string - vendorNumber: string - publicUrl?: string -} - -export interface IMessageOptions { - buttons?: Button[] - media?: string -} - -export interface TwilioRequestBody { - From: string - To: string - Body: string - NumMedia: string - MediaContentType0?: string - MediaUrl0?: string - Latitude?: string - Longitude?: string - ProfileName?: string -} - -export interface TwilioPayload { - from: string - to: string - body: string - name: string -} diff --git a/packages/provider-twilio/src/utils.ts b/packages/provider-twilio/src/utils.ts deleted file mode 100644 index 687becb34..000000000 --- a/packages/provider-twilio/src/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -const parseNumber = (number: string): string => { - return number.replace(/(?:whatsapp:|\+\d+)/, '').replace(/\s/g, '') -} - -const parseNumberFrom = (number: string): string => { - const cleanNumber = number.replace(/whatsapp|:|\+/g, '').replace(/\s/g, '') - return `whatsapp:+${cleanNumber}` -} - -export { parseNumber, parseNumberFrom } diff --git a/packages/provider-twilio/tsconfig.json b/packages/provider-twilio/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/provider-twilio/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/provider-venom/LICENSE.md b/packages/provider-venom/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/provider-venom/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/provider-venom/README.md b/packages/provider-venom/README.md deleted file mode 100644 index 8fa40aa84..000000000 --- a/packages/provider-venom/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/provider-venom

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/provider-venom/__tests__/provider.test.ts b/packages/provider-venom/__tests__/provider.test.ts deleted file mode 100644 index 12154f7be..000000000 --- a/packages/provider-venom/__tests__/provider.test.ts +++ /dev/null @@ -1,639 +0,0 @@ -import { utils } from '@builderbot/bot' -import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import path from 'path' -import venom from 'venom-bot' - -import { VenomProvider } from '../src' - -const phoneNumber = '1234567890' - -jest.mock('fs/promises', () => ({ - writeFile: jest.fn(), -})) - -jest.mock('../src/utils', () => ({ - venomCleanNumber: jest.fn().mockImplementation(() => phoneNumber), - venomisValidNumber: jest.fn().mockImplementation(() => true), - venomGenerateImage: jest.fn(), - venomDeleteTokens: jest.fn(), -})) - -jest.mock('@builderbot/bot') - -jest.mock('venom-bot', () => ({ - create: jest.fn(), -})) - -describe('#VenomProvider', () => { - let venomProvider: VenomProvider - let mockNext: any - let mockRes: any - let mockReq: any - const activeTimers = new Set() - let originalSetTimeout: typeof setTimeout - let originalClearTimeout: typeof clearTimeout - - beforeEach(() => { - // Intercept setTimeout to track all timers - originalSetTimeout = global.setTimeout - originalClearTimeout = global.clearTimeout - - global.setTimeout = ((fn: Function, delay?: number, ...args: any[]) => { - const timer = originalSetTimeout(fn, delay, ...args) - activeTimers.add(timer) - return timer - }) as typeof setTimeout - - global.clearTimeout = ((timer: any) => { - activeTimers.delete(timer) - return originalClearTimeout(timer) - }) as typeof clearTimeout - - venomProvider = new VenomProvider({ name: 'test', gifPlayback: false }) - mockReq = {} - mockRes = { - writeHead: jest.fn(), - end: jest.fn(), - pipe: jest.fn(), - } - mockNext = jest.fn() - mockNext = jest.fn() - }) - - afterEach(() => { - jest.clearAllMocks() - // Clear all tracked timers - activeTimers.forEach((timer) => { - originalClearTimeout(timer) - }) - activeTimers.clear() - // Restore original functions - global.setTimeout = originalSetTimeout - global.clearTimeout = originalClearTimeout - }) - - describe('VenomProvider Constructor', () => { - test('Initialization with default arguments', () => { - expect(venomProvider.globalVendorArgs).toEqual({ - name: 'test', - gifPlayback: false, - port: 3000, - writeMyself: 'none', - }) - }) - }) - - describe('#saveFile', () => { - test('Save file successfully', async () => { - // Arrange - const mockedDecryptFile = jest.fn().mockImplementation(() => Buffer.from('fileContent')) - const ctx = { mimetype: 'image/png' } - const options = { path: '/tmp' } - const expectedFilePath = '/tmp/some-file-name.png' - - venomProvider.vendor = { - decryptFile: mockedDecryptFile, - } as any - jest.spyOn(path, 'join').mockImplementation(() => expectedFilePath) - // Act - const result = await venomProvider.saveFile(ctx, options) - - // Assert - expect(result).toContain('some-file-name.png') - expect(mockedDecryptFile).toHaveBeenCalledWith(ctx) - expect(writeFile).toHaveBeenCalledWith(expectedFilePath, Buffer.from('fileContent')) - mockedDecryptFile.mockReset() - }) - }) - - describe('#sendMessage', () => { - test('Send text message', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Hello, world!' - const options = {} - const mockSendText = jest.fn().mockImplementation(() => 'Text message sent') - venomProvider.vendor = { - sendText: mockSendText, - } as any - jest.spyOn(venomProvider, 'sendButtons') - jest.spyOn(venomProvider, 'sendMedia') - // Act - await venomProvider.sendMessage(fakeRecipient, fakeMessage, options) - - // Assert - expect(mockSendText).toHaveBeenCalledWith(fakeRecipient, fakeMessage) - expect(venomProvider.sendButtons).not.toHaveBeenCalled() - expect(venomProvider.sendMedia).not.toHaveBeenCalled() - }) - - test('should parse the recipient number and send message with buttons', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Choose an option:' - const fakeButtons = [{ body: 'Option 1' }, { body: 'Option 2' }] - const fakeOptions = { buttons: fakeButtons } - jest.spyOn(venomProvider, 'sendButtons').mockResolvedValue(() => true) - jest.spyOn(venomProvider, 'sendMedia') - - // Act - await venomProvider.sendMessage(fakeRecipient, fakeMessage, fakeOptions) - - // Assert - expect(venomProvider.sendButtons).toHaveBeenCalledWith(fakeRecipient, fakeMessage, fakeButtons) - expect(venomProvider.sendMedia).not.toHaveBeenCalled() - }) - - test('should parse the recipient number and send media message', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Here is a media file' - const fakeMedia = 'path/to/media.jpg' - const fakeOptions = { media: fakeMedia } - jest.spyOn(venomProvider, 'sendButtons') - jest.spyOn(venomProvider, 'sendMedia').mockResolvedValue(() => true) - - // Act - await venomProvider.sendMessage(fakeRecipient, fakeMessage, fakeOptions) - - // Assert - expect(venomProvider.sendMedia).toHaveBeenCalledWith(fakeRecipient, fakeMedia, fakeMessage) - expect(venomProvider.sendButtons).not.toHaveBeenCalled() - }) - }) - - describe('#sendMedia', () => { - test('should send image when provided with image URL', async () => { - // Arrange - const number = '+123456789' - const imageUrl = 'https://example.com/image.jpg' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/image.jpg' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('image/jpeg') - const sendImageSpy = jest.spyOn(venomProvider, 'sendImage').mockImplementation(async () => true as any) - - // Act - await venomProvider.sendMedia(number, imageUrl, text) - - // Assert - expect(sendImageSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(imageUrl) - }) - - test('should send video when provided with video URL', async () => { - // Arrange - const number = '+123456789' - const videoUrl = 'https://example.com/video.mp4' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/audio.mp3' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('video/mp4') - const sendVideoSpy = jest.spyOn(venomProvider, 'sendVideo').mockImplementation(async () => true as any) - - // Act - await venomProvider.sendMedia(number, videoUrl, text) - - // Assert - expect(sendVideoSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(videoUrl) - }) - - test('should send audio when provided with audio URL', async () => { - // Arrange - const number = '+123456789' - const audioUrl = 'https://example.com/audio.mp3' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/audio.mp3' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('audio/mp3') - const sendAudioSpy = jest.spyOn(venomProvider, 'sendAudio').mockImplementation(async () => undefined) - // Act - await venomProvider.sendMedia(number, audioUrl, text) - - // Assert - expect(sendAudioSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(audioUrl) - }) - - test('should send file when provided with file URL', async () => { - // Arrange - const number = '+123456789' - const fileUrl = 'https://example.com/test.pdf' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/test.pdf' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('text/plain') - const sendFileSpy = jest.spyOn(venomProvider, 'sendFile').mockImplementation(async () => undefined) - // Act - await venomProvider.sendMedia(number, fileUrl, text) - - // Assert - expect(sendFileSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(fileUrl) - }) - }) - - describe('#sendVideo', () => { - test('Send video as GIF', async () => { - // Arrange - venomProvider.vendor = { - sendVideoAsGif: jest.fn().mockImplementation(() => 'Video sent'), - } as any - const number = '+123456789' - const filePath = '/path/to/video.mp4' - const text = 'Check out this video' - // Act - const result = await venomProvider.sendVideo(number, filePath, text) - - // Assert - expect(result).toEqual('Video sent') - expect(venomProvider.vendor.sendVideoAsGif).toHaveBeenCalledWith(number, filePath, 'video.gif', text) - }) - }) - - describe('#sendFile', () => { - test('Send file successfully', async () => { - // Arrange - venomProvider.vendor = { - sendFile: jest.fn().mockImplementation(() => 'File sent'), - } as any - const number = '+123456789' - const filePath = '/path/to/file.txt' - const text = 'Check out this file' - jest.spyOn(path, 'basename').mockImplementation(() => filePath) - - // Act - const result = await venomProvider.sendFile(number, filePath, text) - - // Assert - expect(result).toEqual('File sent') - expect(venomProvider.vendor.sendFile).toHaveBeenCalledWith(number, filePath, filePath, text) - }) - }) - - describe('#sendImage', () => { - test('Send image successfully', async () => { - // Arrange - venomProvider.vendor = { - sendImage: jest.fn().mockImplementation(() => 'Image sent'), - } as any - const number = '+123456789' - const filePath = '/path/to/image.png' - const text = 'Check out this image' - jest.spyOn(path, 'basename').mockImplementation(() => filePath) - - // Act - const result = await venomProvider.sendImage(number, filePath, text) - - // Assert - expect(result).toEqual('Image sent') - expect(venomProvider.vendor.sendImage).toHaveBeenCalledWith(number, filePath, filePath, text) - }) - }) - - describe('#sendAudio', () => { - test('Send audio successfully', async () => { - // Arrange - venomProvider.vendor = { - sendVoice: jest.fn().mockImplementation(() => 'Audio sent'), - } as any - const number = '+123456789' - const audioPath = '/path/to/audio.mp3' - jest.spyOn(path, 'basename').mockImplementation(() => audioPath) - // Act - const result = await venomProvider.sendAudio(number, audioPath) - - // Assert - expect(result).toEqual('Audio sent') - expect(venomProvider.vendor.sendVoice).toHaveBeenCalledWith(number, audioPath) - }) - }) - - describe('#sendButtons', () => { - test('Send buttons successfully', async () => { - // Arrange - venomProvider.emit = jest.fn() - venomProvider.vendor = { - sendText: jest.fn().mockImplementation(() => 'Buttons sent'), - } as any - const number = '+123456789' - const message = 'Message with buttons' - const buttons = [{ body: 'Button 1' }, { body: 'Button 2' }] - - // Act - const result = await venomProvider.sendButtons(number, message, buttons) - - // Assert - expect(result).toEqual('Buttons sent') - expect(venomProvider.emit).toHaveBeenCalledWith('notice', { - title: 'DEPRECATED', - instructions: [ - `Currently sending buttons is not available with this provider`, - `this function is available with Meta or Twilio`, - ], - }) - - expect(venomProvider.vendor.sendText).toHaveBeenCalledWith( - number, - 'Message with buttons\nButton 1\nButton 2' - ) - }) - }) - - describe('#busEvents', () => { - test('Should return undefine if the from status@broadcast', () => { - // Arrange - const message: any = { - from: 'status@broadcast', - } - // Act - const resul = venomProvider['busEvents']()[0].func(message) - - // Assert - expect(resul).toBeUndefined() - }) - - test('Should return undefine if the from status@broadcast', () => { - // Arrange - const message: any = { - from: phoneNumber, - } - ;(require('../src/utils').venomisValidNumber as jest.Mock).mockImplementation(() => false) - // Act - const resul = venomProvider['busEvents']()[0].func(message) - - // Assert - expect(resul).toBeUndefined() - }) - - test('Set body property for image or video type', () => { - // Arrange - const message: any = { - type: 'image', - from: phoneNumber, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - venomProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_voice_note_test' - ;(require('../src/utils').venomisValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - venomProvider['busEvents']()[0].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_media_') - }) - test('Set body property for document type', () => { - // Arrange - const message: any = { - type: 'document', - from: phoneNumber, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - venomProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_document_test' - ;(require('../src/utils').venomisValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - venomProvider['busEvents']()[0].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_document_') - }) - - test('Set body property for ptt type', () => { - // Arrange - const message: any = { - type: 'ptt', - from: phoneNumber, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - venomProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_voice_note_test' - ;(require('../src/utils').venomisValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - venomProvider['busEvents']()[0].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_voice_note_') - }) - - test('Set body property for lat and lng type', () => { - // Arrange - const message: any = { - lat: '1224', - lng: '1224', - from: phoneNumber, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - venomProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_location_test' - ;(require('../src/utils').venomisValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - venomProvider['busEvents']()[0].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_location_') - }) - }) - - describe('#generateQr', () => { - test('Generate QR code successfully', async () => { - // Arrange - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - venomProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - venomProvider.getListRoutes = jest.fn().mockReturnValue(['Route 1', 'Route 2']) as any - const mockQr = 'mockedQRCode' - ;(require('../src/utils').venomGenerateImage as jest.Mock).mockImplementation(() => true) - // Act - await venomProvider.generateQr(mockQr) - // Assert - - expect(mockEmit).toHaveBeenCalledWith('notice', { - title: '🛜 HTTP Server ON ', - instructions: ['Route 1', 'Route 2'], - }) - - expect(mockEmit).toHaveBeenCalledWith('require_action', { - title: '⚡⚡ ACTION REQUIRED ⚡⚡', - instructions: [ - 'You must scan the QR Code', - 'Remember that the QR code updates every minute', - 'Need help: https://link.codigoencasa.com/DISCORD', - ], - payload: { qr: mockQr }, - }) - }) - }) - - describe('#indexHome', () => { - test('should send the correct image file', () => { - // Arrange - const mockedReadStream = jest.fn() - const mockedFileStream = { pipe: jest.fn() } - mockedReadStream.mockReturnValueOnce(mockedFileStream) - require('fs').createReadStream = mockedReadStream - const req = { params: { idBotName: 'bot123' } } - const res = { writeHead: jest.fn(), end: jest.fn() } - const expectedImagePath = 'ruta/esperada/bot123.qr.png' - const mockedJoin = jest.spyOn(path, 'join') - mockedJoin.mockReturnValueOnce(expectedImagePath) - - // Act - venomProvider['indexHome'](req as any, res as any, mockNext) - // Assert - expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'image/png' }) - }) - }) - - describe('#beforeHttpServerInit', () => { - test('beforeHttpServerInit - you should configure middleware to handle HTTP requests', () => { - // Arrange - const mockUse = jest.fn().mockReturnThis() - const mockGet = jest.fn() - - const mockPolka = jest.fn(() => ({ - use: mockUse, - get: mockGet, - })) - - venomProvider.server = mockPolka() as any - // Act - venomProvider['beforeHttpServerInit']() - - // Assert - expect(mockUse).toHaveBeenCalled() - const middleware = mockUse.mock.calls[0][0] as any - expect(middleware).toBeInstanceOf(Function) - middleware(mockReq, mockRes, mockNext) - expect(mockReq.globalVendorArgs).toBe(venomProvider.globalVendorArgs) - expect(mockGet).toHaveBeenCalledWith('/', venomProvider.indexHome) - }) - }) - - describe('#listenOnEvents', () => { - test('Assign events correctly', () => { - // Arrange - const mockVendor = { - onMessage: jest.fn(), - onIncomingCall: jest.fn(), - } as any - venomProvider.vendor = mockVendor - const mockEvents = [{ event: 'onMessage', func: jest.fn() }] - venomProvider.busEvents = jest.fn().mockReturnValue(mockEvents) as any - - // Act - venomProvider['listenOnEvents'](mockVendor) - - // Assert - mockEvents.forEach(({ event }) => { - if (mockVendor[event]) { - expect(mockVendor[event]).toHaveBeenCalled() - mockVendor[event]({ from: 'sender@example.com', name: 'Sender Name' }) - } - }) - }) - - test('Throw error if vendor is empty', () => { - // Arrange - venomProvider.vendor = null as any - - // Act & Assert - expect(() => venomProvider['listenOnEvents'](null as any)).toThrow('Vendor should not return empty') - }) - - test('Set vendor when not defined', () => { - // Arrange - const mockVendor = { - onMessage: jest.fn(), - } as any - - // Act - venomProvider['listenOnEvents'](mockVendor) - - // Assert - expect(venomProvider.vendor).toEqual(mockVendor) - }) - }) - - describe('#initVendor', () => { - test('should initialize the vendor successfully', async () => { - // Arrange - const mockHostDevice = { id: { user: 'mockUserId' }, pushname: 'mockPushName' } - ;(venom.create as jest.Mock).mockImplementationOnce((_, qrCallback, statusCallback, options) => { - return Promise.resolve({ - getHostDevice: async () => mockHostDevice, - onIncomingCall: jest.fn(), - } as any) - }) - - // Act - await venomProvider['initVendor']() - - // Assert - expect(venom.create).toHaveBeenCalled() - expect(venomProvider.vendor).toBeDefined() - }) - - test('should handle initialization error', async () => { - // Arrange - ;(require('../src/utils').venomDeleteTokens as jest.Mock).mockImplementation(() => false) - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - venomProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - ;(venom.create as jest.Mock).mockImplementationOnce(() => { - return Promise.reject(new Error('Initialization failed')) - }) - - // Act - await venomProvider['initVendor']() - - // Assert - expect(venom.create).toHaveBeenCalled() - expect(mockEmit).toHaveBeenCalled() - // The setTimeout created in the error handler will be automatically cleaned up in afterEach - }) - }) -}) diff --git a/packages/provider-venom/__tests__/utils.test.ts b/packages/provider-venom/__tests__/utils.test.ts deleted file mode 100644 index 8656ac762..000000000 --- a/packages/provider-venom/__tests__/utils.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { utils } from '@builderbot/bot' -import { describe, expect, jest, test } from '@jest/globals' -import { createWriteStream } from 'fs' -import fsExtra from 'fs-extra' - -import { - emptyDirSessions, - notMatches, - venomCleanNumber, - venomDeleteTokens, - venomDownloadMedia, - venomGenerateImage, - venomisValidNumber, - writeFilePromise, -} from '../src/utils' - -// const httpsMock = { -// get: stub(), -// } - -jest.mock('fs-extra', () => ({ - emptyDir: jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(null)), -})) - -jest.mock('@builderbot/bot') - -jest.mock('@builderbot/bot', () => ({ - utils: { - cleanImage: jest.fn(), - }, -})) - -jest.mock('fs', () => ({ - createWriteStream: jest.fn(), - existsSync: jest.fn(), - readdirSync: jest.fn(() => []), - unlinkSync: jest.fn(), - writeFile: jest.fn((path: string, data: any, options: any, callback: (err?: Error | null) => void) => { - callback(null) - }), -})) - -jest.mock('http', () => ({ - get: jest.fn((_, callback: (res: any) => void) => { - const response = { - pipe: jest.fn(), - } - callback(response) - }), -})) - -jest.mock('https', () => ({ - get: jest.fn((_, callback: (res: any) => void) => { - const response = { - pipe: jest.fn(), - } - callback(response) - }), -})) - -describe('#venomCleanNumber', () => { - test('should clear the number properly', () => { - const numeroLimpio = venomCleanNumber('+123 456 789') - expect(numeroLimpio).toBe('123456789@c.us') - }) - - test('I should clear the entire number', () => { - const numeroLimpio = venomCleanNumber('+123 456 789', true) - expect(numeroLimpio).toBe('123456789') - }) -}) - -describe('#venomisValidNumber', () => { - test('should return true for a valid number', () => { - const esValido = venomisValidNumber('123456789@c.us') - expect(esValido).toBe(true) - }) - - test('should return false for an invalid number', () => { - const esValido = venomisValidNumber('123456789@g.us') - expect(esValido).toBe(false) - }) -}) - -describe('#notMatches', () => { - test('should return true for null', () => { - const resultado = notMatches(null) - expect(resultado).toBe(true) - }) - - test('should return true for an array with length other than 3', () => { - const matches = ['data:image/png;base64,base64String'] - const resultado = notMatches(matches as RegExpMatchArray) - expect(resultado).toBe(true) - }) - - test('should return false for an array with length 3', () => { - const matches = ['data:image/png;base64', 'image/png', 'base64String'] - const resultado = notMatches(matches as RegExpMatchArray) - expect(resultado).toBe(false) - }) -}) - -describe('venomDeleteTokens', () => { - test('should delete tokens', () => { - // Mock - const mockEmptyDirSessions = jest.spyOn(fsExtra, 'emptyDir').mockImplementation(() => true) - // Act - venomDeleteTokens('session') - // Assert - expect(mockEmptyDirSessions).toHaveBeenCalled() - }) -}) - -describe('#venomGenerateImage', () => { - test('should generate image correctly from base64 string', async () => { - // Arrange - const base64String = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABwklEQVQ4y6XTv0rDQBQEwO/rb7gR8sQoWg7R5Yls/4Cs2mj5wgd3GpNn8EiH7Yn+AB/h6q3hnvY+kPTrNzHgtdpR2kYiy2EiKquOYexOPzOZHzXg1XC3+vUaP2OOrLuk4F0p/Mz+AtJ+d6HVWz2dzd+h64n65njfSeL+wh5A1AEjsuEX6Wz+a5UwucZ9lRjJHlB0iUJ/BMo2APXM4l5jJ98yBDkWd/zmO93uzVlu0shhFbz9YjW9NhJp8iF0H2u9jnj9XXl96jDxtQntb7oPjbY8aJj5mDN7ZUOtz2Hl+lgezYrYVlmsZ/o0azWmrFXtI/Bi/lMxHkNvcJM9kwjIJKHQW0PqS2TgBK2b1DfSv9rl4e0j+/BzCmW2Qdo5t+Ik/cYAAAAASUVORK5CYII=' - const expectedFileName = 'test.png' - - // Act - await venomGenerateImage(base64String, expectedFileName) - - // Assert - const fs = require('fs') - expect(utils.cleanImage).toHaveBeenCalled() - fs.unlinkSync(expectedFileName) - }) - - test('should throw error for invalid input string', async () => { - // Arrange - const invalidBase64String = 'invalid_base64_string' - const result = await venomGenerateImage(invalidBase64String) - // Act & Assert - expect(result).toBeDefined() - }) -}) - -describe('# const mockEmptyDir = ', () => { - test('should empty the directory correctly', async () => { - // Arrange - const pathBase = '/path/to/directory' - const mockEmptyDir = jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(null)) - - jest.spyOn(fsExtra, 'emptyDir').mockImplementation(mockEmptyDir as any) - - // Act - await emptyDirSessions(pathBase) - - // Assert - expect(mockEmptyDir).toHaveBeenCalledWith(pathBase, expect.any(Function)) - }) - - test('should handle errors when emptying the directory', async () => { - // Arrange - const pathBase = '/path/to/directory' - const error = new Error('Failed to empty directory') - const mockEmptyDir = jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(error)) - - jest.spyOn(fsExtra, 'emptyDir').mockImplementation(mockEmptyDir as any) - - // Act & Assert - await expect(emptyDirSessions(pathBase)).rejects.toEqual(error) - }) -}) - -describe('writeFilePromise', () => { - test('should resolve with true when writeFile is successful', () => { - // Arrange - const pathQr = 'testPath' - const response: any = { data: 'testData' } - // Act - writeFilePromise(pathQr, response).then((result) => { - // Assert - expect(result).toBe(true) - }) - }) - - test('should reject with error message when writeFile encounters an error', async () => { - // Arrange - const pathQr = 'testPath' - const response: any = { data: 'testData' } - require('fs').writeFile.mockImplementationOnce((path, data, options, callback) => { - callback('some error') - }) - - // Act & Assert - await expect(writeFilePromise(pathQr, response)).rejects.toEqual('ERROR_QR_GENERATE') - }) -}) - -describe('venomDownloadMedia ', () => { - test('should download media from a URL using http', () => { - // Arrange - const url = 'http://example.com/media.jpg' - const mockWriteStream = { - on: jest.fn((event: string, cb: () => void) => { - if (event === 'finish') { - cb() - } - }), - close: jest.fn(), - } - ;(createWriteStream as jest.Mock).mockReturnValue(mockWriteStream) - // Act - venomDownloadMedia(url).then((downloadedPath) => { - // Assert - expect(typeof downloadedPath).toBe('string') - expect(downloadedPath).toContain('tmp-') - }) - }) -}) diff --git a/packages/provider-venom/jest.config.ts b/packages/provider-venom/jest.config.ts deleted file mode 100644 index ca2c6a76f..000000000 --- a/packages/provider-venom/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, -} - -export default config diff --git a/packages/provider-venom/package.json b/packages/provider-venom/package.json deleted file mode 100644 index c9ebb4159..000000000 --- a/packages/provider-venom/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@builderbot/provider-venom", - "version": "1.4.2-alpha.11", - "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "files": [ - "./scripts/", - "./dist/" - ], - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "directories": { - "lib": "dist", - "test": "__tests__" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/body-parser": "^1.19.5", - "@types/cors": "^2.8.17", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "@types/proxyquire": "^1.3.31", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "cors": "^2.8.5", - "mime-types": "^3.0.2", - "pino": "^10.1.0", - "proxyquire": "^2.1.3", - "qr-image": "^3.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1" - }, - "dependencies": { - "body-parser": "^2.2.1", - "polka": "^0.5.2", - "sharp": "0.33.3", - "venom-bot": "~5.3.0" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-venom/rollup.config.js b/packages/provider-venom/rollup.config.js deleted file mode 100644 index 402a43d1d..000000000 --- a/packages/provider-venom/rollup.config.js +++ /dev/null @@ -1,25 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import json from '@rollup/plugin-json' -import commonjs from '@rollup/plugin-commonjs' -import { nodeResolve } from '@rollup/plugin-node-resolve' - -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - json(), - nodeResolve({ - resolveOnly: (module) => !/ffmpeg|@builderbot\/bot|venom-bot|sharp/i.test(module), - }), - nodeResolve(), - commonjs(), - typescript(), - ], -} diff --git a/packages/provider-venom/scripts/fix.js b/packages/provider-venom/scripts/fix.js deleted file mode 100644 index feafb3e6f..000000000 --- a/packages/provider-venom/scripts/fix.js +++ /dev/null @@ -1,42 +0,0 @@ -import { readFileSync } from 'fs' -import { writeFile } from 'fs/promises' - -// https://github.com/orkestral/venom/issues/2485 -const fixSendFiles = async () => { - const path = './node_modules/venom-bot/dist/lib/wapi/wapi.js' - let toFix = readFileSync(path) - toFix = toFix - .toString() - .replace( - `return await n.processAttachments("0.4.613"===Debug.VERSION?t:t.map((e=>({file:e}))),e,1),n}`, - `return await n.processAttachments("0.4.613"===Debug.VERSION?t:t.map((e=>({file:e}))),e,e),n}` - ) - await writeFile(path, toFix) -} - -const removeOverLogs = async () => { - const path = './node_modules/venom-bot/dist/utils/spinnies.js' - let toFix = readFileSync(path, 'utf8') - - toFix = toFix - .replace( - /function\s+getSpinnies\(options\)\s*{[^}]+}/, - `function getSpinnies_(options) { - if (!spinnies) { - spinnies = new spinnies_1.default(options); - } - spinnies.fail = () => null; - spinnies.succeed = () => null; - spinnies.add = () => null; - ` - ) - .replace('exports.getSpinnies = getSpinnies;', `exports.getSpinnies = getSpinnies_;`) - await writeFile(path, toFix) -} - -const mainFix = async () => { - await removeOverLogs() - await fixSendFiles() -} - -mainFix() diff --git a/packages/provider-venom/src/index.ts b/packages/provider-venom/src/index.ts deleted file mode 100644 index c35ae3105..000000000 --- a/packages/provider-venom/src/index.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { ProviderClass, utils } from '@builderbot/bot' -import type { Vendor } from '@builderbot/bot/dist/provider/interface/provider' -import type { BotContext, Button, GlobalVendorArgs, SendOptions } from '@builderbot/bot/dist/types' -import { createReadStream } from 'fs' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import { tmpdir } from 'os' -import { basename, join, resolve } from 'path' -import type polka from 'polka' -import venom from 'venom-bot' - -import type { SaveFileOptions } from './types' -import { venomCleanNumber, venomDeleteTokens, venomGenerateImage, venomisValidNumber } from './utils' - -/** - * ⚙️ VenomProvider: Es una clase tipo adaptor - * que extiende clases de ProviderClass (la cual es como interfaz para sber que funciones rqueridas) - * https://github.com/orkestral/venom - */ -class VenomProvider extends ProviderClass { - globalVendorArgs: GlobalVendorArgs = { - name: 'bot', - gifPlayback: false, - port: 3000, - writeMyself: 'none', - } - vendor: venom.Whatsapp - constructor(args: { name: string; gifPlayback: boolean }) { - super() - this.globalVendorArgs = { ...this.globalVendorArgs, ...args } - } - - private generateFileName = (extension: string): string => `file-${Date.now()}.${extension}` - - protected async initVendor(): Promise { - const NAME_DIR_SESSION = `${this.globalVendorArgs.name}_sessions` - try { - const client = await venom.create( - NAME_DIR_SESSION, - (base) => this.generateQr(base), - (info) => { - console.log({ info }) - if ( - [ - 'initBrowser', - 'openBrowser', - 'initWhatsapp', - 'successPageWhatsapp', - 'notLogged', - 'waitForLogin', - 'waitChat', - 'successChat', - ].includes(info) - ) { - console.clear() - this.emit('notice', { - title: '⏱️ Loading... ', - instructions: [`this process can take up to 90 seconds`, `we will let you know shortly`], - }) - } - }, - { - updatesLog: false, - disableSpins: true, - disableWelcome: true, - logQR: false, - autoClose: 45000, - headless: 'new', - ...this.globalVendorArgs, - folderNameToken: NAME_DIR_SESSION, - } - ) - - this.vendor = client - const hostDevice: any = await this.vendor.getHostDevice() - const { id, pushname } = hostDevice - const host = { - name: pushname, - phone: id.user, - } - - client.onIncomingCall(async (call) => { - console.log(call) - // client.sendText(call.peerJid, "Sorry, I still can't answer calls"); - }) - this.emit('ready', true) - this.emit('host', host) - return client - } catch (e) { - console.log(e) - this.emit('auth_failure', { - instructions: [`An error occurred during Venom initialization`, `trying again in 5 seconds...`], - }) - venomDeleteTokens(NAME_DIR_SESSION) - setTimeout(async () => { - console.clear() - await this.initVendor() - }, 5000) - } - } - - protected listenOnEvents(vendor: Vendor): void { - if (!vendor) { - throw Error(`Vendor should not return empty`) - } - - if (!this.vendor) { - this.vendor = vendor - } - - const listEvents = this.busEvents() - for (const { event, func } of listEvents) { - if (this.vendor[event]) - this.vendor[event]((payload: venom.Message & { lat?: string; lng?: string; name: string }) => - func(payload) - ) - } - } - - protected beforeHttpServerInit(): void { - this.server = this.server - .use((req, _, next) => { - req['globalVendorArgs'] = this.globalVendorArgs - return next() - }) - .get('/', this.indexHome) - } - - /** - * - * @param req - * @param res - */ - public indexHome: polka.Middleware = (req, res) => { - const botName = req[this.idBotName] - const qrPath = join(process.cwd(), `${botName}.qr.png`) - const fileStream = createReadStream(qrPath) - res.writeHead(200, { 'Content-Type': 'image/png' }) - fileStream.pipe(res) - } - - protected afterHttpServerInit(): void {} - - /** - * Generamos QR Code pra escanear con el Whatsapp - */ - generateQr = async (qr: string) => { - console.clear() - - this.emit('notice', { - title: '🛜 HTTP Server ON ', - instructions: this.getListRoutes(this.server), - }) - - this.emit('require_action', { - title: '⚡⚡ ACTION REQUIRED ⚡⚡', - instructions: [ - `You must scan the QR Code`, - `Remember that the QR code updates every minute`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ], - payload: { qr }, - }) - - await venomGenerateImage(qr, `${this.globalVendorArgs.name}.qr.png`) - } - - /** - * Mapeamos los eventos nativos de https://docs.orkestral.io/venom/#/?id=events - * para tener un standar de eventos - * @returns - */ - busEvents = () => [ - { - event: 'onMessage', - func: (payload: venom.Message & { lat?: string; lng?: string; name: string }) => { - if (payload.from === 'status@broadcast') { - return - } - if (!venomisValidNumber(payload.from)) { - return - } - - payload.from = venomCleanNumber(payload.from, true) - payload.name = `${payload.sender?.pushname}` - - if (payload.hasOwnProperty('type') && ['image', 'video'].includes(payload.type)) { - payload = { - ...payload, - body: utils.generateRefProvider('_event_media_'), - } - } - - if (payload.hasOwnProperty('type') && ['document'].includes(payload.type)) { - payload = { ...payload, body: utils.generateRefProvider('_event_document_') } - } - - if (payload.hasOwnProperty('type') && ['ptt'].includes(payload.type)) { - payload = { ...payload, body: utils.generateRefProvider('_event_voice_note_') } - } - if (payload.hasOwnProperty('lat') && payload.hasOwnProperty('lng')) { - const lat = payload.lat - const lng = payload.lng - if (lat !== '' && lng !== '') { - payload = { ...payload, body: utils.generateRefProvider('_event_location_') } - } - } - this.emit('message', payload) - }, - }, - ] - - /** - * @deprecated Buttons are not available in this provider, please use sendButtons instead - * @private - * @param {*} number - * @param {*} message - * @param {*} buttons [] - * @returns - */ - sendButtons = async (number: string, message: string, buttons: Button[] = []) => { - this.emit('notice', { - title: 'DEPRECATED', - instructions: [ - `Currently sending buttons is not available with this provider`, - `this function is available with Meta or Twilio`, - ], - }) - const buttonToStr = [message].concat(buttons.map((btn) => `${btn.body}`)).join(`\n`) - return this.vendor.sendText(number, buttonToStr) - // return this.vendor.sendButtons(number, "Title", buttons1, "Description"); - } - - /** - * Enviar audio - * @alpha - * @param {string} number - * @param {string} message - * @param {boolean} voiceNote optional - * @example await sendMessage('+XXXXXXXXXXX', 'audio.mp3') - */ - - sendAudio = async (number: string, audioPath: string) => { - return this.vendor.sendVoice(number, audioPath) - } - - /** - * Enviar imagen - * @param {*} number - * @param {*} imageUrl - * @param {*} text - * @returns - */ - sendImage = async (number: string, filePath: string, text: string) => { - const fileName = basename(filePath) - return this.vendor.sendImage(number, filePath, fileName, text) - } - - /** - * - * @param {string} number - * @param {string} filePath - * @example await sendMessage('+XXXXXXXXXXX', './document/file.pdf') - */ - - sendFile = async (number: string, filePath: string, text: string) => { - const fileName = basename(filePath) - return this.vendor.sendFile(number, filePath, fileName, text) - } - - /** - * Enviar video - * @param {*} number - * @param {*} imageUrl - * @param {*} text - * @returns - */ - sendVideo = async (number: string, filePath: string, text: string) => { - return this.vendor.sendVideoAsGif(number, filePath, 'video.gif', text) - } - - /** - * Enviar imagen o multimedia - * @param {*} number - * @param {*} mediaInput - * @param {*} message - * @returns - */ - sendMedia = async (number: string, mediaUrl: string, text: string) => { - const fileDownloaded = await utils.generalDownload(mediaUrl) - const mimeType = mime.lookup(fileDownloaded) - if (`${mimeType}`.includes('image')) return this.sendImage(number, fileDownloaded, text) - if (`${mimeType}`.includes('video')) return this.sendVideo(number, fileDownloaded, text) - if (`${mimeType}`.includes('audio')) { - const fileOpus = await utils.convertAudio(fileDownloaded, 'mp3') - return this.sendAudio(number, fileOpus) - } - - return this.sendFile(number, fileDownloaded, text) - } - - /** - * - * @param number - * @param message - * @param options - * @returns - */ - sendMessage = async (number: string, message: string, options?: SendOptions): Promise => { - options = { ...options, ...options['options'] } - number = venomCleanNumber(number) - if (options?.buttons?.length) return this.sendButtons(number, message, options.buttons) - if (options?.media) return this.sendMedia(number, options.media, message) - return this.vendor.sendText(number, message) - } - - /** - * - * @param ctx - * @param options - * @returns - */ - saveFile = async (ctx: Partial, options: SaveFileOptions = {}): Promise => { - try { - const { mimetype } = ctx - const buffer = await this.vendor.decryptFile(ctx as venom.Message) - const extension = mime.extension(mimetype) as string - const fileName = this.generateFileName(extension) - const pathFile = join(options?.path ?? tmpdir(), fileName) - await writeFile(pathFile, buffer) - return resolve(pathFile) - } catch (err) { - console.log(`[Error]:`, err.message) - return 'ERROR' - } - } -} - -export { VenomProvider } diff --git a/packages/provider-venom/src/types.ts b/packages/provider-venom/src/types.ts deleted file mode 100644 index a8d92f3e7..000000000 --- a/packages/provider-venom/src/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ProviderClass } from '@builderbot/bot' - -export type BotCtxMiddleware = Partial -export interface SaveFileOptions { - path?: string -} diff --git a/packages/provider-venom/src/utils.ts b/packages/provider-venom/src/utils.ts deleted file mode 100644 index f23458b16..000000000 --- a/packages/provider-venom/src/utils.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { utils } from '@builderbot/bot' -import { writeFile, createWriteStream } from 'fs' -import { emptyDir } from 'fs-extra' -import * as http from 'http' -import * as https from 'https' -import { tmpdir } from 'os' -import { join } from 'path' - -const emptyDirSessions = async (pathBase: string) => - new Promise((resolve, reject) => { - emptyDir(pathBase, (err) => { - if (err) reject(err) - resolve(true) - }) - }) - -const venomCleanNumber = (number: string, full: boolean = false): string => { - number = number.replace('@c.us', '').replace('+', '').replace(/\s/g, '') - number = !full ? `${number}@c.us` : `${number}` - return number -} - -const writeFilePromise = (pathQr: string, response: { type: string; data: Buffer }): Promise => { - return new Promise((resolve, reject) => { - writeFile(pathQr, response.data, 'binary', (err) => { - if (err !== null) reject('ERROR_QR_GENERATE') - resolve(true) - }) - }) -} - -const notMatches = (matches: RegExpMatchArray | null): boolean => { - return !matches || matches.length !== 3 -} - -const venomGenerateImage = async (base: string, name: string = 'qr.png'): Promise => { - const PATH_QR = `${process.cwd()}/${name}` - const matches = base.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/) - - if (notMatches(matches)) { - return new Error('Invalid input string') - } - - const response: { type: string; data: Buffer } = { - type: matches[1], - data: Buffer.from(matches[2], 'base64'), - } - - await writeFilePromise(PATH_QR, response) - await utils.cleanImage(PATH_QR) -} - -const venomDownloadMedia = (url: string): Promise => { - return new Promise((resolve, reject) => { - const ext = url.split('.').pop() || 'unknown' - const checkProtocol = url.includes('https:') - const handleHttp = checkProtocol ? https : http - const name = `tmp-${Date.now()}.${ext}` - const fullPath = `${tmpdir()}/${name}` - const file = createWriteStream(fullPath) - handleHttp.get(url, function (response) { - response.pipe(file) - file.on('finish', function () { - file.close() - resolve(fullPath) - }) - file.on('error', function () { - console.log('error') - file.close() - reject(new Error('Download failed')) - }) - }) - }) -} - -const venomDeleteTokens = (session: string) => { - try { - const pathTokens = join(process.cwd(), session) - emptyDirSessions(pathTokens) - } catch (e) { - return - } -} - -const venomisValidNumber = (rawNumber: string): boolean => { - const regexGroup = /\@g.us\b/gm - const exist = rawNumber.match(regexGroup) - return !exist -} - -export { - venomCleanNumber, - venomDeleteTokens, - venomGenerateImage, - venomisValidNumber, - venomDownloadMedia, - writeFilePromise, - notMatches, - emptyDirSessions, -} diff --git a/packages/provider-venom/tsconfig.json b/packages/provider-venom/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/provider-venom/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/provider-web-whatsapp/CHANGELOG.md b/packages/provider-web-whatsapp/CHANGELOG.md deleted file mode 100644 index 2be001189..000000000 --- a/packages/provider-web-whatsapp/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [0.1.0-alpha.18](https://github.com/codigoencasa/bot-whatsapp/compare/v0.1.0-alpha.0...v0.1.0-alpha.18) (2024-01-19) - -**Note:** Version bump only for package @builderbot/web-whatsapp diff --git a/packages/provider-web-whatsapp/LICENSE.md b/packages/provider-web-whatsapp/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/provider-web-whatsapp/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/provider-web-whatsapp/README.md b/packages/provider-web-whatsapp/README.md deleted file mode 100644 index 1d685955d..000000000 --- a/packages/provider-web-whatsapp/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/provider-web-whatsapp

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/provider-web-whatsapp/__tests__/provider.test.ts b/packages/provider-web-whatsapp/__tests__/provider.test.ts deleted file mode 100644 index 18e101272..000000000 --- a/packages/provider-web-whatsapp/__tests__/provider.test.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { utils } from '@builderbot/bot' -import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import fs from 'fs' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import path from 'path' -import { Client } from 'whatsapp-web.js' - -import { WebWhatsappProvider } from '../src/index' - -const phoneNumber = '1234567890@c.us' - -jest.mock('@builderbot/bot') - -jest.mock('../src/utils', () => ({ - wwebCleanNumber: jest.fn().mockImplementation(() => phoneNumber), - wwebIsValidNumber: jest.fn().mockImplementation(() => false), - wwebGenerateImage: jest.fn(), - wwebGetChromeExecutablePath: jest.fn(), - wwebDeleteTokens: jest.fn(), -})) - -jest.mock('fs/promises', () => ({ - writeFile: jest.fn(), -})) - -describe('#WebWhatsappProvider', () => { - let webWhatsappProvider: WebWhatsappProvider - let mockNext: any - let mockRes: any - let mockReq: any - beforeEach(() => { - const args = { name: 'bot', gifPlayback: false } - webWhatsappProvider = new WebWhatsappProvider(args) - mockNext = jest.fn() - mockReq = {} - mockRes = { - writeHead: jest.fn(), - end: jest.fn(), - pipe: jest.fn(), - } - }) - - describe('#initVendor', () => { - test('initVendor initializes the vendor correctly', async () => { - // Arrange - - // Act - const result = await webWhatsappProvider['initVendor']() - - // Assert - expect(result).toBeInstanceOf(Client) - }) - }) - - describe('#sendMessage', () => { - test('Send text message', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Hello, world!' - const options = {} - const mockSendText = jest.fn().mockImplementation(() => 'Text message sent') - webWhatsappProvider.vendor = { - sendMessage: mockSendText, - } as any - jest.spyOn(webWhatsappProvider, 'sendButtons') - jest.spyOn(webWhatsappProvider, 'sendMedia') - // Act - await webWhatsappProvider.sendMessage(fakeRecipient, fakeMessage, options) - - // Assert - expect(mockSendText).toHaveBeenCalledWith('1234567890@c.us', fakeMessage) - expect(webWhatsappProvider.sendButtons).not.toHaveBeenCalled() - expect(webWhatsappProvider.sendMedia).not.toHaveBeenCalled() - }) - - test('should parse the recipient number and send message with buttons', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Choose an option:' - const fakeButtons = [{ body: 'Option 1' }, { body: 'Option 2' }] - const fakeOptions = { buttons: fakeButtons } - jest.spyOn(webWhatsappProvider, 'sendButtons').mockImplementation(() => true as any) - jest.spyOn(webWhatsappProvider, 'sendMedia') - - // Act - await webWhatsappProvider.sendMessage(fakeRecipient, fakeMessage, fakeOptions) - - // Assert - expect(webWhatsappProvider.sendButtons).toHaveBeenCalledWith('1234567890@c.us', fakeMessage, fakeButtons) - expect(webWhatsappProvider.sendMedia).not.toHaveBeenCalled() - }) - - test('should parse the recipient number and send media message', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Here is a media file' - const fakeMedia = 'path/to/media.jpg' - const fakeOptions = { media: fakeMedia } - jest.spyOn(webWhatsappProvider, 'sendButtons') - jest.spyOn(webWhatsappProvider, 'sendMedia').mockImplementation(() => true as any) - - // Act - await webWhatsappProvider.sendMessage(fakeRecipient, fakeMessage, fakeOptions) - - // Assert - expect(webWhatsappProvider.sendMedia).toHaveBeenCalledWith('1234567890@c.us', fakeMedia, fakeMessage) - expect(webWhatsappProvider.sendButtons).not.toHaveBeenCalled() - }) - }) - - describe('#sendMedia', () => { - test('should send image when provided with image URL', async () => { - // Arrange - const number = '+123456789' - const imageUrl = 'https://example.com/image.jpg' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/image.jpg' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('image/jpeg') - const sendImageSpy = jest - .spyOn(webWhatsappProvider, 'sendImage') - .mockImplementation(async () => true as any) - - // Act - await webWhatsappProvider.sendMedia(number, imageUrl, text) - - // Assert - expect(sendImageSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(imageUrl) - }) - - test('should send video when provided with video URL', async () => { - // Arrange - const number = '+123456789' - const videoUrl = 'https://example.com/video.mp4' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/audio.mp3' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('video/mp4') - const sendVideoSpy = jest - .spyOn(webWhatsappProvider, 'sendVideo') - .mockImplementation(async () => true as any) - - // Act - await webWhatsappProvider.sendMedia(number, videoUrl, text) - - // Assert - expect(sendVideoSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(videoUrl) - }) - - test('should send audio when provided with audio URL', async () => { - // Arrange - const number = '+123456789' - const audioUrl = 'https://example.com/audio.mp3' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/audio.mp3' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('audio/mp3') - const sendAudioSpy = jest - .spyOn(webWhatsappProvider, 'sendAudio') - .mockImplementation(async () => undefined as any) - // Act - await webWhatsappProvider.sendMedia(number, audioUrl, text) - - // Assert - expect(sendAudioSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(audioUrl) - }) - - test('should send file when provided with file URL', async () => { - // Arrange - const number = '+123456789' - const fileUrl = 'https://example.com/test.pdf' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/test.pdf' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('text/plain') - const sendFileSpy = jest - .spyOn(webWhatsappProvider, 'sendFile') - .mockImplementation(async () => undefined as any) - // Act - await webWhatsappProvider.sendMedia(number, fileUrl, text) - // Assert - expect(sendFileSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(fileUrl) - }) - }) - - describe('#sendFile', () => { - test('should send a file successfully', async () => { - // Arrange: - const number = '+1234567890' - const filePath = '/path/to/test/file.txt' - const text = 'Test file' - jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('sticker-buffer')) - jest.spyOn(mime, 'lookup').mockReturnValue('text/plain') - const mockSendFile = jest.fn().mockImplementation(() => 'file message sent') - webWhatsappProvider.vendor = { - sendMessage: mockSendFile, - } as any - // Act - const result = await webWhatsappProvider.sendFile(number, filePath, text) - - // Assert - expect(result).toBeTruthy() - expect(mockSendFile).toHaveBeenCalled() - }) - }) - - describe('#sendVideo', () => { - test('should send a sendVideo successfully', async () => { - // Arrange: - const number = '+1234567890' - const filePath = '/path/to/test/file.txt' - const text = 'Test file' - jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('sticker-buffer')) - jest.spyOn(mime, 'lookup').mockReturnValue('video/mp3') - const mockSendVideo = jest.fn().mockImplementation(() => 'video message sent') - webWhatsappProvider.vendor = { - sendMessage: mockSendVideo, - } as any - // Act - const result = await webWhatsappProvider.sendVideo(number, filePath, text) - - // Assert - expect(result).toBeTruthy() - expect(mockSendVideo).toHaveBeenCalled() - }) - }) - - describe('#sendAudio', () => { - test('should send a sendAudio successfully', async () => { - // Arrange: - const number = '+1234567890' - const filePath = '/path/to/test/file.txt' - const text = 'Test file' - jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('sticker-buffer')) - jest.spyOn(mime, 'lookup').mockReturnValue('audio/mp3') - const mockSendAudio = jest.fn().mockImplementation(() => 'audio message sent') - webWhatsappProvider.vendor = { - sendMessage: mockSendAudio, - } as any - // Act - const result = await webWhatsappProvider.sendAudio(number, filePath, text) - - // Assert - expect(result).toBeTruthy() - expect(mockSendAudio).toHaveBeenCalled() - }) - }) - - describe('#sendImage', () => { - test('should send a sendImage successfully', async () => { - // Arrange: - const number = '+1234567890' - const filePath = '/path/to/test/file.txt' - const text = 'Test file' - jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('sticker-buffer')) - jest.spyOn(mime, 'lookup').mockReturnValue('imagen/png') - const mockSendImage = jest.fn().mockImplementation(() => 'imagen message sent') - webWhatsappProvider.vendor = { - sendMessage: mockSendImage, - } as any - // Act - const result = await webWhatsappProvider.sendImage(number, filePath, text) - - // Assert - expect(result).toBeTruthy() - expect(mockSendImage).toHaveBeenCalled() - }) - }) - - describe('#sendButtons', () => { - test('Send buttons successfully', async () => { - // Arrange - webWhatsappProvider.emit = jest.fn() - const number = '+123456789' - const message = 'Message with buttons' - const buttons = [{ body: 'Button 1' }, { body: 'Button 2' }] - const mockSendButtons = jest.fn().mockImplementation(() => 'Buttons sent') - webWhatsappProvider.vendor = { - sendMessage: mockSendButtons, - } as any - // Act - const result = await webWhatsappProvider.sendButtons(number, message, buttons) - - // Assert - expect(result).toEqual('Buttons sent') - expect(webWhatsappProvider.emit).toHaveBeenCalledWith('notice', { - title: 'DEPRECATED', - instructions: [ - `Currently sending buttons is not available with this provider`, - `this function is available with Meta or Twilio`, - ], - }) - - expect(mockSendButtons).toHaveBeenCalled() - }) - }) - - describe('#busEvents', () => { - test('Should return undefine if the from status@broadcast', async () => { - // Arrange - const message: any = { - from: 'status@broadcast', - } - // Act - const resul = await webWhatsappProvider['busEvents']()[3].func(message) - - // Assert - expect(resul).toEqual(undefined) - }) - - test('Should return undefine if the from status@broadcast', async () => { - // Arrange - const message: any = { - from: phoneNumber, - } - ;(require('../src/utils').wwebIsValidNumber as jest.Mock).mockImplementation(() => false) - // Act - const resul = await webWhatsappProvider['busEvents']()[3].func(message) - - // Assert - expect(resul).toBeUndefined() - }) - - test('messaga emit event media', () => { - // Arrange - const message: any = { - _data: { - type: 'image', - from: '1234567890', - }, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - webWhatsappProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_media__test' - ;(require('../src/utils').wwebIsValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - webWhatsappProvider['busEvents']()[3].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_media_') - }) - - test('Set body property for document type', () => { - // Arrange - const message: any = { - _data: { - type: 'document', - from: phoneNumber, - }, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - webWhatsappProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_document_test' - ;(require('../src/utils').wwebIsValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - webWhatsappProvider['busEvents']()[3].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_document_') - }) - - test('Set body property for ptt type', () => { - // Arrange - const message: any = { - _data: { - type: 'ptt', - from: phoneNumber, - }, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - webWhatsappProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_voice_note_test' - ;(require('../src/utils').wwebIsValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - webWhatsappProvider['busEvents']()[3].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_voice_note_') - }) - - test('Set body property for lat and lng type', () => { - // Arrange - const message: any = { - _data: { - lat: '1224', - lng: '1224', - from: phoneNumber, - }, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - webWhatsappProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_location_test' - ;(require('../src/utils').wwebIsValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - webWhatsappProvider['busEvents']()[3].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_location_') - }) - - test('#auth_failure - should emit the correct events with payloads', async () => { - // Arrange - const payload: any = { - message: 'Test', - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - webWhatsappProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - webWhatsappProvider['busEvents']()[0].func(payload) - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('auth_failure', payload) - }) - - test('#ready - should emit the correct events with payloads', async () => { - // Arrange - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - webWhatsappProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - webWhatsappProvider['busEvents']()[2].func({} as any) - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('ready', true) - }) - test('#qr - should emit the correct events with payloads', async () => { - // Arrange - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - webWhatsappProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - - // Act - webWhatsappProvider['busEvents']()[1].func('qr') - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('require_action', { - instructions: [ - 'You must scan the QR Code', - 'Remember that the QR code updates every minute', - 'Need help: https://link.codigoencasa.com/DISCORD', - ], - title: '⚡⚡ ACTION REQUIRED ⚡⚡', - payload: { qr: 'qr' }, - }) - }) - }) - - describe('#saveFile', () => { - test('Save file successfully', async () => { - // Arrange - const ctx = { fileData: { mimetype: 'image/png', data: 'base64encodeddata' } } - const options = { path: '/tmp' } - const expectedFilePath = '/tmp/some-file-name.png' - - jest.spyOn(path, 'join').mockImplementation(() => expectedFilePath) - // Act - const result = await webWhatsappProvider.saveFile(ctx as any, options) - - // Assert - expect(result).toContain('some-file-name.png') - expect(writeFile).toHaveBeenCalled() - }) - }) - - describe('#indexHome', () => { - test('should send the correct image file', () => { - // Arrange - const mockedReadStream = jest.fn() - const mockedFileStream = { pipe: jest.fn() } - mockedReadStream.mockReturnValueOnce(mockedFileStream) - require('fs').createReadStream = mockedReadStream - const req = { params: { idBotName: 'bot123' } } - const res = { writeHead: jest.fn(), end: jest.fn() } - const expectedImagePath = 'ruta/esperada/bot123.qr.png' - const mockedJoin = jest.spyOn(path, 'join') - mockedJoin.mockReturnValueOnce(expectedImagePath) - - // Act - webWhatsappProvider['indexHome'](req as any, res as any, mockNext) - // Assert - expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'image/png' }) - }) - }) - - describe('#beforeHttpServerInit', () => { - test('beforeHttpServerInit - you should configure middleware to handle HTTP requests', () => { - // Arrange - const mockUse = jest.fn().mockReturnThis() - const mockGet = jest.fn() - - const mockPolka = jest.fn(() => ({ - use: mockUse, - get: mockGet, - })) - - webWhatsappProvider.server = mockPolka() as any - // Act - webWhatsappProvider['beforeHttpServerInit']() - - // Assert - expect(mockUse).toHaveBeenCalled() - const middleware = mockUse.mock.calls[0][0] as any - expect(middleware).toBeInstanceOf(Function) - middleware(mockReq, mockRes, mockNext) - expect(mockReq.globalVendorArgs).toBe(webWhatsappProvider.globalVendorArgs) - expect(mockGet).toHaveBeenCalledWith('/', webWhatsappProvider.indexHome) - }) - }) - - describe('#afterHttpServerInit ', () => { - test(' emits a notice event with the correct data', () => { - // Arrange - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - webWhatsappProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - // Act - webWhatsappProvider['afterHttpServerInit']() - - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('notice', { - title: '⏱️ Loading... ', - instructions: [`this process can take up to 90 seconds`, `we will let you know shortly`], - }) - }) - }) -}) diff --git a/packages/provider-web-whatsapp/__tests__/utils.test.ts b/packages/provider-web-whatsapp/__tests__/utils.test.ts deleted file mode 100644 index 339108a05..000000000 --- a/packages/provider-web-whatsapp/__tests__/utils.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { utils } from '@builderbot/bot' -import { describe, expect, jest, test } from '@jest/globals' -import { createWriteStream } from 'fs' -import fs from 'fs' -import fsExtra from 'fs-extra' -import os from 'os' -import { join } from 'path' -import qr from 'qr-image' - -import { - emptyDirSessions, - wwebCleanNumber, - wwebDeleteTokens, - wwebDownloadMedia, - wwebGenerateImage, - wwebGetChromeExecutablePath, - wwebIsValidNumber, -} from '../src/utils' - -jest.mock('qr-image', () => ({ - image: jest.fn(() => ({ - pipe: jest.fn(), - })), -})) - -jest.mock('fs-extra', () => ({ - emptyDir: jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(null)), -})) - -jest.mock('@builderbot/bot', () => ({ - utils: { - cleanImage: jest.fn(), - }, -})) - -jest.mock('../src/utils', () => ({ - wwebGetWindowsChromeExecutablePath: jest.fn(), -})) - -jest.mock('../src/utils', () => ({ - ...(jest.requireActual('../src/utils') as any), - wwebGetWindowsChromeExecutablePath: jest.fn(), -})) - -jest.mock('http', () => ({ - get: jest.fn((_, callback: (res: any) => void) => { - const response = { - pipe: jest.fn(), - } - callback(response) - }), -})) - -jest.mock('https', () => ({ - get: jest.fn((_, callback: (res: any) => void) => { - const response = { - pipe: jest.fn(), - } - callback(response) - }), -})) - -jest.mock('fs', () => ({ - createWriteStream: jest.fn(), - existsSync: jest.fn(), - readdirSync: jest.fn(() => []), - unlinkSync: jest.fn(), -})) - -describe('wwebCleanNumber', () => { - test('it should clean a number properly', () => { - const inputNumber = '+123 456 789' - const cleanedNumber = wwebCleanNumber(inputNumber) - expect(cleanedNumber).toBe('123456789@c.us') - }) -}) - -describe('wwebIsValidNumber', () => { - test('it should return true for a valid number', () => { - const rawNumber = '+123456789' - expect(wwebIsValidNumber(rawNumber)).toBe(true) - }) - - test('it should return false for a group number', () => { - const rawNumber = '+123456789@g.us' - expect(wwebIsValidNumber(rawNumber)).toBe(false) - }) -}) - -describe('venomDeleteTokens', () => { - test('should delete tokens', () => { - // Mock - const mockEmptyDirSessions = jest.spyOn(fsExtra, 'emptyDir').mockImplementation(() => true) - // Act - wwebDeleteTokens('session') - // Assert - expect(mockEmptyDirSessions).toHaveBeenCalled() - }) -}) - -describe('#venomGenerateImage', () => { - test('should generate an image file from a base64 string', () => { - // Arrange - const base64 = 'yourBase64String' - const imageName = 'test_image.png' - const imagePath = join(process.cwd(), imageName) - const mockWriteStream = { - on: jest.fn(), - write: jest.fn(), - end: jest.fn(), - } - const mockPipe = jest.fn().mockReturnValue(mockWriteStream) - const mockQrSvg = { pipe: mockPipe } - ;(qr.image as jest.Mock).mockReturnValue(mockQrSvg) - ;(createWriteStream as jest.Mock).mockReturnValue(jest.fn()) - - // Act - wwebGenerateImage(base64, imageName).then((result) => { - // Assert - expect(result).toBeTruthy() - expect(qr.image).toHaveBeenCalledWith(base64, { type: 'png', margin: 4 }) - expect(utils.cleanImage).toHaveBeenCalledWith(imagePath) - expect(createWriteStream).toHaveBeenCalledWith(imagePath) - expect(mockWriteStream.on).toHaveBeenCalledWith('finish', expect.any(Function)) - }) - }) -}) - -describe('#mockEmptyDir', () => { - test('should empty the directory correctly', async () => { - // Arrange - const pathBase = '/path/to/directory' - const mockEmptyDir = jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(null)) - - jest.spyOn(fsExtra, 'emptyDir').mockImplementation(mockEmptyDir as any) - - // Act - await emptyDirSessions(pathBase) - - // Assert - expect(mockEmptyDir).toHaveBeenCalledWith(pathBase, expect.any(Function)) - }) - - test('should handle errors when emptying the directory', async () => { - // Arrange - const pathBase = '/path/to/directory' - const error = new Error('Failed to empty directory') - const mockEmptyDir = jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(error)) - - jest.spyOn(fsExtra, 'emptyDir').mockImplementation(mockEmptyDir as any) - - // Act & Assert - await expect(emptyDirSessions(pathBase)).rejects.toEqual(error) - }) -}) - -describe('#wwebGetChromeExecutablePath ', () => { - test('should return the correct Chrome executable path for Windows', () => { - // Arrange - jest.spyOn(os, 'platform').mockReturnValue('win32') - const mockWindowsChromePath = '' - jest.spyOn(fs, 'existsSync').mockReturnValueOnce(false) - - // Act - const result = wwebGetChromeExecutablePath() - - // Assert - expect(result).toBe(mockWindowsChromePath) - }) - - test('should return the correct Chrome executable path for macOS', () => { - // Arrange - jest.spyOn(os, 'platform').mockReturnValue('darwin') - - // Act - const result = wwebGetChromeExecutablePath() - - // Assert - expect(result).toBe('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') - }) - - test('should return the correct Chrome executable path for Linux', () => { - // Arrange - jest.spyOn(os, 'platform').mockReturnValue('linux') - - // Act - const result = wwebGetChromeExecutablePath() - - // Assert - expect(result).toBe('/usr/bin/google-chrome') - }) - - test('should handle unknown platform and return null', () => { - // Arrange - jest.spyOn(os, 'platform').mockReturnValue('some_unknown_platform' as any) - const consoleErrorSpy = jest.spyOn(console, 'error') - - // Act - const result = wwebGetChromeExecutablePath() - - // Assert - expect(result).toBeNull() - expect(consoleErrorSpy).toHaveBeenCalledWith('Could not find browser.') - }) -}) - -describe('wwebDownloadMedia', () => { - test('should download media from a URL using http', () => { - // Arrange - const url = 'http://example.com/media.jpg' - const mockWriteStream = { - on: jest.fn((event: string, cb: () => void) => { - if (event === 'finish') { - cb() - } - }), - close: jest.fn(), - } - ;(createWriteStream as jest.Mock).mockReturnValue(mockWriteStream) - // Act - wwebDownloadMedia(url).then((downloadedPath) => { - // Assert - expect(typeof downloadedPath).toBe('string') - expect(downloadedPath).toContain('tmp-') - }) - }) - - test('should download media from a URL using http error', () => { - // Arrange - const url = 'http://example.com/media.jpg' - const mockWriteStream = { - on: jest.fn((event: string, cb: () => void) => { - if (event === 'error') { - cb() - } - }), - close: jest.fn(), - } - ;(createWriteStream as jest.Mock).mockReturnValue(mockWriteStream) - const consoleErrorSpy = jest.spyOn(console, 'error') - // Act - wwebDownloadMedia(url).catch(() => expect(consoleErrorSpy).toHaveBeenCalled()) - }) -}) diff --git a/packages/provider-web-whatsapp/config/api-extractor.json b/packages/provider-web-whatsapp/config/api-extractor.json deleted file mode 100644 index 3199a2fac..000000000 --- a/packages/provider-web-whatsapp/config/api-extractor.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - - "mainEntryPointFilePath": "/dist/index.d.ts", - - "apiReport": { - "enabled": true, - "reportFileName": "api.md", - "reportFolder": "/" - }, - - "docModel": { - "enabled": true - }, - - "dtsRollup": { - "enabled": true - }, - - "messages": { - "compilerMessageReporting": { - "default": { - "logLevel": "warning" - } - }, - - "extractorMessageReporting": { - "default": { - "logLevel": "warning" - } - }, - - "tsdocMessageReporting": { - "default": { - "logLevel": "warning" - } - } - } -} diff --git a/packages/provider-web-whatsapp/jest.config.ts b/packages/provider-web-whatsapp/jest.config.ts deleted file mode 100644 index ca2c6a76f..000000000 --- a/packages/provider-web-whatsapp/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, -} - -export default config diff --git a/packages/provider-web-whatsapp/package.json b/packages/provider-web-whatsapp/package.json deleted file mode 100644 index d181201e3..000000000 --- a/packages/provider-web-whatsapp/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@builderbot/provider-web-whatsapp", - "version": "1.4.2-alpha.11", - "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "directories": { - "lib": "dist", - "test": "__tests__" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@jest/globals": "^30.2.0", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/body-parser": "^1.19.5", - "@types/cors": "^2.8.17", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "cors": "^2.8.5", - "mime-types": "^3.0.2", - "pino": "^10.1.0", - "qr-image": "^3.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1" - }, - "dependencies": { - "body-parser": "^2.2.1", - "polka": "^0.5.2", - "sharp": "0.33.3", - "whatsapp-web.js": "~1.34.2" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-web-whatsapp/rollup.config.js b/packages/provider-web-whatsapp/rollup.config.js deleted file mode 100644 index 24ef2b8a4..000000000 --- a/packages/provider-web-whatsapp/rollup.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import json from '@rollup/plugin-json' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - json(), - commonjs(), - nodeResolve({ - resolveOnly: (module) => !/ffmpeg|whatsapp-web.js|@builderbot\/bot|sharp/i.test(module), - }), - typescript(), - ], -} diff --git a/packages/provider-web-whatsapp/src/index.ts b/packages/provider-web-whatsapp/src/index.ts deleted file mode 100644 index a605d80f8..000000000 --- a/packages/provider-web-whatsapp/src/index.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { ProviderClass, utils } from '@builderbot/bot' -import type { BotContext, GlobalVendorArgs, SendOptions } from '@builderbot/bot/dist/types' -import { createReadStream, readFileSync } from 'fs' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import { tmpdir } from 'os' -import { basename, join, resolve } from 'path' -import type { Middleware } from 'polka' -import type WAWebJS from 'whatsapp-web.js' -import { Client, LocalAuth, MessageMedia, Buttons } from 'whatsapp-web.js' - -import { - wwebCleanNumber, - wwebDeleteTokens, - wwebGenerateImage, - wwebGetChromeExecutablePath, - wwebIsValidNumber, -} from './utils' - -/** - * ⚙️ WebWhatsappProvider: Es una clase tipo adaptor - * que extiende clases de ProviderClass (la cual es como interfaz para sber que funciones rqueridas) - * https://github.com/pedroslopez/whatsapp-web.js - */ -class WebWhatsappProvider extends ProviderClass { - globalVendorArgs: GlobalVendorArgs = { name: `bot`, gifPlayback: false, port: 3000, writeMyself: 'none' } - vendor: Client - constructor(args: { name: string; gifPlayback: boolean }) { - super() - this.globalVendorArgs = { ...this.globalVendorArgs, ...args } - } - - private generateFileName = (extension: string): string => `file-${Date.now()}.${extension}` - - protected initVendor(): Promise { - const NAME_DIR_SESSION = `${this.globalVendorArgs.name}_sessions` - - this.vendor = new Client({ - puppeteer: { - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--unhandled-rejections=strict'], - executablePath: wwebGetChromeExecutablePath(), - }, - ...this.globalVendorArgs, - authStrategy: new LocalAuth({ - clientId: NAME_DIR_SESSION, - }), - }) - - this.vendor.initialize().catch((e) => { - console.log(e) - this.emit('auth_failure', { - instructions: [`An error occurred during Venom initialization`, `trying again in 5 seconds...`], - }) - wwebDeleteTokens(NAME_DIR_SESSION) - setTimeout(async () => { - console.clear() - await this.initVendor() - }, 5000) - }) - - return Promise.resolve(this.vendor) - } - - protected beforeHttpServerInit(): void { - this.server = this.server - .use((req, _, next) => { - req['globalVendorArgs'] = this.globalVendorArgs - return next() - }) - .get('/', this.indexHome) - } - - public indexHome: Middleware = (req, res) => { - const botName = req[this.idBotName] - const qrPath = join(process.cwd(), `${botName}.qr.png`) - const fileStream = createReadStream(qrPath) - res.writeHead(200, { 'Content-Type': 'image/png' }) - fileStream.pipe(res) - } - - protected afterHttpServerInit(): void { - this.emit('notice', { - title: '⏱️ Loading... ', - instructions: [`this process can take up to 90 seconds`, `we will let you know shortly`], - }) - } - - async saveFile(ctx: BotContext, options?: { path: string }): Promise { - const fileData: WAWebJS.MessageMedia = ctx.fileData - const extension = mime.extension(fileData.mimetype) as string - const fileName = this.generateFileName(extension) - const pathFile = join(options?.path ?? tmpdir(), fileName) - const buffer = Buffer.from(fileData.data, 'base64') - await writeFile(pathFile, buffer) - return resolve(pathFile) - } - - /** - * Mapeamos los eventos nativos de whatsapp-web.js a los que la clase Provider espera - * para tener un standar de eventos - * @returns - */ - busEvents = () => [ - { - event: 'auth_failure', - func: (payload: any) => this.emit('auth_failure', payload), - }, - { - event: 'qr', - func: async (qr: string) => { - this.emit('require_action', { - title: '⚡⚡ ACTION REQUIRED ⚡⚡', - instructions: [ - `You must scan the QR Code`, - `Remember that the QR code updates every minute`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ], - payload: { qr }, - }) - await wwebGenerateImage(qr, `${this.globalVendorArgs.name}.qr.png`) - }, - }, - { - event: 'ready', - func: () => { - const host = { ...this.vendor?.info?.wid, phone: this.vendor?.info?.wid?.user } - this.emit('ready', true) - this.emit('host', host) - }, - }, - { - event: 'message', - func: async ( - payload: WAWebJS.Message & { - _data: { lng?: string; lat?: string; type?: string } - [key: string]: any - name: string - } - ) => { - if (payload.from === 'status@broadcast') { - return - } - - if (!wwebIsValidNumber(payload.from)) { - return - } - payload.from = wwebCleanNumber(payload.from, true) - payload.name = `${payload?.author}` - - if (payload?.hasMedia) { - const media = await payload.downloadMedia() - payload.fileData = media - } - - if (payload?._data?.lat && payload?._data?.lng) { - payload = { ...payload, body: utils.generateRefProvider('_event_location_') } - } - - if (payload._data.hasOwnProperty('type') && ['image', 'video'].includes(payload._data.type)) { - payload = { ...payload, body: utils.generateRefProvider('_event_media_') } - } - - if (payload._data.hasOwnProperty('type') && ['document'].includes(payload._data.type)) { - payload = { ...payload, body: utils.generateRefProvider('_event_document_') } - } - - if (payload._data.hasOwnProperty('type') && ['ptt'].includes(payload._data.type)) { - payload = { ...payload, body: utils.generateRefProvider('_event_voice_note_') } - } - - this.emit('message', payload) - }, - }, - ] - - /** - * @deprecated Buttons are not available in this provider, please use sendButtons instead - * @private - * @private - * @param {*} number - * @param {*} message - * @param {*} buttons [] - * @returns - */ - sendButtons = async (number: string, message: any, buttons: any = []) => { - this.emit('notice', { - title: 'DEPRECATED', - instructions: [ - `Currently sending buttons is not available with this provider`, - `this function is available with Meta or Twilio`, - ], - }) - const buttonMessage = new Buttons(message, buttons, '', '') - return this.vendor.sendMessage(number, buttonMessage) - } - - /** - * Enviar lista - * https://docs.wwebjs.dev/List.html - * @private - * @alpha No funciona en whatsapp bussines - * @param {*} number - * @param {*} message - * @param {*} buttons [] - * @returns - */ - // sendList = async (number, message, listInput = []) => { - // let sections = [ - // { - // title: 'sectionTitle', - // rows: [ - // { title: 'ListItem1', description: 'desc' }, - // { title: 'ListItem2' }, - // ], - // }, - // ] - // let list = new List('List body', 'btnText', sections, 'Title', 'footer') - // return this.vendor.sendMessage(number, list) - // } - - /** - * Enviar un mensaje solo texto - * https://docs.wwebjs.dev/Message.html - * @private - * @param {*} number - * @param {*} message - * @returns - */ - sendText = async (number: string, message: WAWebJS.MessageContent) => { - return this.vendor.sendMessage(number, message) - } - - /** - * Enviar imagen - * @param {*} number - * @param {*} imageUrl - * @param {*} text - * @returns - */ - sendImage = async (number: string, filePath: string, caption: string) => { - const base64 = readFileSync(filePath, { encoding: 'base64' }) - const mimeType = mime.lookup(filePath) - const media = new MessageMedia(`${mimeType}`, base64) - return this.vendor.sendMessage(number, media, { caption }) - } - - /** - * Enviar audio - * @param {*} number - * @param {*} imageUrl - * @param {*} text - * @returns - */ - - sendAudio = async (number: string, filePath: string, caption: string) => { - const base64 = readFileSync(filePath, { encoding: 'base64' }) - const mimeType = mime.lookup(filePath) - const media = new MessageMedia(`${mimeType}`, base64) - return this.vendor.sendMessage(number, media, { caption }) - } - - /** - * Enviar video - * @param {*} number - * @param {*} imageUrl - * @param {*} text - * @returns - */ - sendVideo = async (number: string, filePath: string, caption: string) => { - const base64 = readFileSync(filePath, { encoding: 'base64' }) - const mimeType = mime.lookup(filePath) - const media = new MessageMedia(`${mimeType}`, base64) - return this.vendor.sendMessage(number, media, { - sendMediaAsDocument: false, - caption, - }) - } - - /** - * Enviar Arhivos/pdf - * @param {*} number - * @param {*} imageUrl - * @param {*} text - * @returns - */ - sendFile = async (number: string, filePath: string, caption: string) => { - const base64 = readFileSync(filePath, { encoding: 'base64' }) - const mimeType = mime.lookup(filePath) - const filename = basename(filePath) - const media = new MessageMedia(`${mimeType}`, base64, filename) - return this.vendor.sendMessage(number, media, { caption }) - } - - /** - * Enviar imagen o multimedia - * @param {*} number - * @param {*} mediaInput - * @param {*} message - * @returns - */ - sendMedia = async (number: string, mediaUrl: string, text: string) => { - const fileDownloaded = await utils.generalDownload(mediaUrl) - const mimeType = mime.lookup(fileDownloaded) - - if (`${mimeType}`.includes('image')) return this.sendImage(number, fileDownloaded, text) - if (`${mimeType}`.includes('video')) return this.sendVideo(number, fileDownloaded, text) - if (`${mimeType}`.includes('audio')) { - const fileOpus = await utils.convertAudio(fileDownloaded) - return this.sendAudio(number, fileOpus, text) - } - - return this.sendFile(number, fileDownloaded, text) - } - - /** - * Funcion SendRaw envia opciones directamente del proveedor - * @param {string} number - * @param {string} message - * @example await sendMessage('+XXXXXXXXXXX', 'Hello World') - */ - - sendRaw = () => this.vendor.sendMessage - /** - * - * @param {*} userId - * @param {*} message - * @param {*} param2 - * @returns - */ - sendMessage = async (number: string, message: string, options?: SendOptions): Promise => { - options = { ...options, ...options['options'] } - number = wwebCleanNumber(number) - if (options?.buttons?.length) return this.sendButtons(number, message, options.buttons) - if (options?.media) return this.sendMedia(number, options.media, message) - return this.sendText(number, message) - } -} - -export { WebWhatsappProvider } diff --git a/packages/provider-web-whatsapp/src/types.ts b/packages/provider-web-whatsapp/src/types.ts deleted file mode 100644 index a760c360e..000000000 --- a/packages/provider-web-whatsapp/src/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { ProviderClass } from '@builderbot/bot' - -export type BotCtxMiddleware = Partial diff --git a/packages/provider-web-whatsapp/src/utils.ts b/packages/provider-web-whatsapp/src/utils.ts deleted file mode 100644 index f5729ee41..000000000 --- a/packages/provider-web-whatsapp/src/utils.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { utils } from '@builderbot/bot' -import { createWriteStream, existsSync } from 'fs' -import { emptyDir } from 'fs-extra' -import * as http from 'http' -import * as https from 'https' -import { tmpdir, platform } from 'os' -import { join } from 'path' -import * as qr from 'qr-image' - -const emptyDirSessions = async (pathBase: string) => - new Promise((resolve, reject) => { - emptyDir(pathBase, (err) => { - if (err) reject(err) - resolve(true) - }) - }) - -const wwebGetChromeExecutablePath = () => { - const myPlatform = platform() - switch (myPlatform) { - case 'win32': - return wwebGetWindowsChromeExecutablePath() - case 'darwin': - return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' - case 'linux': - return '/usr/bin/google-chrome' - default: - console.error('Could not find browser.') - return null - } -} - -const wwebGetWindowsChromeExecutablePath = () => { - const programFilesPath = process.env.ProgramFiles || '' - const programFiles86Path = process.env['ProgramFiles(x86)'] || '' - const pathProgramFiles86Path = join(programFiles86Path, 'Google', 'Chrome', 'Application', 'chrome.exe') - const pathProgramFiles64Path = join(programFilesPath, 'Google', 'Chrome', 'Application', 'chrome.exe') - - if (existsSync(pathProgramFiles86Path)) return pathProgramFiles86Path - if (existsSync(pathProgramFiles64Path)) return pathProgramFiles64Path - return '' -} - -const wwebCleanNumber = (number: string, full: boolean = false): string => { - number = number.replace('@c.us', '').replace('+', '').replace(/\s/g, '') - number = !full ? `${number}@c.us` : `${number}` - return number -} - -const wwebGenerateImage = async (base64: string, name: string = 'qr.png'): Promise => { - const PATH_QR = `${process.cwd()}/${name}` - const qr_svg = qr.image(base64, { type: 'png', margin: 4 }) - - const writeFilePromise = (): Promise => - new Promise((resolve, reject) => { - const file = qr_svg.pipe(createWriteStream(PATH_QR)) - file.on('finish', () => resolve(true)) - file.on('error', reject) - }) - - await writeFilePromise() - await utils.cleanImage(PATH_QR) -} - -const wwebDeleteTokens = (session: string) => { - try { - const pathTokens = join(process.cwd(), session) - emptyDirSessions(pathTokens) - console.log('Tokens clean..') - } catch (e) { - return - } -} - -const wwebIsValidNumber = (rawNumber: string): boolean => { - const regexGroup = /\@g.us\b/gm - const exist = rawNumber.match(regexGroup) - return !exist -} - -const wwebDownloadMedia = async (url: string): Promise => { - return new Promise((resolve, reject) => { - const ext = url.split('.').pop() || 'unknown' - const checkProtocol = url.startsWith('https:') - const handleHttp = checkProtocol ? https : http - const name = `tmp-${Date.now()}.${ext}` - const fullPath = `${tmpdir()}/${name}` - const file = createWriteStream(fullPath) - - handleHttp.get(url, function (response) { - response.pipe(file) - file.on('finish', function () { - file.close() - resolve(fullPath) - }) - file.on('error', function (err) { - console.error('Error downloading media:', err) - file.close() - reject(err) - }) - }) - }) -} - -export { - wwebCleanNumber, - wwebGenerateImage, - wwebDeleteTokens, - wwebIsValidNumber, - wwebDownloadMedia, - wwebGetChromeExecutablePath, - emptyDirSessions, - wwebGetWindowsChromeExecutablePath, -} diff --git a/packages/provider-web-whatsapp/tsconfig.json b/packages/provider-web-whatsapp/tsconfig.json deleted file mode 100644 index bed0d7489..000000000 --- a/packages/provider-web-whatsapp/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": ["node"] - }, - "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "node_modules"] -} diff --git a/packages/provider-wppconnect/CHANGELOG.md b/packages/provider-wppconnect/CHANGELOG.md deleted file mode 100644 index 8a847aff4..000000000 --- a/packages/provider-wppconnect/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [0.1.0-alpha.18](https://github.com/codigoencasa/bot-whatsapp/compare/v0.1.0-alpha.0...v0.1.0-alpha.18) (2024-01-19) - -**Note:** Version bump only for package @builderbot/wppconnect diff --git a/packages/provider-wppconnect/LICENSE.md b/packages/provider-wppconnect/LICENSE.md deleted file mode 100644 index 959d8ec5a..000000000 --- a/packages/provider-wppconnect/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Leifer Mendez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/provider-wppconnect/README.md b/packages/provider-wppconnect/README.md deleted file mode 100644 index 90c1de1f2..000000000 --- a/packages/provider-wppconnect/README.md +++ /dev/null @@ -1,21 +0,0 @@ -

- -

@builderbot/provider-wppconnect

- -

- - -## Documentation - -Visit [builderbot](https://builderbot.app/) to view the full documentation. - - -## Official Course - -If you want to discover all the functions and features offered by the library you can take the course. -[View Course](https://app.codigoencasa.com/courses/builderbot?refCode=LEIFER) - - -## Contact Us -- [💻 Discord](https://link.codigoencasa.com/DISCORD) -- [👌 𝕏 (Twitter)](https://twitter.com/leifermendez) \ No newline at end of file diff --git a/packages/provider-wppconnect/__tests__/provider.test.ts b/packages/provider-wppconnect/__tests__/provider.test.ts deleted file mode 100644 index b470563c3..000000000 --- a/packages/provider-wppconnect/__tests__/provider.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { utils } from '@builderbot/bot' -import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import wppconnect from '@wppconnect-team/wppconnect' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import path from 'path' - -import { WPPConnectProvider } from '../src' - -const phoneNumber = '1234567890' - -jest.mock('fs/promises', () => ({ - writeFile: jest.fn(), -})) - -jest.mock('../src/utils', () => ({ - WppConnectValidNumber: jest.fn().mockImplementation(() => true), - WppConnectCleanNumber: jest.fn(), - WppConnectGenerateImage: jest.fn(), - WppDeleteTokens: jest.fn(), -})) - -jest.mock('@wppconnect-team/wppconnect', () => ({ - create: jest.fn(), - defaultLogger: { transports: [{ silent: true }] }, -})) - -jest.mock('@builderbot/bot') - -describe('#WPPConnectProvider', () => { - let wPPConnectProvider: WPPConnectProvider - let mockNext: any - let mockRes: any - let mockReq: any - - beforeEach(() => { - wPPConnectProvider = new WPPConnectProvider({ name: 'test-bot' }) - mockNext = jest.fn() - mockReq = {} - mockRes = { - writeHead: jest.fn(), - end: jest.fn(), - pipe: jest.fn(), - } - }) - - describe('#saveFile', () => { - test('Save file successfully', async () => { - // Arrange - const mockedDecryptFile = jest.fn().mockImplementation(() => Buffer.from('fileContent')) - const ctx = { mimetype: 'image/png' } - const options = { path: '/tmp' } - const expectedFilePath = '/tmp/some-file-name.png' - - wPPConnectProvider.vendor = { - decryptFile: mockedDecryptFile, - } as any - jest.spyOn(path, 'join').mockImplementation(() => expectedFilePath) - // Act - const result = await wPPConnectProvider.saveFile(ctx, options) - - // Assert - expect(result).toContain('some-file-name.png') - expect(mockedDecryptFile).toHaveBeenCalledWith(ctx) - expect(writeFile).toHaveBeenCalledWith(expectedFilePath, Buffer.from('fileContent')) - mockedDecryptFile.mockReset() - }) - }) - - describe('#sendMessage', () => { - test('Send text message', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Hello, world!' - const options = {} - const mockSendText = jest.fn().mockImplementation(() => 'Text message sent') - wPPConnectProvider.vendor = { - sendText: mockSendText, - } as any - jest.spyOn(wPPConnectProvider, 'sendButtons') - jest.spyOn(wPPConnectProvider, 'sendMedia') - // Act - await wPPConnectProvider.sendMessage(fakeRecipient, fakeMessage, options) - - // Assert - expect(mockSendText).toHaveBeenCalledWith(fakeRecipient, fakeMessage) - expect(wPPConnectProvider.sendButtons).not.toHaveBeenCalled() - expect(wPPConnectProvider.sendMedia).not.toHaveBeenCalled() - }) - - test('should parse the recipient number and send message with buttons', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Choose an option:' - const fakeButtons = [{ body: 'Option 1' }, { body: 'Option 2' }] - const fakeOptions = { buttons: fakeButtons } - jest.spyOn(wPPConnectProvider, 'sendButtons').mockImplementation(() => true as any) - jest.spyOn(wPPConnectProvider, 'sendMedia') - - // Act - await wPPConnectProvider.sendMessage(fakeRecipient, fakeMessage, fakeOptions) - - // Assert - expect(wPPConnectProvider.sendButtons).toHaveBeenCalledWith(fakeRecipient, fakeMessage, fakeButtons) - expect(wPPConnectProvider.sendMedia).not.toHaveBeenCalled() - }) - - test('should parse the recipient number and send media message', async () => { - // Arrange - const fakeRecipient = '1234567890' - const fakeMessage = 'Here is a media file' - const fakeMedia = 'path/to/media.jpg' - const fakeOptions = { media: fakeMedia } - jest.spyOn(wPPConnectProvider, 'sendButtons') - jest.spyOn(wPPConnectProvider, 'sendMedia').mockResolvedValue(() => true) - - // Act - await wPPConnectProvider.sendMessage(fakeRecipient, fakeMessage, fakeOptions) - - // Assert - expect(wPPConnectProvider.sendMedia).toHaveBeenCalledWith(fakeRecipient, fakeMedia, fakeMessage) - expect(wPPConnectProvider.sendButtons).not.toHaveBeenCalled() - }) - }) - - describe('#sendMedia', () => { - test('should send image when provided with image URL', async () => { - // Arrange - const number = '+123456789' - const imageUrl = 'https://example.com/image.jpg' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/image.jpg' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('image/jpeg') - const sendImageSpy = jest.spyOn(wPPConnectProvider, 'sendImage').mockImplementation(async () => true as any) - - // Act - await wPPConnectProvider.sendMedia(number, imageUrl, text) - - // Assert - expect(sendImageSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(imageUrl) - }) - - test('should send video when provided with video URL', async () => { - // Arrange - const number = '+123456789' - const videoUrl = 'https://example.com/video.mp4' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/audio.mp3' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('video/mp4') - const sendVideoSpy = jest.spyOn(wPPConnectProvider, 'sendVideo').mockImplementation(async () => true as any) - - // Act - await wPPConnectProvider.sendMedia(number, videoUrl, text) - - // Assert - expect(sendVideoSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(videoUrl) - }) - - test('should send audio when provided with audio URL', async () => { - // Arrange - const number = '+123456789' - const audioUrl = 'https://example.com/audio.mp3' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/audio.mp3' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('audio/mp3') - const sendAudioSpy = jest.spyOn(wPPConnectProvider, 'sendPtt').mockImplementation(async () => undefined) - // Act - await wPPConnectProvider.sendMedia(number, audioUrl, text) - - // Assert - expect(sendAudioSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(audioUrl) - }) - - test('should send file when provided with file URL', async () => { - // Arrange - const number = '+123456789' - const fileUrl = 'https://example.com/test.pdf' - const text = 'Hello World' - const fileDownloaded = 'path/to/downloaded/test.pdf' - ;(utils.generalDownload as jest.MockedFunction).mockResolvedValue( - fileDownloaded - ) - jest.spyOn(mime, 'lookup').mockReturnValue('text/plain') - const sendFileSpy = jest.spyOn(wPPConnectProvider, 'sendFile').mockImplementation(async () => undefined) - // Act - await wPPConnectProvider.sendMedia(number, fileUrl, text) - - // Assert - expect(sendFileSpy).toHaveBeenCalled() - expect(utils.generalDownload).toHaveBeenCalledWith(fileUrl) - }) - }) - - describe('#sendVideo', () => { - test('Send video as GIF', async () => { - // Arrange - wPPConnectProvider.vendor = { - sendVideoAsGif: jest.fn().mockImplementation(() => 'Video sent'), - } as any - const number = '+123456789' - const filePath = '/path/to/video.mp4' - const text = 'Check out this video' - // Act - const result = await wPPConnectProvider.sendVideo(number, filePath, text) - - // Assert - expect(result).toEqual('Video sent') - expect(wPPConnectProvider.vendor.sendVideoAsGif).toHaveBeenCalledWith(number, filePath, 'video.gif', text) - }) - }) - - describe('#sendFile', () => { - test('Send file successfully', async () => { - // Arrange - wPPConnectProvider.vendor = { - sendFile: jest.fn().mockImplementation(() => 'File sent'), - } as any - const number = '+123456789' - const filePath = '/path/to/file.txt' - const text = 'Check out this file' - jest.spyOn(path, 'basename').mockImplementation(() => filePath) - - // Act - const result = await wPPConnectProvider.sendFile(number, filePath, text) - - // Assert - expect(result).toEqual('File sent') - expect(wPPConnectProvider.vendor.sendFile).toHaveBeenCalledWith(number, filePath, filePath, text) - }) - }) - - describe('#sendImage', () => { - test('Send image successfully', async () => { - // Arrange - wPPConnectProvider.vendor = { - sendImage: jest.fn().mockImplementation(() => 'Image sent'), - } as any - const number = '+123456789' - const filePath = '/path/to/image.png' - const text = 'Check out this image' - jest.spyOn(path, 'basename').mockImplementation(() => filePath) - - // Act - const result = await wPPConnectProvider.sendImage(number, filePath, text) - - // Assert - expect(result).toEqual('Image sent') - expect(wPPConnectProvider.vendor.sendImage).toHaveBeenCalledWith(number, filePath, 'image-name', text) - }) - }) - describe('#sendPtt', () => { - test('should call vendor.sendPtt with correct arguments', async () => { - // Arrange - wPPConnectProvider.vendor = { - sendPtt: jest.fn().mockImplementation(() => 'Image sent'), - } as any - const number = '+123456789' - const filePath = '/path/to/image.png' - jest.spyOn(path, 'basename').mockImplementation(() => filePath) - - // Act - const result = await wPPConnectProvider.sendPtt(number, filePath) - - // Assert - expect(result).toEqual('Image sent') - expect(wPPConnectProvider.vendor.sendPtt).toHaveBeenCalledWith(number, filePath) - }) - }) - - describe('sendPoll', () => { - test('should return false if poll options length is less than 2', async () => { - // Arrange - wPPConnectProvider.vendor = { - sendPollMessage: jest.fn().mockImplementation(() => 'Image sent'), - } as any - const number = '123456789' - const text = 'Sample poll text' - const poll = { - options: ['Option 1'], - multiselect: false, - } - - // Act - const result = await wPPConnectProvider.sendPoll(number, text, poll) - - // Assert - expect(result).toBe(false) - expect(wPPConnectProvider.vendor.sendPollMessage).not.toHaveBeenCalled() - }) - - test('should call vendor.sendPollMessage with correct arguments', async () => { - // Arrange - const number = '123456789' - const text = 'Sample poll text' - const poll = { - options: ['Option 1', 'Option 2'], - multiselect: true, - } - wPPConnectProvider.vendor = { - sendPollMessage: jest.fn().mockImplementation(() => 'sent'), - } as any - - // Act - const result = await wPPConnectProvider.sendPoll(number, text, poll) - - // Assert - expect(result).toEqual('sent') - expect(wPPConnectProvider.vendor.sendPollMessage).toHaveBeenCalledWith(number, text, poll.options, { - selectableCount: 1, - }) - }) - }) - - describe('#sendButtons', () => { - test('should emit notice event with correct details', async () => { - // Arrange - const number = '123456789' - const text = 'Button message' - const buttons = [{ body: 'Button 1' }, { body: 'Button 2' }] - - const mockEmit = jest.fn() - wPPConnectProvider.emit = mockEmit - wPPConnectProvider.vendor = { - sendText: jest.fn().mockImplementation(() => 'sent'), - } as any - // Act - await wPPConnectProvider.sendButtons(number, text, buttons) - - // Assert - expect(mockEmit).toHaveBeenCalledWith('notice', { - title: 'DEPRECATED', - instructions: [ - 'Currently sending buttons is not available with this provider', - 'this function is available with Meta or Twilio', - ], - }) - }) - - test('should send button message with correct details', async () => { - // Arrange - const number = '123456789' - const text = 'Button message' - const buttons = [{ body: 'Button 1' }, { body: 'Button 2' }] - - // Mock del método sendMessage - wPPConnectProvider.vendor = { - sendText: jest.fn().mockImplementation(() => 'success'), - } as any - - // Act - const result = await wPPConnectProvider.sendButtons(number, text, buttons) - - // Assert - expect(result).toEqual('success') - expect(wPPConnectProvider.vendor.sendText).toHaveBeenCalled() - }) - }) - - describe('#beforeHttpServerInit', () => { - test('beforeHttpServerInit - you should configure middleware to handle HTTP requests', () => { - // Arrange - const mockUse = jest.fn().mockReturnThis() - const mockGet = jest.fn() - - const mockPolka = jest.fn(() => ({ - use: mockUse, - get: mockGet, - })) - - wPPConnectProvider.server = mockPolka() as any - // Act - wPPConnectProvider['beforeHttpServerInit']() - - // Assert - expect(mockUse).toHaveBeenCalled() - const middleware = mockUse.mock.calls[0][0] as any - expect(middleware).toBeInstanceOf(Function) - middleware(mockReq, mockRes, mockNext) - expect(mockReq.globalVendorArgs).toBe(wPPConnectProvider.globalVendorArgs) - expect(mockGet).toHaveBeenCalledWith('/', wPPConnectProvider.indexHome) - }) - }) - - describe('#indexHome', () => { - test('should send the correct image file', () => { - // Arrange - const mockedReadStream = jest.fn() - const mockedFileStream = { pipe: jest.fn() } - mockedReadStream.mockReturnValueOnce(mockedFileStream) - require('fs').createReadStream = mockedReadStream - const req = { params: { idBotName: 'bot123' } } - const res = { writeHead: jest.fn(), end: jest.fn() } - const expectedImagePath = 'ruta/esperada/bot123.qr.png' - const mockedJoin = jest.spyOn(path, 'join') - mockedJoin.mockReturnValueOnce(expectedImagePath) - - // Act - wPPConnectProvider['indexHome'](req as any, res as any, mockNext) - // Assert - expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'image/png' }) - }) - }) - - describe('#listenOnEvents', () => { - test('Assign events correctly', () => { - // Arrange - const mockVendor = { - onMessage: jest.fn(), - onIncomingCall: jest.fn(), - } as any - wPPConnectProvider.vendor = mockVendor - const mockEvents = [{ event: 'onMessage', func: jest.fn() }] - wPPConnectProvider.busEvents = jest.fn().mockReturnValue(mockEvents) as any - - // Act - wPPConnectProvider['listenOnEvents'](mockVendor) - - // Assert - mockEvents.forEach(({ event }) => { - if (mockVendor[event]) { - expect(mockVendor[event]).toHaveBeenCalled() - mockVendor[event]({ from: 'sender@example.com', name: 'Sender Name' }) - } - }) - }) - - test('Throw error if vendor is empty', () => { - // Arrange - wPPConnectProvider.vendor = null as any - - // Act & Assert - expect(() => wPPConnectProvider['listenOnEvents'](null as any)).toThrow('Vendor should not return empty') - }) - - test('Set vendor when not defined', () => { - // Arrange - const mockVendor = { - onMessage: jest.fn(), - } as any - - // Act - wPPConnectProvider['listenOnEvents'](mockVendor) - - // Assert - expect(wPPConnectProvider.vendor).toEqual(mockVendor) - }) - }) - - describe('#busEvents', () => { - test('Should return undefine if the from status@broadcast', () => { - // Arrange - const message: any = { - from: 'status@broadcast', - } - // Act - const resul = wPPConnectProvider['busEvents']()[0].func(message) - - // Assert - expect(resul).toBeUndefined() - }) - - test('Should return undefine if the from status@broadcast', () => { - // Arrange - const message: any = { - from: phoneNumber, - } - ;(require('../src/utils').WppConnectValidNumber as jest.Mock).mockImplementation(() => false) - // Act - const resul = wPPConnectProvider['busEvents']()[0].func(message) - - // Assert - expect(resul).toBeUndefined() - }) - - test('Set body property for image or video type', () => { - // Arrange - const message: any = { - type: 'image', - from: phoneNumber, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - wPPConnectProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_voice_note_test' - ;(require('../src/utils').WppConnectValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - wPPConnectProvider['busEvents']()[0].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_media_') - }) - - test('Set body property for document type', () => { - // Arrange - const message: any = { - type: 'document', - from: phoneNumber, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - wPPConnectProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_document_test' - ;(require('../src/utils').WppConnectValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - wPPConnectProvider['busEvents']()[0].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_document_') - }) - - test('Set body property for ptt type', () => { - // Arrange - const message: any = { - type: 'ptt', - from: phoneNumber, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - wPPConnectProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_voice_note_test' - ;(require('../src/utils').WppConnectValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - wPPConnectProvider['busEvents']()[0].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_voice_note_') - }) - - test('Set body property for lat and lng type', () => { - // Arrange - const message: any = { - lat: '1224', - lng: '1224', - from: phoneNumber, - } - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - wPPConnectProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - const refProvider = '_event_location_test' - ;(require('../src/utils').WppConnectValidNumber as jest.Mock).mockImplementation(() => true) - ;(utils.generateRefProvider as jest.MockedFunction).mockImplementation( - () => refProvider - ) - // Act - wPPConnectProvider['busEvents']()[0].func(message) - - // Assert - expect(mockEmit).toHaveBeenCalled() - expect(utils.generateRefProvider).toHaveBeenCalledWith('_event_location_') - }) - - test('should modify payload correctly and emit "message" event', async () => { - // Arrange - const payload: any = { - selectedOptions: [{ name: 'Option 1' }], - msgId: { _serialized: '12345' }, - chatId: 'chat123', - sender: '+123456789', - } - const payloadExpected = { - body: 'Option 1', - chatId: 'chat123', - from: undefined, - id: '12345', - msgId: { _serialized: '12345' }, - notifyName: 'John Doe', - selectedOptions: [{ name: 'Option 1' }], - sender: { pushname: 'John Doe' }, - t: undefined, - to: '+123456789', - type: 'poll', - } - - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - wPPConnectProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - wPPConnectProvider.vendor = { - getContact: jest.fn().mockImplementation(() => ({ pushname: 'John Doe' })), - } as any - - // Act - await wPPConnectProvider['busEvents']()[1].func(payload) - - // Assert - expect(mockEmit).toHaveBeenCalledWith('message', payloadExpected) - }) - }) - - describe('#initVendor', () => { - test('should initialize the vendor successfully', async () => { - // Arrange - const mockHostDevice = { id: { user: 'mockUserId' }, pushname: 'mockPushName' } - ;(wppconnect.create as jest.Mock).mockImplementationOnce((_, qrCallback, statusCallback, options) => { - return Promise.resolve({ - getWid: async () => mockHostDevice, - onIncomingCall: jest.fn(), - } as any) - }) - - // Act - await wPPConnectProvider['initVendor']() - - // Assert - expect(wppconnect.create).toHaveBeenCalled() - expect(wPPConnectProvider.vendor).toBeDefined() - }) - }) - - describe('#afterHttpServerInit ', () => { - test(' emits a notice event with the correct data', () => { - // Arrange - const mockEmit = jest.fn() - const mockEventEmitter = { - emit: mockEmit, - } - wPPConnectProvider.emit = (mockEventEmitter as any).emit.bind(mockEventEmitter) - // Act - wPPConnectProvider['afterHttpServerInit']() - - // Assert - expect(mockEventEmitter.emit).toHaveBeenCalledWith('notice', { - title: '⏱️ Loading... ', - instructions: [`this process can take up to 90 seconds`, `we will let you know shortly`], - }) - }) - }) -}) diff --git a/packages/provider-wppconnect/__tests__/utils.test.ts b/packages/provider-wppconnect/__tests__/utils.test.ts deleted file mode 100644 index 15c037439..000000000 --- a/packages/provider-wppconnect/__tests__/utils.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { utils } from '@builderbot/bot' -import { describe, expect, jest, test } from '@jest/globals' -import fsExtra from 'fs-extra' - -import { - emptyDirSessions, - notMatches, - WppConnectCleanNumber, - WppConnectGenerateImage, - WppConnectValidNumber, - WppDeleteTokens, - writeFilePromise, -} from '../src/utils' - -jest.mock('qr-image', () => ({ - image: jest.fn(() => ({ - pipe: jest.fn(), - })), -})) - -jest.mock('fs-extra', () => ({ - emptyDir: jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(null)), -})) - -jest.mock('@builderbot/bot', () => ({ - utils: { - cleanImage: jest.fn(), - }, -})) - -jest.mock('fs', () => ({ - createWriteStream: jest.fn(), - existsSync: jest.fn(), - readdirSync: jest.fn(() => []), - unlinkSync: jest.fn(), - writeFile: jest.fn((path: string, data: any, options: any, callback: (err?: Error | null) => void) => { - callback(null) - }), -})) - -describe('#WppDeleteTokens', () => { - test('should delete tokens', () => { - // Mock - const mockEmptyDirSessions = jest.spyOn(fsExtra, 'emptyDir').mockImplementation(() => true) - // Act - WppDeleteTokens('session') - // Assert - expect(mockEmptyDirSessions).toHaveBeenCalled() - }) -}) - -describe('# const mockEmptyDir = ', () => { - test('should empty the directory correctly', async () => { - // Arrange - const pathBase = '/path/to/directory' - const mockEmptyDir = jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(null)) - - jest.spyOn(fsExtra, 'emptyDir').mockImplementation(mockEmptyDir as any) - - // Act - await emptyDirSessions(pathBase) - - // Assert - expect(mockEmptyDir).toHaveBeenCalledWith(pathBase, expect.any(Function)) - }) - - test('should handle errors when emptying the directory', async () => { - // Arrange - const pathBase = '/path/to/directory' - const error = new Error('Failed to empty directory') - const mockEmptyDir = jest.fn((_path: string, callback: (err?: Error | null) => void) => callback(error)) - - jest.spyOn(fsExtra, 'emptyDir').mockImplementation(mockEmptyDir as any) - - // Act & Assert - await expect(emptyDirSessions(pathBase)).rejects.toEqual(error) - }) -}) - -describe('#WppConnectValidNumber', () => { - test('should return true for a valid number', () => { - // Arrange - const validNumber = '123456789@c.us' - - // Act - const result = WppConnectValidNumber(validNumber) - - // Assert - expect(result).toBe(true) - }) - - test('should return false for an invalid number', () => { - // Arrange - const invalidNumber = '123456789@g.us' - - // Act - const result = WppConnectValidNumber(invalidNumber) - - // Assert - expect(result).toBe(false) - }) -}) - -describe('#notMatches', () => { - test('should return true for null matches', () => { - // Arrange - const nullMatches: RegExpMatchArray | null = null - - // Act - const result = notMatches(nullMatches) - - // Assert - expect(result).toBe(true) - }) - - test('should return true for matches with length not equal to 3', () => { - // Arrange - const invalidMatches: RegExpMatchArray | null = ['match1', 'match2'] - - // Act - const result = notMatches(invalidMatches) - - // Assert - expect(result).toBe(true) - }) - - test('should return false for matches with length equal to 3', () => { - // Arrange - const validMatches: RegExpMatchArray | null = ['match1', 'match2', 'match3'] - - // Act - const result = notMatches(validMatches) - - // Assert - expect(result).toBe(false) - }) -}) - -describe('#WppConnectCleanNumber', () => { - test('should clean number without @c.us and + when full is false', () => { - // Arrange - const number = '+123 456 789@c.us' - const full = false - - // Act - const result = WppConnectCleanNumber(number, full) - - // Assert - expect(result).toBe('123456789') - }) - - test('should clean number with @c.us and + when full is true', () => { - // Arrange - const number = '+123 456 789@c.us' - const full = true - - // Act - const result = WppConnectCleanNumber(number, full) - - // Assert - expect(result).toBe('123456789@c.us') - }) - - test('should clean number without @c.us and + when full is true', () => { - // Arrange - const number = '+123 456 789@c.us' - const full = true - - // Act - const result = WppConnectCleanNumber(number, full) - - // Assert - expect(result).toBe('123456789@c.us') - }) -}) - -describe('writeFilePromise', () => { - test('should resolve with true when writeFile is successful', () => { - // Arrange - const pathQr = 'testPath' - const response: any = { data: 'testData' } - // Act - writeFilePromise(pathQr, response).then((result) => { - // Assert - expect(result).toBe(true) - }) - }) - - test('should reject with error message when writeFile encounters an error', async () => { - // Arrange - const pathQr = 'testPath' - const response: any = { data: 'testData' } - require('fs').writeFile.mockImplementationOnce((path, data, options, callback) => { - callback('some error') - }) - - // Act & Assert - await expect(writeFilePromise(pathQr, response)).rejects.toEqual('ERROR_QR_GENERATE') - }) -}) - -describe('#WppConnectGenerateImage', () => { - test('should generate image correctly from base64 string', async () => { - // Arrange - const base64String = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABwklEQVQ4y6XTv0rDQBQEwO/rb7gR8sQoWg7R5Yls/4Cs2mj5wgd3GpNn8EiH7Yn+AB/h6q3hnvY+kPTrNzHgtdpR2kYiy2EiKquOYexOPzOZHzXg1XC3+vUaP2OOrLuk4F0p/Mz+AtJ+d6HVWz2dzd+h64n65njfSeL+wh5A1AEjsuEX6Wz+a5UwucZ9lRjJHlB0iUJ/BMo2APXM4l5jJ98yBDkWd/zmO93uzVlu0shhFbz9YjW9NhJp8iF0H2u9jnj9XXl96jDxtQntb7oPjbY8aJj5mDN7ZUOtz2Hl+lgezYrYVlmsZ/o0azWmrFXtI/Bi/lMxHkNvcJM9kwjIJKHQW0PqS2TgBK2b1DfSv9rl4e0j+/BzCmW2Qdo5t+Ik/cYAAAAASUVORK5CYII=' - const expectedFileName = 'test.png' - - // Act - await WppConnectGenerateImage(base64String, expectedFileName) - - // Assert - const fs = require('fs') - expect(utils.cleanImage).toHaveBeenCalled() - fs.unlinkSync(expectedFileName) - }) - - test('should throw error for invalid input string', async () => { - // Arrange - const invalidBase64String = 'invalid_base64_string' - const result = await WppConnectGenerateImage(invalidBase64String) - // Act & Assert - expect(result).toBeDefined() - }) -}) diff --git a/packages/provider-wppconnect/config/api-extractor.json b/packages/provider-wppconnect/config/api-extractor.json deleted file mode 100644 index 3199a2fac..000000000 --- a/packages/provider-wppconnect/config/api-extractor.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - - "mainEntryPointFilePath": "/dist/index.d.ts", - - "apiReport": { - "enabled": true, - "reportFileName": "api.md", - "reportFolder": "/" - }, - - "docModel": { - "enabled": true - }, - - "dtsRollup": { - "enabled": true - }, - - "messages": { - "compilerMessageReporting": { - "default": { - "logLevel": "warning" - } - }, - - "extractorMessageReporting": { - "default": { - "logLevel": "warning" - } - }, - - "tsdocMessageReporting": { - "default": { - "logLevel": "warning" - } - } - } -} diff --git a/packages/provider-wppconnect/jest.config.ts b/packages/provider-wppconnect/jest.config.ts deleted file mode 100644 index ca2c6a76f..000000000 --- a/packages/provider-wppconnect/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest' - -const config: Config = { - preset: 'ts-jest', - verbose: true, - cache: true, -} - -export default config diff --git a/packages/provider-wppconnect/package.json b/packages/provider-wppconnect/package.json deleted file mode 100644 index bb91a39e2..000000000 --- a/packages/provider-wppconnect/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "@builderbot/provider-wppconnect", - "version": "1.4.2-alpha.11", - "description": "Now I’m the model of a modern major general / The venerated Virginian veteran whose men are all / Lining up, to put me up on a pedestal / Writin’ letters to relatives / Embellishin’ my elegance and eloquence / But the elephant is in the room / The truth is in ya face when ya hear the British cannons go / BOOM", - "keywords": [], - "author": "Leifer Mendez ", - "license": "ISC", - "main": "dist/index.cjs", - "types": "dist/index.d.ts", - "type": "module", - "scripts": { - "build": "rimraf dist && rollup --config", - "test": "jest", - "test:coverage": "jest --coverage", - "test:watch": "jest --watchAll --coverage" - }, - "files": [ - "./dist/" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/codigoencasa/bot-whatsapp.git" - }, - "bugs": { - "url": "https://github.com/codigoencasa/bot-whatsapp/issues" - }, - "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme", - "devDependencies": { - "@builderbot/bot": "workspace:^", - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@types/body-parser": "^1.19.5", - "@types/cors": "^2.8.17", - "@types/mime-types": "^2.1.4", - "@types/node": "^24.10.2", - "@types/polka": "^0.5.7", - "@types/proxyquire": "^1.3.31", - "@types/qr-image": "^3.2.9", - "@types/sinon": "^17.0.3", - "cors": "^2.8.5", - "mime-types": "^3.0.2", - "pino": "^10.1.0", - "proxyquire": "^2.1.3", - "puppeteer-core": "^24.32.1", - "qr-image": "^3.2.0", - "rimraf": "^6.1.2", - "rollup-plugin-typescript2": "^0.36.0", - "sinon": "^17.0.1" - }, - "dependencies": { - "@jest/globals": "^30.2.0", - "@wppconnect-team/wppconnect": "~1.37.8", - "body-parser": "^2.2.1", - "polka": "^0.5.2", - "sharp": "0.33.5" - }, - "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" -} diff --git a/packages/provider-wppconnect/rollup.config.js b/packages/provider-wppconnect/rollup.config.js deleted file mode 100644 index 2ffbba2d1..000000000 --- a/packages/provider-wppconnect/rollup.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import typescript from 'rollup-plugin-typescript2' -import json from '@rollup/plugin-json' -import { nodeResolve } from '@rollup/plugin-node-resolve' -import commonjs from '@rollup/plugin-commonjs' -export default { - input: ['src/index.ts'], - output: [ - { - dir: 'dist', - entryFileNames: '[name].cjs', - format: 'cjs', - exports: 'named', - }, - ], - plugins: [ - json(), - nodeResolve({ - resolveOnly: (module) => !/ffmpeg|@wppconnect|@builderbot\/bot|sharp/i.test(module), - }), - commonjs(), - typescript(), - ], -} diff --git a/packages/provider-wppconnect/src/index.ts b/packages/provider-wppconnect/src/index.ts deleted file mode 100644 index 5a226cf9a..000000000 --- a/packages/provider-wppconnect/src/index.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { ProviderClass, utils } from '@builderbot/bot' -import type { BotContext, GlobalVendorArgs, SendOptions } from '@builderbot/bot/dist/types' -import type { Message, Whatsapp } from '@wppconnect-team/wppconnect' -import { create, defaultLogger } from '@wppconnect-team/wppconnect' -import { createReadStream } from 'fs' -import { writeFile } from 'fs/promises' -import mime from 'mime-types' -import { tmpdir } from 'os' -import { basename, join, resolve } from 'path' -import type { Middleware } from 'polka' - -import type { SaveFileOptions } from './types' -import { WppConnectGenerateImage, WppConnectValidNumber, WppConnectCleanNumber, WppDeleteTokens } from './utils' - -/** - * ⚙️ WppConnectProvider: Es una clase tipo adaptador - * que extiende la clase ProviderClass (la cual es como una interfaz para saber qué funciones son requeridas). - * https://github.com/wppconnect-team/wppconnect - */ -defaultLogger.transports.forEach((t) => (t.silent = true)) -class WPPConnectProvider extends ProviderClass { - protected afterHttpServerInit(): void { - this.emit('notice', { - title: '⏱️ Loading... ', - instructions: [`this process can take up to 90 seconds`, `we will let you know shortly`], - }) - } - - globalVendorArgs: GlobalVendorArgs = { name: 'bot', port: 3000, writeMyself: 'none' } - vendor: Whatsapp - - constructor(args: { name: string }) { - super() - this.globalVendorArgs = { ...this.globalVendorArgs, ...args } - } - - /** - * Iniciar WppConnect - */ - protected async initVendor(): Promise { - const NAME_DIR_SESSION = `${this.globalVendorArgs.name}_sessions` - - try { - const createInstance = await create({ - session: NAME_DIR_SESSION, - catchQR: (base64QRImg, _, attempt) => { - if (attempt == 5) throw new Error() - - this.emit('require_action', { - title: '⚡⚡ ACTION REQUIRED ⚡⚡', - instructions: [ - `You must scan the QR Code`, - `Remember that the QR code updates every minute`, - `Need help: https://link.codigoencasa.com/DISCORD`, - ], - payload: { qr: base64QRImg }, - }) - WppConnectGenerateImage(base64QRImg, `${this.globalVendorArgs.name}.qr.png`) - }, - puppeteerOptions: { - headless: true, - args: ['--no-sandbox'], - }, - ...this.globalVendorArgs, - folderNameToken: NAME_DIR_SESSION, - }) - this.vendor = createInstance - const hostDevice = await createInstance.getWid() - const parseNumber = `${hostDevice}`.split('@').shift() - const host = { phone: parseNumber } - this.emit('ready', true) - this.emit('host', host) - - return createInstance - } catch (error) { - this.emit('auth_failure', { - instructions: [`An error occurred during WPP initialization`, `trying again in 5 seconds...`], - }) - WppDeleteTokens(NAME_DIR_SESSION) - setTimeout(async () => { - console.clear() - await this.initVendor() - }, 5000) - } - } - - /** - * Mapeamos los eventos nativos a los que la clase Provider espera - * para tener un standar de eventos - * @returns - */ - busEvents = () => [ - { - event: 'onMessage', - func: (payload: Message & { lat?: string; lng?: string; name: string }) => { - if (payload.from === 'status@broadcast') { - return - } - if (!WppConnectValidNumber(payload.from)) { - return - } - payload.from = WppConnectCleanNumber(payload.from, false) - payload.name = `${payload?.author}` - - // Ensure body is always set (required by BotContext) - if (!payload.body) { - payload.body = '' - } - - if (payload.hasOwnProperty('type') && ['image', 'video'].includes(payload.type)) { - payload = { ...payload, body: utils.generateRefProvider('_event_media_') } - } - if (payload.hasOwnProperty('type') && ['document'].includes(payload.type)) { - payload = { ...payload, body: utils.generateRefProvider('_event_document_') } - } - if (payload.hasOwnProperty('type') && ['ptt'].includes(payload.type)) { - payload = { ...payload, body: utils.generateRefProvider('_event_voice_note_') } - } - if (payload.hasOwnProperty('lat') && payload.hasOwnProperty('lng')) { - const lat = payload.lat - const lng = payload.lng - if (lat !== '' && lng !== '') { - payload = { ...payload, body: utils.generateRefProvider('_event_location_') } - } - } - // Emitir el evento "message" con el payload modificado - this.emit('message', payload as BotContext) - }, - }, - { - event: 'onPollResponse', - func: async (payload: any) => { - const selectedOption = payload.selectedOptions.find((option: { name: any }) => option && option.name) - - payload.id = payload.msgId?._serialized ?? '' - payload.type = 'poll' - payload.body = selectedOption ? selectedOption.name : '' - payload.notifyName = payload.sender - payload.from = WppConnectCleanNumber(payload.sender, false) - payload.to = payload.sender - payload.sender = (await this.vendor.getContact(payload.chatId)) ?? {} - payload.notifyName = payload?.sender?.pushname ?? '' - payload.t = payload.timestamp - - // Emitir el evento "message" con el payload modificado - this.emit('message', payload) - }, - }, - ] - - protected listenOnEvents(vendor: any): void { - if (!vendor) { - throw Error(`Vendor should not return empty`) - } - - if (!this.vendor) { - this.vendor = vendor - } - - const listEvents = this.busEvents() - for (const { event, func } of listEvents) { - if (this.vendor[event]) this.vendor[event]((payload: any) => func(payload)) - } - } - - /** - * - * @param req - * @param res - */ - public indexHome: Middleware = (req, res) => { - const botName = req[this.idBotName] - const qrPath = join(process.cwd(), `${botName}.qr.png`) - const fileStream = createReadStream(qrPath) - res.writeHead(200, { 'Content-Type': 'image/png' }) - fileStream.pipe(res) - } - - protected beforeHttpServerInit(): void { - this.server = this.server - .use((req, _, next) => { - req['globalVendorArgs'] = this.globalVendorArgs - return next() - }) - .get('/', this.indexHome) - } - /** - * @deprecated Buttons are not available in this provider, please use sendButtons instead - * @private - * @param {string} number - * @param {string} text - * @param {Array} buttons - * @example await sendButtons("+XXXXXXXXXXX", "Your Text", [{"body": "Button 1"},{"body": "Button 2"}]) - */ - sendButtons = async (number: any, text: any, buttons: any[]) => { - this.emit('notice', { - title: 'DEPRECATED', - instructions: [ - `Currently sending buttons is not available with this provider`, - `this function is available with Meta or Twilio`, - ], - }) - - const templateButtons = buttons.map((btn: { body: any }, i: any) => ({ - id: `id-btn-${i}`, - text: btn.body, - })) - - const buttonMessage = { - useTemplateButtons: true, - buttons: templateButtons, - } - - return this.vendor.sendText(number, text, buttonMessage) - } - - /** - * Enviar mensaje con encuesta - * @param {string} number - * @param {string} text - * @param {Array} poll - * @example await sendPollMessage("+XXXXXXXXXXX", "You accept terms", [ "Yes", "Not"], {"selectableCount": 1}) - */ - - sendPoll = async (number: any, text: any, poll: { options: string[]; multiselect: any }) => { - if (poll.options.length < 2) return false - - const selectableCount = poll.multiselect === undefined ? 1 : poll.multiselect ? 1 : 0 - return this.vendor.sendPollMessage(number, text, poll.options, { selectableCount }) - } - - /** - * Enviar audio - * @alpha - * @param {string} number - * @param {string} message - * @param {boolean} voiceNote optional - * @example await sendMessage('+XXXXXXXXXXX', 'audio.mp3') - */ - - sendPtt = async (number: any, audioPath: string) => { - return this.vendor.sendPtt(number, audioPath) - } - - /** - * Enviar imagen - * @param {string} number - The phone number to send the image to. - * @param {string} filePath - The path to the image file. - * @param {string} text - The text to accompany the image. - * @returns {Promise} - A promise representing the result of sending the image. - */ - sendImage = async (number: string, filePath: string, text: string): Promise => { - return this.vendor.sendImage(number, filePath, 'image-name', text) - } - - /** - * - * @param {string} number - * @param {string} filePath - * @example await sendMessage('+XXXXXXXXXXX', './document/file.pdf') - */ - - sendFile = async (number: any, filePath: string, text: any) => { - const fileName = basename(filePath) - return this.vendor.sendFile(number, filePath, fileName, text) - } - - /** - * Enviar video - * @param {string} number - El número de teléfono al que se enviará el video. - * @param {string} filePath - La ruta al archivo de video. - * @param {string} text - El texto que acompañará al video. - * @returns {Promise<{ - * ack: number; - * id: string; - * sendMsgResult: SendMsgResult; - * }>} - Una promesa que representa el resultado de enviar el video. - */ - sendVideo = async (number: string, filePath: string, text: string): Promise => { - return this.vendor.sendVideoAsGif(number, filePath, 'video.gif', text) - } - - /** - * Enviar imagen o multimedia - * @param {*} number - * @param {*} mediaInput - * @param {*} message - * @returns - */ - sendMedia = async (number: any, mediaUrl: string, text: any) => { - const fileDownloaded = await utils.generalDownload(mediaUrl) - const mimeType = mime.lookup(fileDownloaded) - if (`${mimeType}`.includes('image')) return this.sendImage(number, fileDownloaded, text) - if (`${mimeType}`.includes('video')) return this.sendVideo(number, fileDownloaded, text) - if (`${mimeType}`.includes('audio')) { - const fileOpus = await utils.convertAudio(fileDownloaded) - return this.sendPtt(number, fileOpus) - } - - return this.sendFile(number, fileDownloaded, text) - } - - /** - * Enviar mensaje al usuario - * @param {*} to - * @param {*} message - * @param {*} param2 - * @returns - */ - sendMessage = async (number: string, message: string, options?: SendOptions): Promise => { - options = { ...options, ...options['options'] } - if (options?.buttons?.length) return this.sendButtons(number, message, options.buttons) - if (options?.media) return this.sendMedia(number, options.media, message) - return this.vendor.sendText(number, message) - } - - private generateFileName = (extension: string): string => `file-${Date.now()}.${extension}` - - saveFile = async (ctx: Partial, options: SaveFileOptions = {}): Promise => { - try { - const { mimetype } = ctx - const buffer = await this.vendor.decryptFile(ctx as Message) - const extension = mime.extension(mimetype) as string - const fileName = this.generateFileName(extension) - const pathFile = join(options?.path ?? tmpdir(), fileName) - await writeFile(pathFile, buffer) - return resolve(pathFile) - } catch (err) { - console.log(`[Error]:`, err.message) - return 'ERROR' - } - } -} - -export { WPPConnectProvider } diff --git a/packages/provider-wppconnect/src/types.ts b/packages/provider-wppconnect/src/types.ts deleted file mode 100644 index ebdb5a29a..000000000 --- a/packages/provider-wppconnect/src/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ProviderClass } from '@builderbot/bot' -export interface Response { - type: string - data: Buffer -} - -export type BotCtxMiddleware = Partial - -export interface SaveFileOptions { - path?: string -} diff --git a/packages/provider-wppconnect/src/utils.ts b/packages/provider-wppconnect/src/utils.ts deleted file mode 100644 index 052549f0e..000000000 --- a/packages/provider-wppconnect/src/utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { utils } from '@builderbot/bot' -import { writeFile } from 'fs' -import { emptyDir } from 'fs-extra' -import { join } from 'path' - -import type { Response } from './types' - -const emptyDirSessions = async (pathBase: string) => - new Promise((resolve, reject) => { - emptyDir(pathBase, (err) => { - if (err) reject(err) - resolve(true) - }) - }) - -const WppConnectCleanNumber = (number: string, full: boolean = false): string => { - number = number.replace('@c.us', '').replace('+', '').replace(/\s/g, '') - number = full ? `${number}@c.us` : `${number}` - return number -} - -const notMatches = (matches: RegExpMatchArray | null): boolean => { - return !matches || matches.length !== 3 -} - -const writeFilePromise = (pathQr: string, response: Response): Promise => { - return new Promise((resolve, reject) => { - writeFile(pathQr, response.data, 'binary', (err) => { - if (err !== null) reject('ERROR_QR_GENERATE') - resolve(true) - }) - }) -} - -const WppDeleteTokens = (session: string) => { - try { - const pathTokens = join(process.cwd(), session) - emptyDirSessions(pathTokens) - console.log('Tokens clean..') - } catch (e) { - return - } -} - -const WppConnectGenerateImage = async (base: string, name: string = 'qr.png'): Promise => { - const PATH_QR: string = `${process.cwd()}/${name}` - const matches: RegExpMatchArray | null = base.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/) - - if (notMatches(matches)) { - return new Error('Invalid input string') - } - const response: Response = { - type: matches[1], - data: Buffer.from(matches[2], 'base64'), - } - - await writeFilePromise(PATH_QR, response) - await utils.cleanImage(PATH_QR) -} - -const WppConnectValidNumber = (rawNumber: string): boolean => { - const regexGroup: RegExp = /\@g.us\b/gm - const exist: RegExpMatchArray | null = rawNumber.match(regexGroup) - return !exist -} - -export { - WppConnectValidNumber, - WppDeleteTokens, - WppConnectGenerateImage, - WppConnectCleanNumber, - notMatches, - writeFilePromise, - emptyDirSessions, -} diff --git a/packages/provider-wppconnect/tsconfig.json b/packages/provider-wppconnect/tsconfig.json deleted file mode 100644 index 9f15948c9..000000000 --- a/packages/provider-wppconnect/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2021", - "types": [ - "node" - ] - }, - "include": [ - "src/**/*.js", - "src/**/*.ts" - ], - "exclude": [ - "**/*.spec.ts", - "**/*.test.ts", - "node_modules" - ] -} \ No newline at end of file diff --git a/renamer.js b/renamer.js new file mode 100644 index 000000000..f6a7ab424 --- /dev/null +++ b/renamer.js @@ -0,0 +1,75 @@ +const fs = require('fs') +const path = require('path') + +const BUSCAR = '@builderbot/' +const REEMPLAZAR = '@japcon-bot/' +const CARPETA_PACKAGES = path.join(__dirname, 'packages') + +function actualizarObjetoDependencias(obj) { + if (!obj) return + for (const key in obj) { + if (key.startsWith(BUSCAR)) { + const nuevoNombre = key.replace(BUSCAR, REEMPLAZAR) + obj[nuevoNombre] = obj[key] + delete obj[key] + } + } +} + +function procesarPackageJson(rutaArchivo) { + try { + const contenido = fs.readFileSync(rutaArchivo, 'utf8') + const json = JSON.parse(contenido) + let modificado = false + + if (json.name && json.name.startsWith(BUSCAR)) { + json.name = json.name.replace(BUSCAR, REEMPLAZAR) + modificado = true + } + + if (json.dependencies) { + actualizarObjetoDependencias(json.dependencies) + modificado = true + } + if (json.devDependencies) { + actualizarObjetoDependencias(json.devDependencies) + modificado = true + } + if (json.peerDependencies) { + actualizarObjetoDependencias(json.peerDependencies) + modificado = true + } + + if (modificado) { + fs.writeFileSync(rutaArchivo, JSON.stringify(json, null, 2) + '\n', 'utf8') + console.log(`✅ Modificado: ${path.relative(__dirname, rutaArchivo)}`) + } + } catch (error) { + console.error(`❌ Error en ${rutaArchivo}:`, error.message) + } +} + +function buscarPackages(dir) { + if (!fs.existsSync(dir)) return + const archivos = fs.readdirSync(dir) + + for (const archivo of archivos) { + // FILTRO CRÍTICO: Ignorar node_modules y carpetas de compilación dist + if (archivo === 'node_modules' || archivo === 'dist' || archivo === '.turbo') { + continue + } + + const rutaCompleta = path.join(dir, archivo) + const stat = fs.statSync(rutaCompleta) + + if (stat.isDirectory()) { + buscarPackages(rutaCompleta) + } else if (archivo === 'package.json') { + procesarPackageJson(rutaCompleta) + } + } +} + +console.log('🚀 Iniciando renombrado masivo (evitando node_modules)...') +buscarPackages(CARPETA_PACKAGES) +console.log('🏁 ¡Proceso terminado!')