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/.gitignore b/.gitignore index d1feed8a8..e78500aee 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 @@ -34,6 +36,7 @@ log/* *.tgz lib tmp/ +!tmp/chatwoot-media/.gitkeep .yarn/* !.yarn/releases !.yarn/plugins/@yarnpkg/plugin-postinstall.cjs diff --git a/lerna.json b/lerna.json index 92feb0dc8..afc21fa34 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.1", + "version": "1.4.2-alpha.11", "packages": [ "packages/bot", "packages/cli", @@ -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": { @@ -34,7 +35,7 @@ "**/*" ], "noVerifyAccess": true, - "syncWorkspaceLockfile": false + "workspaceProtocol": "noop" } }, "npmClient": "pnpm", diff --git a/package.json b/package.json index b8129504b..1ecb065cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@builderbot/root", - "version": "1.3.10", + "name": "@japcon-bot/root", + "version": "1.0.0", "description": "Bot de wahtsapp open source para MVP o pequeños negocios", "main": "app.js", "private": true, @@ -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\")", @@ -45,6 +46,7 @@ "repository": "https://github.com/leifermendez/bot-whatsapp", "license": "ISC", "workspaces": [ + "packages/plugins/*", "packages/bot", "packages/cli", "packages/create-builderbot", diff --git a/packages/bot/package.json b/packages/bot/package.json index 9071faedb..ac8868fb8 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -1,66 +1,66 @@ { - "name": "@builderbot/bot", - "version": "1.4.1", - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "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/bot/src/core/coreClass.ts b/packages/bot/src/core/coreClass.ts index 4f21ed09c..0124e5b9a 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 ?? {}), }, } @@ -141,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 @@ -158,6 +159,9 @@ class CoreClass

extends body, from, prevRef: prevMsg.refSerialize, + source_id, + source_type, + ctwa_id, }) await this.database.save(ctxByNumber) } @@ -206,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/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 375c6d322..000000000 --- a/packages/cli/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@builderbot/cli", - "version": "1.4.1", - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 a1ce090e4..000000000 --- a/packages/contexts-dialogflow-cx/__tests__/dialogflow-cx.class.test.ts +++ /dev/null @@ -1,275 +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 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(null, 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(null, 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(null, 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(null, 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(null, 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(null, 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(null, 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(null, 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(null, 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(null, 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 f0f18d759..000000000 --- a/packages/contexts-dialogflow-cx/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@builderbot/contexts-dialogflow-cx", - "version": "1.4.1", - "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": "^1.4.1", - "@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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 fd8241408..000000000 --- a/packages/contexts-dialogflow/__tests__/dialogflow.class.test.ts +++ /dev/null @@ -1,188 +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 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(null, 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(null, 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(null, 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.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 DialogFlowContext(null, mockProvider) - dialogFlowContext['createSession'] = stub().resolves('session') - dialogFlowContext['detectIntent'] = stub().resolves({ - queryResult: { - fulfillmentMessages: [ - { - 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('createSession should return the correct session path', () => { - const dialogFlowContext = new DialogFlowContext(null, 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 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 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 DialogFlowContext(null, 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) - - assert.equal(result, null) - - mockDetectIntent.restore() -}) - -test.after.each(() => { - unlinkSync(pathFile) -}) -test.run() diff --git a/packages/contexts-dialogflow/package.json b/packages/contexts-dialogflow/package.json deleted file mode 100644 index f2f64e53a..000000000 --- a/packages/contexts-dialogflow/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@builderbot/contexts-dialogflow", - "version": "1.4.1", - "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": "^1.4.1", - "@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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 897c452e2..000000000 --- a/packages/create-builderbot/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "create-builderbot", - "version": "1.4.1", - "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": "^1.4.1" - }, - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 7e43edb52..000000000 --- a/packages/database-json/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@builderbot/database-json", - "version": "1.4.1", - "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": "^1.4.1" - }, - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 3f739acb9..000000000 --- a/packages/database-mongo/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@builderbot/database-mongo", - "version": "1.4.1", - "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": "^1.4.1", - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 e9ec4d9c3..000000000 --- a/packages/database-mysql/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@builderbot/database-mysql", - "version": "1.4.1", - "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": "^1.4.1", - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 f72c326fb..000000000 --- a/packages/database-postgres/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@builderbot/database-postgres", - "version": "1.4.1", - "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": "^1.4.1", - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 97909593c..000000000 --- a/packages/eslint-plugin-builderbot/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "eslint-plugin-builderbot", - "version": "1.4.1", - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 d0a6fa6f9..9ace9c427 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -1,72 +1,72 @@ { - "name": "@builderbot/manager", - "version": "1.4.1", - "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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "@builderbot/provider-venom": { + "optional": true + } + }, + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-baileys/__tests__/baileysProvider.test.ts b/packages/provider-baileys/__tests__/baileysProvider.test.ts index 33a937f3b..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', }) }) }) @@ -1002,6 +1003,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/__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..3f37bb860 --- /dev/null +++ b/packages/provider-baileys/__tests__/lidCache.critical.test.ts @@ -0,0 +1,473 @@ +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 + 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 }) + }) + + 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/package.json b/packages/provider-baileys/package.json index a27697330..85755b180 100644 --- a/packages/provider-baileys/package.json +++ b/packages/provider-baileys/package.json @@ -1,6 +1,6 @@ { - "name": "@builderbot/provider-baileys", - "version": "1.4.1", + "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 ", @@ -38,7 +38,6 @@ }, "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", @@ -63,7 +62,8 @@ "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "wa-sticker-formatter": "^4.4.4", - "wtfnode": "^0.10.1" + "wtfnode": "^0.10.1", + "@japcon-bot/bot": "workspace:^" }, "dependencies": { "@adiwajshing/keyed-db": "^0.2.4", @@ -77,5 +77,5 @@ "node-cache": "^5.1.2", "sharp": "0.33.3" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } diff --git a/packages/provider-baileys/src/bailey.ts b/packages/provider-baileys/src/bailey.ts index 2029f9882..9ac46b9df 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') @@ -561,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, @@ -759,6 +786,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 +838,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 + ) } /** @@ -872,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) } 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 } 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 6609adfb1..000000000 --- a/packages/provider-email/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "@builderbot/provider-email", - "version": "1.4.1", - "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": "^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/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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 fa6e36f7d..000000000 --- a/packages/provider-evolution-api/package.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "name": "@builderbot/provider-evolution-api", - "version": "1.4.1", - "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": "^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", - "@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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 fb56d05e9..284f9d606 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.1", + "name": "@japcon-bot/provider-facebook-messenger", + "version": "1.0.0", "description": "Provider for Facebook Messenger", "keywords": [ "facebook", @@ -33,7 +33,6 @@ }, "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", @@ -46,12 +45,13 @@ "rimraf": "^6.1.2", "rollup-plugin-typescript2": "^0.36.0", "ts-jest": "^29.4.6", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "@japcon-bot/bot": "workspace:^" }, "dependencies": { "axios": "^1.13.2", "mime-types": "^3.0.2", "polka": "^0.5.2" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } 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-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 9acc998ee..000000000 --- a/packages/provider-gohighlevel/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@builderbot/provider-gohighlevel", - "version": "1.4.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", - "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": "^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", - "@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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 f0279e78c..000000000 --- a/packages/provider-gupshup/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@builderbot/provider-gupshup", - "version": "1.4.1", - "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": "^1.4.1", - "@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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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/__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/package.json b/packages/provider-instagram/package.json index 7602131e4..94612995b 100644 --- a/packages/provider-instagram/package.json +++ b/packages/provider-instagram/package.json @@ -1,6 +1,6 @@ { - "name": "@builderbot/provider-instagram", - "version": "1.4.1", + "name": "@japcon-bot/provider-instagram", + "version": "1.0.0", "description": "Provider for Instagram Messaging", "keywords": [ "instagram", @@ -33,7 +33,6 @@ }, "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", @@ -47,7 +46,8 @@ "rimraf": "^6.1.2", "rollup-plugin-typescript2": "^0.36.0", "ts-jest": "^29.4.6", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "@japcon-bot/bot": "workspace:^" }, "dependencies": { "axios": "^1.13.2", @@ -55,5 +55,5 @@ "mime-types": "^3.0.2", "polka": "^0.5.2" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "gitHead": "be4c4287fe2fa5847968c98c3d9d32088edeaca3" } 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-instagram/src/instagram.provider.ts b/packages/provider-instagram/src/instagram.provider.ts index f3a1eb517..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,15 +256,28 @@ 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') } } 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) + } + + 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) } @@ -296,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, }, @@ -309,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') } } @@ -373,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') } } @@ -402,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') } } @@ -431,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') } } @@ -460,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') } } @@ -488,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') } } @@ -540,8 +616,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') } 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) + }) +``` 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', 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/package.json b/packages/provider-meta/package.json index 92eeaff38..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.1", - "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": "^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", - "@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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "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-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 } 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/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..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, @@ -133,9 +136,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, + from: message.from || message.from_user_id, + url: stickerUrl ?? fileData?.url, + fileData, to, id: message.sticker.id, body: utils.generateRefProvider('_event_media_'), @@ -147,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, @@ -164,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, 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 2ecacdd1a..000000000 --- a/packages/provider-sherpa/package.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "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" -} 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 5e288770b..518688c32 100644 --- a/packages/provider-telegram/package.json +++ b/packages/provider-telegram/package.json @@ -1,6 +1,6 @@ { - "name": "@builderbot/provider-telegram", - "version": "1.4.1", + "name": "@japcon-bot/provider-telegram", + "version": "1.0.0", "description": "Provider for Telegram", "keywords": [], "author": "", @@ -29,7 +29,6 @@ }, "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", @@ -44,10 +43,11 @@ "jest": "^30.2.0", "rimraf": "^6.1.2", "rollup-plugin-typescript2": "^0.36.0", - "ts-jest": "^29.4.6" + "ts-jest": "^29.4.6", + "@japcon-bot/bot": "workspace:^" }, "dependencies": { "telegram": "^2.23.10" }, - "gitHead": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" + "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 7a5e5670e..000000000 --- a/packages/provider-twilio/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@builderbot/provider-twilio", - "version": "1.4.1", - "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": "^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/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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 71d0fc0be..000000000 --- a/packages/provider-venom/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@builderbot/provider-venom", - "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", - "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": "^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/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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 2e3c17b23..000000000 --- a/packages/provider-web-whatsapp/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@builderbot/provider-web-whatsapp", - "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", - "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": "^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/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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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 b5b66b062..000000000 --- a/packages/provider-wppconnect/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "@builderbot/provider-wppconnect", - "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", - "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": "^1.4.1", - "@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": "b36178dfb87da8ce270e28b6daaff2c50374b6eb" -} 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/pnpm-lock.yaml b/pnpm-lock.yaml index 4fbea8e3b..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 @@ -615,6 +615,34 @@ importers: specifier: ^0.5.6 version: 0.5.6 + packages/plugins/chatwoot: + dependencies: + '@builderbot/bot': + specifier: workspace:^ + version: link:../../bot + 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': @@ -649,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 @@ -746,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 @@ -810,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 @@ -895,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 @@ -962,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 @@ -1020,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 @@ -1078,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 @@ -1148,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 @@ -1257,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 @@ -1342,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 @@ -1409,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 @@ -1482,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 @@ -1561,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 @@ -1637,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) @@ -2227,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: { @@ -17736,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 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/*' 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!') 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.')