├── .gitignore ├── doc ├── replies-relations.jpg ├── whatsapp-chatbot.jpg ├── whatsapp-chatbot-control.jpg ├── nginx │ └── reverse-proxy.conf └── examples │ ├── conversation9.js │ ├── conversation4.js │ ├── conversation5.js │ ├── conversation6.js │ ├── conversation7.js │ ├── conversation2.js │ ├── conversation8.js │ ├── conversation10.js │ ├── conversation3.js │ ├── conversation1.js │ └── conversation11.js ├── babel.config.cjs ├── .dockerignore ├── src ├── httpCtrl.js ├── conversations │ ├── conversation.js │ └── conversation.test.js ├── core.test.js ├── main.js ├── helpers.test.js ├── config.js ├── helpers.js ├── httpCtrl.html └── core.js ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn.lock 3 | tokens/* 4 | logs/* -------------------------------------------------------------------------------- /doc/replies-relations.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfadev/jfa-whatsapp-chatbot/HEAD/doc/replies-relations.jpg -------------------------------------------------------------------------------- /doc/whatsapp-chatbot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfadev/jfa-whatsapp-chatbot/HEAD/doc/whatsapp-chatbot.jpg -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [["@babel/preset-env", { targets: { node: "current" } }]], 3 | }; 4 | -------------------------------------------------------------------------------- /doc/whatsapp-chatbot-control.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfadev/jfa-whatsapp-chatbot/HEAD/doc/whatsapp-chatbot-control.jpg -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .git/ 3 | doc/ 4 | .gitignore 5 | docker-compose.yml 6 | Dockerfile 7 | LICENCE 8 | README.md 9 | yarn.lock -------------------------------------------------------------------------------- /src/httpCtrl.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { chatbotOptions } from "./config"; 4 | import { httpCtrl } from "./core"; 5 | 6 | /* Http chatbot control server (http://localhost:3000/) */ 7 | /* -----------------------------------------------------*/ 8 | httpCtrl("chatbotSession", chatbotOptions.httpCtrl.port); 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | wchatbot: 5 | container_name: wchatbot 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | restart: unless-stopped 10 | volumes: 11 | - ./src:/wchatbot/src 12 | # - ./tokens:/wchatbot/tokens 13 | - ./logs:/wchatbot/logs 14 | ports: 15 | - '3000:3000' -------------------------------------------------------------------------------- /src/conversations/conversation.js: -------------------------------------------------------------------------------- 1 | // import { buttons, remoteTxt, remoteJson, remoteImg } from "../helpers"; 2 | 3 | /** 4 | * Chatbot conversation flow 5 | * Your custom conversation 6 | */ 7 | export default [ 8 | { 9 | id: 1, 10 | parent: 0, 11 | pattern: /hi|hello/, 12 | message: "Welcome to Jfa WhatsApp Chatbot!", 13 | end: true, 14 | }, 15 | ]; -------------------------------------------------------------------------------- /doc/nginx/reverse-proxy.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name wchatbotcp.domain.com; 5 | 6 | location / { 7 | proxy_pass http://localhost:3000; 8 | proxy_set_header Host $host; 9 | proxy_set_header X-Real-IP $remote_addr; 10 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 11 | proxy_set_header X-Forwarded-Proto $scheme; 12 | } 13 | } -------------------------------------------------------------------------------- /src/core.test.js: -------------------------------------------------------------------------------- 1 | import { log, error, session, httpCtrl } from "./core"; 2 | 3 | describe("Core", () => { 4 | it("Test log", () => { 5 | let passed = true; 6 | try { 7 | // log("Test", "Test log message"); 8 | } catch { 9 | passed = false; 10 | } 11 | expect(passed).toBeTruthy();; 12 | }); 13 | it("Test error", () => { 14 | let passed = true; 15 | try { 16 | // error("Test error log message", { status: 0 }); 17 | } catch { 18 | passed = false; 19 | } 20 | expect(passed).toBeTruthy();; 21 | }); 22 | }); -------------------------------------------------------------------------------- /doc/examples/conversation9.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Chatbot conversation flow 3 | * Example 9 4 | */ 5 | export default [ 6 | { 7 | id: 1, 8 | parent: 0, 9 | pattern: /.*/, // Match all 10 | message: "", 11 | // Inject custom code or overwrite output 'message' property before reply 12 | beforeReply(from, input, output, parents, media) { 13 | if (media) { 14 | console.log("media buffer", media.buffer); 15 | return `You send file with .${media.extension} extension!`; 16 | } else { 17 | return "Send a picture please!"; 18 | } 19 | }, 20 | end: true, 21 | }, 22 | ]; -------------------------------------------------------------------------------- /doc/examples/conversation4.js: -------------------------------------------------------------------------------- 1 | import { remoteImg } from "../helpers"; 2 | 3 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 4 | 5 | /** 6 | * Chatbot conversation flow 7 | * Example 4 8 | */ 9 | export default [ 10 | { 11 | id: 1, 12 | parent: 0, 13 | pattern: /.*/, // Match all 14 | message: "Image local and remote! Send [local] or [remote]", 15 | }, 16 | { 17 | id: 2, 18 | parent: 1, 19 | pattern: /local/, 20 | image: "./images/image1.jpg", 21 | end: true, 22 | }, 23 | { 24 | id: 3, 25 | parent: 1, 26 | pattern: /remote/, 27 | image: remoteImg(`${customEndpoint}/image1.jpg`), 28 | end: true, 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /doc/examples/conversation5.js: -------------------------------------------------------------------------------- 1 | import { remoteImg } from "../helpers"; 2 | 3 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 4 | 5 | /** 6 | * Chatbot conversation flow 7 | * Example 5 8 | */ 9 | export default [ 10 | { 11 | id: 1, 12 | parent: 0, 13 | pattern: /.*/, // Match all 14 | message: "Audio local and remote! Send [local] or [remote]", 15 | }, 16 | { 17 | id: 2, 18 | parent: 1, 19 | pattern: /local/, 20 | audio: "./audios/audio1.mp3", 21 | end: true, 22 | }, 23 | { 24 | id: 3, 25 | parent: 1, 26 | pattern: /remote/, 27 | audio: remoteAudio(`${customEndpoint}/audio1.mp3`), 28 | end: true, 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /doc/examples/conversation6.js: -------------------------------------------------------------------------------- 1 | import fetch from "sync-fetch"; 2 | 3 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 4 | 5 | /** 6 | * Chatbot conversation flow 7 | * Example 6 8 | */ 9 | export default [ 10 | { 11 | id: 1, 12 | parent: 0, 13 | pattern: /.*/, // Match all 14 | message: "", 15 | // Inject custom code or overwrite output 'message' property before reply 16 | beforeReply(from, input, output, parents) { 17 | // Get reply from external api and overwrite output 'message' 18 | const response = fetch(`${customEndpoint}/ai-reply.php/?input=${input}`).json(); 19 | return response.message; 20 | }, 21 | end: true, 22 | }, 23 | ]; 24 | -------------------------------------------------------------------------------- /doc/examples/conversation7.js: -------------------------------------------------------------------------------- 1 | import fetch from "sync-fetch"; 2 | 3 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 4 | 5 | /** 6 | * Chatbot conversation flow 7 | * Example 7 8 | */ 9 | export default [ 10 | { 11 | id: 1, 12 | parent: 0, 13 | pattern: /.*/, // Match all 14 | message: "Hello!", 15 | // Inject custom code after reply 16 | afterReply(from, input, parents) { 17 | // Send whatsapp number to external api 18 | const response = fetch(`${customEndpoint}/number-lead.php/`, { 19 | method: "POST", 20 | body: JSON.stringify({ number: from }), 21 | headers: { "Content-Type": "application/json" }, 22 | }).json(); 23 | console.log("response:", response); 24 | }, 25 | end: true, 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /wchatbot 4 | RUN apk update && apk add --no-cache nmap && \ 5 | echo @edge http://nl.alpinelinux.org/alpine/edge/community >> /etc/apk/repositories && \ 6 | echo @edge http://nl.alpinelinux.org/alpine/edge/main >> /etc/apk/repositories && \ 7 | apk update && \ 8 | apk add --no-cache \ 9 | chromium \ 10 | harfbuzz \ 11 | "freetype>2.8" \ 12 | ttf-freefont \ 13 | nss 14 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 15 | RUN npm install pm2 -g 16 | COPY . /wchatbot 17 | RUN npm install 18 | EXPOSE 3000 19 | CMD pm2 start src/main.js \ 20 | --node-args='--es-module-specifier-resolution=node' \ 21 | --name wchatbot && \ 22 | pm2-runtime start src/httpCtrl.js \ 23 | --node-args='--es-module-specifier-resolution=node' \ 24 | --name wchatbotcp -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jordi Fernandes Alves 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /doc/examples/conversation2.js: -------------------------------------------------------------------------------- 1 | import { buttons, remoteTxt, remoteJson } from "../helpers"; 2 | 3 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 4 | 5 | /** 6 | * Chatbot conversation flow 7 | * Example 2 8 | */ 9 | export default [ 10 | { 11 | id: 1, 12 | parent: 0, 13 | pattern: /.*/, 14 | message: "Hello! I am a Delivery Chatbot.", 15 | description: "Choice one option!", 16 | buttons: buttons([ 17 | "See today's menu?", 18 | "Order directly!", 19 | "Talk to a human!", 20 | ]), 21 | }, 22 | { 23 | id: 2, 24 | parent: 1, // Relation with id: 1 25 | pattern: /menu/, 26 | message: remoteTxt(`${customEndpoint}/menu.txt`), 27 | // message: remoteJson(`${customEndpoint}/menu.json`)[0].message, 28 | end: true, 29 | }, 30 | { 31 | id: 3, 32 | parent: 1, // Relation with id: 1 33 | pattern: /order/, 34 | message: "Make a order!", 35 | link: `${customEndpoint}/delivery-order.php`, 36 | end: true, 37 | }, 38 | { 39 | id: 4, 40 | parent: 1, // Relation with id: 1 41 | pattern: /human/, 42 | message: "Please call the following WhatsApp number: +1 206 555 0100", 43 | end: true, 44 | }, 45 | ]; 46 | -------------------------------------------------------------------------------- /doc/examples/conversation8.js: -------------------------------------------------------------------------------- 1 | import { buttons, inp } from "../helpers"; 2 | 3 | /** 4 | * Chatbot conversation flow 5 | * Example 8 6 | */ 7 | export default [ 8 | { 9 | id: 1, 10 | parent: 0, 11 | pattern: /.*/, 12 | message: "Choice one option", 13 | description: "choice option:", 14 | buttons: buttons(["Option 1", "Option 2"]), 15 | }, 16 | { 17 | id: 2, 18 | parent: 1, 19 | pattern: /.*/, 20 | message: "We have received your request. Thanks.\n\n", 21 | beforeReply(from, input, output, parents) { 22 | output += `Your option: ${inp(2, parents)}`; 23 | return output; 24 | }, 25 | forward: "5512736862295@c.us", // default number or empty 26 | beforeForward(from, forward, input, parents) { // Overwrite forward number 27 | switch (inp(2, parents)) { // Access to replies inputs by id 28 | case "option 1": 29 | forward = "5511994751001@c.us"; 30 | break; 31 | case "option 2": 32 | forward = "5584384738389@c.us"; 33 | break; 34 | default: 35 | forward = "5512736862295@c.us"; 36 | break; 37 | } 38 | return forward; 39 | }, 40 | end: true, 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // import schedule from "node-schedule"; 4 | import { session, log } from "./core"; 5 | import conversation from "./conversations/conversation"; 6 | // import conversation1 from './conversations/conversation1.js'; 7 | // import conversation2 from './conversations/conversation2.js'; 8 | 9 | try { 10 | /* Single WhatsApp account */ 11 | /* ------------------------*/ 12 | session("chatbotSession", conversation); 13 | // OR: 14 | // const chatbot = await session("chatbotSession", conversation); 15 | /* ---------------------------*/ 16 | 17 | /* Multiple WhatsApp accounts */ 18 | /* ---------------------------*/ 19 | // session("chatbotSession", conversation1); 20 | // session("chatbotSession", conversation2); 21 | // OR: 22 | // const chatbot1 = await session("chatbotSession", conversation1); 23 | // const chatbot2 = await session("chatbotSession", conversation2); 24 | // ... 25 | /* ---------------------------*/ 26 | 27 | /* Schedule Jobs */ 28 | /* ---------------------------*/ 29 | // const job1 = schedule.scheduleJob( 30 | // jobsOptions.job1.rule, 31 | // async () => { 32 | // // custom logic 33 | // } 34 | // ); 35 | /* ---------------------------*/ 36 | } catch (error) { 37 | console.log("error", error.toString()); 38 | log("Error", `${error.toString()} Please try restart de bot.`); 39 | } 40 | -------------------------------------------------------------------------------- /doc/examples/conversation10.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | 3 | /** 4 | * Chatbot conversation flow 5 | * Example 10 6 | */ 7 | export default [ 8 | { 9 | id: 1, 10 | parent: 0, 11 | pattern: /\b(?!photo\b)\w+/, // different to photo 12 | message: `Write "photo" for starting.`, 13 | }, 14 | { 15 | id: 2, 16 | parent: [0, 1], 17 | pattern: /photo/, 18 | message: `Hi I'm a Chatbot, send a photo(s)`, 19 | }, 20 | { 21 | id: 3, 22 | parent: 2, 23 | pattern: /\b(?!finalize\b)\w+/, // different to finalize 24 | message: "", 25 | async beforeReply(from, input, output, parents, media) { 26 | const uniqId = new Date().getTime(); 27 | // Download media 28 | if (media) { 29 | const dirName = "./downloads"; 30 | const fileName = `${uniqId}.${media.extension}`; 31 | const filePath = `${dirName}/${fileName}`; 32 | await fs.mkdir(dirName, { recursive: true }); 33 | await fs.writeFile(filePath, await media.buffer); 34 | return `Photo download successfully! Send another or write "finalize".`; 35 | } else { 36 | return `Try send again or write "finalize".`; 37 | } 38 | }, 39 | goTo(from, input, output, parents, media) { 40 | return 3; // return to id = 3 41 | }, 42 | }, 43 | { 44 | id: 4, 45 | parent: 2, 46 | pattern: /finalize/, 47 | message: "Thank's you!", 48 | end: true, 49 | }, 50 | ]; 51 | -------------------------------------------------------------------------------- /doc/examples/conversation3.js: -------------------------------------------------------------------------------- 1 | import fetch from "sync-fetch"; 2 | import { remoteImg } from "../helpers"; 3 | 4 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 5 | 6 | /** 7 | * Chatbot conversation flow 8 | * Example 3 9 | */ 10 | export default [ 11 | { 12 | id: 1, 13 | parent: 0, 14 | pattern: /.*/, // Match all 15 | message: "Hello! I am a Delivery Chatbot. Send a menu item number!", 16 | }, 17 | { 18 | id: 2, 19 | parent: 0, // Same parent (send reply id=1 and id=2) 20 | pattern: /.*/, // Match all 21 | image: remoteImg(`${customEndpoint}/menu.jpg`), 22 | }, 23 | { 24 | id: 3, 25 | parent: 1, // Relation with id: 1 26 | pattern: /\d+/, // Match any number 27 | message: "You are choice item number $input. How many units do you want?", // Inject input value ($input) in message 28 | }, 29 | { 30 | id: 4, 31 | parent: 2, // Relation with id: 2 32 | pattern: /\d+/, // Match any number 33 | message: "You are choice $input units. How many units do you want?", 34 | // Inject custom code or overwrite output 'message' property before reply 35 | beforeReply(from, input, output, parents) { 36 | // Example check external api and overwrite output 'message' 37 | const response = fetch( 38 | `${customEndpoint}/delivery-check-stock.php/?item=${input}&qty=${parents.pop()}` 39 | ).json(); 40 | return response.stock === 0 41 | ? "Item number $input is not available in this moment!" 42 | : output; 43 | }, 44 | end: true, 45 | }, 46 | ]; 47 | -------------------------------------------------------------------------------- /doc/examples/conversation1.js: -------------------------------------------------------------------------------- 1 | import { buttons } from "../helpers"; 2 | 3 | /** 4 | * Chatbot conversation flow 5 | * Example 1 6 | */ 7 | export default [ 8 | { 9 | id: 1, 10 | parent: 0, 11 | pattern: /hello|hi|howdy|good day|good morning|hey|hi-ya|how are you|how goes it|howdy\-do/, 12 | message: "Hello! Thank you for contacting me, I am a Chatbot 🤖 , we will gladly assist you.", 13 | description: "Can I help with something?", 14 | buttons: buttons([ 15 | "Website", 16 | "LinkedIn", 17 | "Github", 18 | "Donate", 19 | "Leave a Message", 20 | ]), 21 | }, 22 | { 23 | id: 2, 24 | parent: 1, // Relation with id: 1 25 | pattern: /website/, 26 | message: "Visit my website and learn more about me!", 27 | link: "https://jordifernandes.com/", 28 | end: true, 29 | }, 30 | { 31 | id: 3, 32 | parent: 1, // Relation with id: 1 33 | pattern: /linkedin/, 34 | message: "Visit my LinkedIn profile!", 35 | link: "https://www.linkedin.com/in/jfadev", 36 | end: true, 37 | }, 38 | { 39 | id: 4, 40 | parent: 1, // Relation with id: 1 41 | pattern: /github/, 42 | message: "Check my Github repositories!", 43 | link: "https://github.com/jfadev", 44 | end: true, 45 | }, 46 | { 47 | id: 5, 48 | parent: 1, // Relation with id: 1 49 | pattern: /donate/, 50 | message: "A tip is always good!", 51 | link: "https://jordifernandes.com/donate/", 52 | end: true, 53 | }, 54 | { 55 | id: 6, 56 | parent: 1, // Relation with id: 1 57 | pattern: /leave a message/, 58 | message: "Write your message, I will contact you as soon as possible!", 59 | }, 60 | { 61 | id: 7, 62 | parent: 6, // Relation with id: 6 63 | pattern: /.*/, // Match with all text 64 | message: "Thank you very much, your message will be sent to Jordi! Sincerely the Chatbot 🤖 !", 65 | end: true, 66 | }, 67 | ]; 68 | -------------------------------------------------------------------------------- /src/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | buttons, 3 | remoteTxt, 4 | remoteJson, 5 | remoteImg, 6 | remoteAudio, 7 | list, 8 | inp, 9 | med, 10 | } from "./helpers"; 11 | 12 | const txtUrl = "https://raw.githubusercontent.com/git/git/master/Documentation/git.txt"; 13 | const jsonUrl = "https://raw.githubusercontent.com/jfadev/jfa-whatsapp-chatbot/master/package.json"; 14 | const imgUrl = "https://raw.githubusercontent.com/jfadev/jfa-whatsapp-chatbot/master/doc/whatsapp-chatbot.jpg"; 15 | const audioUrl = "https://github.com/exaile/exaile-test-files/raw/master/art.mp3"; 16 | 17 | describe("Helpers", () => { 18 | it("Test buttons", () => { 19 | expect(buttons(["Btn 1", "Btn 2"])).toEqual([ 20 | { 21 | buttonText: { 22 | displayText: "Btn 1", 23 | }, 24 | }, 25 | { 26 | buttonText: { 27 | displayText: "Btn 2", 28 | }, 29 | }, 30 | ]); 31 | }); 32 | it("Test remoteTxt", () => { 33 | expect(remoteTxt(txtUrl)).toBeTruthy(); 34 | }); 35 | it("Test remoteJson", () => { 36 | expect(JSON.stringify(remoteJson(jsonUrl))).toBeTruthy(); 37 | }); 38 | it("Test remoteImg", () => { 39 | expect(remoteImg(imgUrl).base64).toMatch(/^data\:/); 40 | }); 41 | it("Test remoteAudio", () => { 42 | expect(remoteAudio(audioUrl).base64).toMatch(/^data\:audio\/mp3/); 43 | }); 44 | it("Test list", () => { 45 | expect(list(["El 1", "El 2"])).toEqual([ 46 | { 47 | title: " ", 48 | rows: [ 49 | { 50 | rowId: "1", 51 | title: "El 1", 52 | description: " ", 53 | }, 54 | { 55 | rowId: "2", 56 | title: "El 2", 57 | description: " ", 58 | }, 59 | ], 60 | }, 61 | ]); 62 | }); 63 | it("Test inp", () => { 64 | expect(inp(1, [{ id: 1, input: "Ok" }])).toBe('Ok'); 65 | }); 66 | it("Test med", () => { 67 | expect(med(1, [{ id: 1, media: "Ok" }])).toBe('Ok'); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Chatbot options 3 | */ 4 | export const chatbotOptions = { 5 | httpCtrl: { 6 | port: 3000, // httpCtrl port (http://localhost:3000/) 7 | username: "admin", 8 | password: "chatbot", 9 | }, 10 | }; 11 | 12 | /** 13 | * Jobs options 14 | */ 15 | // export const jobsOptions = { 16 | // job1: { 17 | // rule: "*/15 * * * *", 18 | // }, 19 | // }; 20 | 21 | /** 22 | * Venom Bot options 23 | * @link https://github.com/orkestral/venom 24 | */ 25 | export const venomOptions = { 26 | multidevice: true, 27 | folderNameToken: "tokens", //folder name when saving tokens 28 | mkdirFolderToken: "", //folder directory tokens, just inside the venom folder, example: { mkdirFolderToken: '/node_modules', } //will save the tokens folder in the node_modules directory 29 | headless: true, // Headless chrome 30 | devtools: false, // Open devtools by default 31 | useChrome: true, // If false will use Chromium instance 32 | debug: true, // Opens a debug session 33 | logQR: true, // Logs QR automatically in terminal 34 | browserWS: "", // If u want to use browserWSEndpoint 35 | browserArgs: [ 36 | "--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36", 37 | "--no-sandbox", // Will be passed to browser. Use --no-sandbox with Docker 38 | ], //Original parameters ---Parameters to be added into the chrome browser instance 39 | puppeteerOptions: { args: ["--no-sandbox"] }, // Will be passed to puppeteer.launch. Use --no-sandbox with Docker 40 | disableSpins: true, // Will disable Spinnies animation, useful for containers (docker) for a better log 41 | disableWelcome: true, // Will disable the welcoming message which appears in the beginning 42 | updatesLog: true, // Logs info updates automatically in terminal 43 | autoClose: false, //60000, // Automatically closes the venom-bot only when scanning the QR code (default 60 seconds, if you want to turn it off, assign 0 or false) 44 | createPathFileToken: true, //creates a folder when inserting an object in the client's browser, to work it is necessary to pass the parameters in the function create browserSessionToken 45 | }; 46 | -------------------------------------------------------------------------------- /doc/examples/conversation11.js: -------------------------------------------------------------------------------- 1 | import { inp, med } from "../helpers"; 2 | import { promises as fs } from "fs"; 3 | 4 | const menu = "Menu:\n\n" + 5 | "1. Send Text\n" + 6 | "2. Send Image\n"; 7 | 8 | /** 9 | * Chatbot conversation flow 10 | * Example 11 11 | */ 12 | export default [ 13 | { 14 | id: 1, 15 | parent: 0, 16 | pattern: /\/admin/, 17 | from: "5584384738389@c.us", // only respond to this number 18 | message: menu 19 | }, 20 | { 21 | id: 2, 22 | parent: [1, 5], 23 | pattern: /.*/, 24 | message: "", 25 | async beforeReply(from, input, output, parents, media) { 26 | switch (input) { 27 | case "1": 28 | return `Write your text:`; 29 | case "2": 30 | return `Send your image:`; 31 | } 32 | }, 33 | }, 34 | { 35 | id: 3, 36 | parent: 2, 37 | pattern: /.*/, 38 | message: `Write "/save" to save or cancel with "/cancel".`, 39 | }, 40 | { 41 | id: 4, 42 | parent: 3, 43 | pattern: /\/save/, 44 | message: "", 45 | async beforeReply(from, input, output, parents, media) { 46 | let txt = ""; 47 | let img = null; 48 | let filePath = null; 49 | const type = inp(2, parents); 50 | if (type === "1") { 51 | txt = inp(3, parents); 52 | } else if (type === "2") { 53 | img = med(3, parents); // media from parent replies 54 | } 55 | if (img) { 56 | const uniqId = new Date().getTime(); 57 | const dirName = "."; 58 | const fileName = `${uniqId}.${img.extension}`; 59 | filePath = `${dirName}/${fileName}`; 60 | await fs.writeFile(filePath, await img.buffer); 61 | } else { 62 | const uniqId = new Date().getTime(); 63 | const dirName = "."; 64 | const fileName = `${uniqId}.txt`; 65 | await fs.writeFile(filePath, txt); 66 | } 67 | return `Ok, text or image saved. Thank you very much!`; 68 | }, 69 | end: true, 70 | }, 71 | { 72 | id: 5, 73 | parent: 3, 74 | pattern: /\/cancel/, 75 | message: menu, 76 | goTo(from, input, output, parents, media) { 77 | return 2; 78 | }, 79 | clearParents: true, // reset parents 80 | }, 81 | ]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jfa-whatsapp-chatbot", 3 | "version": "0.0.1", 4 | "description": "WhatsApp Chatbot", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "node --es-module-specifier-resolution=node src/main.js", 8 | "dev:detach": "node --es-module-specifier-resolution=node src/main.js >> logs/console.log 2>&1 &", 9 | "log:dev": "tail -f logs/console.log", 10 | "http-ctrl:dev": "node --es-module-specifier-resolution=node src/httpCtrl.js", 11 | "http-ctrl:dev:detach": "node --es-module-specifier-resolution=node src/httpCtrl.js >> logs/http-ctrl-console.log 2>&1 &", 12 | "log:http-ctrl:dev": "tail -f logs/http-ctrl-console.log", 13 | "start": "pm2 start src/main.js --node-args='--es-module-specifier-resolution=node' --name wchatbot", 14 | "stop": "pm2 stop wchatbot", 15 | "restart": "pm2 restart wchatbot", 16 | "reload": "pm2 reload wchatbot", 17 | "log": "pm2 logs wchatbot", 18 | "http-ctrl:start": "pm2 start src/httpCtrl.js --node-args='--es-module-specifier-resolution=node'--name wchatbotcp", 19 | "http-ctrl:stop": "pm2 stop wchatbotcp", 20 | "http-ctrl:restart": "pm2 restart wchatbotcp", 21 | "http-ctrl:reload": "pm2 reload wchatbotcp", 22 | "http-ctrl:log": "pm2 logs wchatbotcp", 23 | "conversations": "tail -f logs/conversations.log", 24 | "test": "jest" 25 | }, 26 | "repository": "git@github.com:jfadev/jfa-whatsapp-chatbot.git", 27 | "bugs": { 28 | "url": "https://github.com/jfadev/jfa-whatsapp-chatbot/issues" 29 | }, 30 | "homepage": "https://github.com/jfadev/jfa-whatsapp-chatbot#readme", 31 | "author": "Jordi Fernandes Alves ", 32 | "license": "MIT", 33 | "keywords": [ 34 | "nodejs", 35 | "whatsapp", 36 | "chatbot", 37 | "venom", 38 | "docker" 39 | ], 40 | "dependencies": { 41 | "child_process": "^1.0.2", 42 | "express": "^4.17.3", 43 | "fs": "^0.0.1-security", 44 | "http": "^0.0.1-security", 45 | "node-fetch": "^3.1.0", 46 | "puppeteer": "19.8.5", 47 | "sync-fetch": "^0.4.1", 48 | "venom-bot": "^5.0.1" 49 | }, 50 | "devDependencies": { 51 | "@babel/core": "^7.18.10", 52 | "@babel/preset-env": "^7.18.10", 53 | "babel-jest": "^28.1.3", 54 | "jest": "^28.1.3" 55 | }, 56 | "main": "main.js" 57 | } 58 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import fetch from "sync-fetch"; 2 | 3 | /** 4 | * Create buttons 5 | * @param {Array} buttonTexts 6 | */ 7 | export function buttons(buttonTexts) { 8 | let buttons = []; 9 | buttonTexts.forEach((text) => { 10 | buttons.push({ 11 | buttonText: { 12 | displayText: text, 13 | }, 14 | }); 15 | }); 16 | return buttons; 17 | } 18 | 19 | /** 20 | * Get remote TXT file 21 | * @param {String} url 22 | * @returns 23 | */ 24 | export function remoteTxt(url) { 25 | return fetch(url).text(); 26 | } 27 | 28 | /** 29 | * Get remote JSON file 30 | * @param {String} url 31 | * @returns 32 | */ 33 | export function remoteJson(url) { 34 | return fetch(url).json(); 35 | } 36 | 37 | /** 38 | * Get remote Image (jpg, png, gif) file 39 | * @param {String} url 40 | * @returns 41 | */ 42 | export function remoteImg(url) { 43 | const filename = url.split("/").pop(); 44 | const ext = filename.split(".").pop(); 45 | const mimeType = `image/${ext}`; 46 | const response = fetch(url).buffer().toString("base64"); 47 | return { 48 | filename: filename, 49 | base64: `data:${mimeType};base64,${response}`, 50 | }; 51 | } 52 | 53 | /** 54 | * Get remote Audio (mp3) file 55 | * @param {String} url 56 | * @returns 57 | */ 58 | export function remoteAudio(url) { 59 | const filename = url.split("/").pop(); 60 | const response = fetch(url).buffer().toString("base64"); 61 | return { 62 | filename: filename, 63 | base64: `data:audio/mp3;base64,${response}`, 64 | }; 65 | } 66 | 67 | /** 68 | * Create list 69 | * @param {Array} listRows 70 | */ 71 | export function list(listRows) { 72 | let rows = []; 73 | listRows.forEach((row, i) => { 74 | if (row.hasOwnProperty("title") && row.hasOwnProperty("description")) { 75 | rows.push({ 76 | rowId: `${i + 1}`, 77 | title: row.title, 78 | description: row.description, 79 | }); 80 | } else { 81 | rows.push({ 82 | rowId: `${i + 1}`, 83 | title: row, 84 | description: " ", 85 | }); 86 | } 87 | }); 88 | return [ 89 | { 90 | title: " ", 91 | rows: rows, 92 | }, 93 | ]; 94 | } 95 | 96 | /** 97 | * Get input from parents by reply id 98 | * @param {Number} id 99 | * @param {Array} parents 100 | */ 101 | export function inp(id, parents) { 102 | const row = parents.find((o) => o.id === id); 103 | return row ? row.input.replace("\n ", "") : ""; 104 | } 105 | 106 | /** 107 | * Get media from parents by reply id 108 | * @param {Number} id 109 | * @param {Array} parents 110 | */ 111 | export function med(id, parents) { 112 | const row = parents.find((o) => o.id === id); 113 | return row ? row.media : null; 114 | } 115 | -------------------------------------------------------------------------------- /src/conversations/conversation.test.js: -------------------------------------------------------------------------------- 1 | import conversation from "./conversation"; 2 | 3 | describe("Conversation Flow", () => { 4 | for (const reply of conversation) { 5 | it(`Reply ${reply.id}: Check [id] field`, () => { 6 | expect(reply.id).toBeDefined(); 7 | expect(reply.id).toBeGreaterThan(0); 8 | }); 9 | it(`Reply ${reply.id}: Check [parent] field`, () => { 10 | expect(reply.parent).toBeDefined(); 11 | expect( 12 | Number.isInteger(reply.parent) || 13 | reply.parent instanceof Array 14 | ).toBeTruthy() 15 | }); 16 | it(`Reply ${reply.id}: Check [pattern] field`, () => { 17 | expect(reply.pattern).toBeDefined(); 18 | expect(reply.pattern instanceof RegExp).toBeTruthy(); 19 | }); 20 | it(`Reply ${reply.id}: Check [from] field`, () => { 21 | expect( 22 | !reply.hasOwnProperty("from") || 23 | ( 24 | typeof reply.from === "string" || 25 | reply.from instanceof Array || 26 | typeof reply.from === "object" 27 | ) 28 | ).toBeTruthy() 29 | }); 30 | it(`Reply ${reply.id}: Check [message] field`, () => { 31 | expect( 32 | !reply.hasOwnProperty("message") || 33 | typeof reply.message === "string" 34 | ).toBeTruthy(); 35 | }); 36 | it(`Reply ${reply.id}: Check [buttons] field`, () => { 37 | expect( 38 | !reply.hasOwnProperty("buttons") || 39 | ( 40 | reply.buttons instanceof Array && 41 | reply.hasOwnProperty("description") && 42 | typeof reply.description === "string" 43 | ) 44 | ).toBeTruthy(); 45 | }); 46 | it(`Reply ${reply.id}: Check [list] field`, () => { 47 | expect( 48 | !reply.hasOwnProperty("list") || 49 | ( 50 | reply.list instanceof Array && 51 | reply.hasOwnProperty("button") && 52 | typeof reply.button === "string" && 53 | reply.hasOwnProperty("description") && 54 | typeof reply.description === "string" 55 | ) 56 | ).toBeTruthy(); 57 | }); 58 | it(`Reply ${reply.id}: Check [link] field`, () => { 59 | expect( 60 | !reply.hasOwnProperty("link") || 61 | ( 62 | typeof reply.link === "string" && 63 | /^http/.test(reply.link) && 64 | reply.hasOwnProperty("message") && 65 | typeof reply.message === "string" 66 | ) 67 | ).toBeTruthy(); 68 | }); 69 | it(`Reply ${reply.id}: Check [image] field`, () => { 70 | expect( 71 | !reply.hasOwnProperty("image") || 72 | ( 73 | typeof reply.image === "string" || 74 | reply.image.hasOwnProperty("base64") 75 | ) 76 | ).toBeTruthy(); 77 | }); 78 | it(`Reply ${reply.id}: Check [audio] field`, () => { 79 | expect( 80 | !reply.hasOwnProperty("audio") || 81 | ( 82 | typeof reply.audio === "string" || 83 | reply.audio.hasOwnProperty("base64") 84 | ) 85 | ).toBeTruthy(); 86 | }); 87 | it(`Reply ${reply.id}: Check [forward] field`, () => { 88 | expect( 89 | !reply.hasOwnProperty("forward") || 90 | ( 91 | typeof reply.forward === "string" && 92 | /^\d+\@c\.us/.test(reply.link) && 93 | reply.hasOwnProperty("message") && 94 | typeof reply.message === "string" 95 | ) 96 | ).toBeTruthy(); 97 | }); 98 | it(`Reply ${reply.id}: Check [end] field`, () => { 99 | expect( 100 | !reply.hasOwnProperty("end") || 101 | typeof reply.end === "boolean" 102 | ).toBeTruthy(); 103 | }); 104 | it(`Reply ${reply.id}: Check [clearParents] field`, () => { 105 | expect( 106 | !reply.hasOwnProperty("clearParents") || 107 | typeof reply.clearParents === "boolean" 108 | ).toBeTruthy(); 109 | }); 110 | } 111 | }); 112 | -------------------------------------------------------------------------------- /src/httpCtrl.html: -------------------------------------------------------------------------------- 1 | 2 | Whatsapp Chatbot CP 3 | 4 | 5 | 167 | 168 | 169 |
170 |
171 |

Whatsapp Chatbot Control Panel

172 |
173 | 193 |
194 |

Controls

195 | 196 | 197 | 198 | 199 | 200 |

Refresh rate (s)

201 | 202 |
203 |
204 |

Log

205 |
206 |
207 |
208 | 383 | 384 | 385 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | import venom from "venom-bot"; 2 | import { chatbotOptions, venomOptions } from "./config"; 3 | import express from "express"; 4 | import fs from "fs"; 5 | import http from "http"; 6 | import { exec } from "child_process"; 7 | import mime from "mime-types"; 8 | 9 | console.log("\x1b[36m", "--- Jfa WhatsApp Chatbot (by @jfadev) ---", "\x1b[0m"); 10 | 11 | /** 12 | * Logging debug 13 | * @param {String} type 14 | * @param {String} message 15 | */ 16 | export function log(type, message) { 17 | const datetime = new Date().toLocaleString(); 18 | const msg = `[${datetime}] [${type}] ${message.replace(/\n/g, " ")}`; 19 | console.log(msg); 20 | if (!fs.existsSync("logs")) { 21 | fs.mkdirSync("logs", { recursive: true }); 22 | fs.writeFileSync("logs/conversations.log", ""); 23 | } 24 | fs.appendFileSync("logs/conversations.log", msg + "\n", "utf8"); 25 | } 26 | 27 | /** 28 | * Logging error 29 | * @param {String} message 30 | */ 31 | export function error(message, err) { 32 | const datetime = new Date().toLocaleString(); 33 | const msg = `[${datetime}] [Error] ${message.replace(/\n/g, " ")}`; 34 | console.error(msg); 35 | console.error(err); 36 | if (!fs.existsSync("logs")) { 37 | fs.mkdirSync("logs", { recursive: true }); 38 | fs.writeFileSync("logs/conversations.log", ""); 39 | } 40 | fs.appendFileSync( 41 | "logs/conversations.log", 42 | msg + " " + err.status + "\n", 43 | "utf8" 44 | ); 45 | } 46 | 47 | /** 48 | * Create a chatbot session 49 | * @param {String} name 50 | * @param {Array} conversation 51 | */ 52 | export async function session(name, conversation) { 53 | log("Init", "Starting chatbot..."); 54 | return new Promise((resolve, reject) => { 55 | if (!fs.existsSync(`tokens/${name}`)) { 56 | fs.mkdirSync(`tokens/${name}`, { recursive: true }); 57 | } 58 | fs.writeFileSync( 59 | `tokens/${name}/qr.json`, 60 | JSON.stringify({ attempts: 0, base64Qr: "" }) 61 | ); 62 | fs.writeFileSync( 63 | `tokens/${name}/session.json`, 64 | JSON.stringify({ session: name, status: "starting" }) 65 | ); 66 | fs.writeFileSync( 67 | `tokens/${name}/info.json`, 68 | JSON.stringify({ 69 | id: "", 70 | formattedTitle: "", 71 | displayName: "", 72 | isBusiness: "", 73 | imgUrl: "", 74 | wWebVersion: "", 75 | groups: [], 76 | }) 77 | ); 78 | fs.writeFileSync( 79 | `tokens/${name}/connection.json`, 80 | JSON.stringify({ status: "DISCONNECTED" }) 81 | ); 82 | venom 83 | .create( 84 | name, 85 | (base64Qr, asciiQR, attempts, urlCode) => { 86 | fs.writeFileSync( 87 | `tokens/${name}/qr.json`, 88 | JSON.stringify({ attempts, base64Qr }) 89 | ); 90 | }, 91 | (statusSession, session) => { 92 | fs.writeFileSync( 93 | `tokens/${name}/session.json`, 94 | JSON.stringify({ session: name, status: statusSession }) 95 | ); 96 | }, 97 | venomOptions 98 | ) 99 | .then(async (client) => { 100 | await start(client, conversation); 101 | // const hostDevice = await client.getHostDevice(); 102 | const me = (await client.getAllContacts())?.find(o => o.isMe); 103 | const hostDevice = { 104 | id: { _serialized: me.id._serialized }, 105 | formattedTitle: me.formattedName, 106 | displayName: me.displayName, 107 | isBusiness: me.isBusiness, 108 | imgUrl: me.profilePicThumbObj.img, 109 | }; 110 | const wWebVersion = await client.getWAVersion(); 111 | const groups = (await client.getAllChats()) 112 | .filter((chat) => chat.isGroup) 113 | .map((group) => { 114 | return { id: group.id._serialized, name: group.name }; 115 | }); 116 | setInterval(async () => { 117 | let status = "DISCONNECTED"; 118 | try { 119 | status = await client.getConnectionState(); 120 | } catch (error) {} 121 | fs.writeFileSync( 122 | `tokens/${name}/connection.json`, 123 | JSON.stringify({ status }) 124 | ); 125 | fs.writeFileSync( 126 | `tokens/${name}/info.json`, 127 | JSON.stringify({ 128 | id: hostDevice.id._serialized, 129 | formattedTitle: hostDevice.formattedTitle, 130 | displayName: hostDevice.displayName, 131 | isBusiness: hostDevice.isBusiness, 132 | imgUrl: hostDevice.imgUrl, 133 | wWebVersion, 134 | groups, 135 | }) 136 | ); 137 | }, 2000); 138 | 139 | let time = 0; 140 | client.onStreamChange((state) => { 141 | log("Debug", `State: ${state}`); 142 | clearTimeout(time); 143 | if (state === 'DISCONNECTED' || state === 'SYNCING') { 144 | time = setTimeout(() => { 145 | log("Reload", `Try reload client (${state})...`); 146 | client.close(); 147 | resolve(session(name, conversation)); 148 | }, 80000); 149 | } 150 | }); 151 | 152 | 153 | setTimeout(() => { 154 | log("Debug", `Try reload client`); 155 | client.close(); 156 | // resolve(session(name, conversation)); 157 | }, 90000); 158 | 159 | resolve(client); 160 | }) 161 | .catch((err) => { 162 | console.error(err); 163 | reject(err); 164 | }); 165 | }); 166 | } 167 | 168 | /** 169 | * Create a chatbot http Qr login 170 | * @param {String} name 171 | * @param {Number} port 172 | */ 173 | export async function httpCtrl(name, port = 3000) { 174 | if (!fs.existsSync("logs")) { 175 | fs.mkdirSync("logs", { recursive: true }); 176 | fs.writeFileSync("logs/conversations.log", ""); 177 | } 178 | const app = express(); 179 | const httpServer = http.createServer(app); 180 | httpServer.listen(port, () => { 181 | console.log( 182 | `\x1b[32minfo\x1b[39m: [${name}] Http chatbot control running on http://localhost:${port}/` 183 | ); 184 | }); 185 | const authorize = (req, res) => { 186 | const reject = () => { 187 | res.setHeader("www-authenticate", "Basic"); 188 | res.sendStatus(401); 189 | }; 190 | const authorization = req.headers.authorization; 191 | if (!authorization) { 192 | return reject(); 193 | } 194 | const [username, password] = Buffer.from( 195 | authorization.replace("Basic ", ""), 196 | "base64" 197 | ) 198 | .toString() 199 | .split(":"); 200 | 201 | if ( 202 | !( 203 | username === chatbotOptions.httpCtrl.username && 204 | password === chatbotOptions.httpCtrl.password 205 | ) 206 | ) { 207 | return reject(); 208 | } 209 | }; 210 | app.get("/", (req, res, next) => { 211 | authorize(req, res); 212 | const buffer = fs.readFileSync("src/httpCtrl.html"); 213 | let html = buffer.toString(); 214 | res.send(html); 215 | }); 216 | app.get("/data", (req, res, next) => { 217 | authorize(req, res); 218 | const infoPath = `tokens/${name}/info.json`; 219 | const qrPath = `tokens/${name}/qr.json`; 220 | const sessPath = `tokens/${name}/session.json`; 221 | const info = fs.existsSync(infoPath) 222 | ? JSON.parse(fs.readFileSync(infoPath)) 223 | : null; 224 | const qr = fs.existsSync(qrPath) 225 | ? JSON.parse(fs.readFileSync(qrPath)) 226 | : null; 227 | const sess = fs.existsSync(sessPath) 228 | ? JSON.parse(fs.readFileSync(sessPath)) 229 | : null; 230 | const logs = fs 231 | .readFileSync("logs/conversations.log") 232 | .toString() 233 | .replace(/\n/g, "
"); 234 | res.json({ 235 | info, 236 | session: sess, 237 | qr, 238 | logs, 239 | }); 240 | }); 241 | app.get("/connection", async (req, res, next) => { 242 | authorize(req, res); 243 | const connectionPath = `tokens/${name}/connection.json`; 244 | const connection = fs.existsSync(connectionPath) 245 | ? JSON.parse(fs.readFileSync(connectionPath)) 246 | : null; 247 | res.json({ status: connection?.status }); 248 | }); 249 | app.get("/controls/start", (req, res, next) => { 250 | authorize(req, res); 251 | exec("yarn start", (err, stdout, stderr) => { 252 | if (err) { 253 | res.json({ status: "ERROR" }); 254 | console.error(err); 255 | return; 256 | } 257 | res.json({ status: "OK" }); 258 | console.log(stdout); 259 | log("Start", `Start chatbot...`); 260 | }); 261 | }); 262 | app.get("/controls/stop", (req, res, next) => { 263 | authorize(req, res); 264 | exec("yarn stop", (err, stdout, stderr) => { 265 | if (err) { 266 | res.json({ status: "ERROR" }); 267 | console.error(err); 268 | return; 269 | } 270 | res.json({ status: "OK" }); 271 | console.log(stdout); 272 | log("Stop", `Stop chatbot...`); 273 | }); 274 | }); 275 | app.get("/controls/reload", (req, res, next) => { 276 | authorize(req, res); 277 | exec("yarn reload", (err, stdout, stderr) => { 278 | if (err) { 279 | res.json({ status: "ERROR" }); 280 | console.error(err); 281 | return; 282 | } 283 | res.json({ status: "OK" }); 284 | console.log(stdout); 285 | log("Reload", `Reload chatbot...`); 286 | }); 287 | }); 288 | app.get("/controls/restart", (req, res, next) => { 289 | authorize(req, res); 290 | fs.rmSync(`tokens/${name}`, { recursive: true, force: true }); 291 | exec("yarn restart", (err, stdout, stderr) => { 292 | if (err) { 293 | res.json({ status: "ERROR" }); 294 | console.error(err); 295 | return; 296 | } 297 | res.json({ status: "OK" }); 298 | console.log(stdout); 299 | log("Restart", `Restart chatbot...`); 300 | }); 301 | }); 302 | app.get("/controls/log/clear", (req, res, next) => { 303 | authorize(req, res); 304 | exec("> logs/conversations.log", (err, stdout, stderr) => { 305 | if (err) { 306 | res.json({ status: "ERROR" }); 307 | console.error(err); 308 | return; 309 | } 310 | res.json({ status: "OK" }); 311 | console.log(stdout); 312 | }); 313 | }); 314 | } 315 | 316 | /** 317 | * Start run listener of whatsapp messages 318 | * @param {Object} client 319 | * @param {Array} conversation 320 | */ 321 | export async function start(client, conversation) { 322 | log("Start", `Conversation flow (${conversation.length} replies) running...`); 323 | try { 324 | let sessions = []; 325 | client.onMessage(async (message) => { 326 | if (!sessions.find((o) => o.from === message.from)) { 327 | sessions.push({ from: message.from, parent: 0, parents: [] }); 328 | } 329 | const parent = sessions.find((o) => o.from === message.from).parent; 330 | const parents = sessions.find((o) => o.from === message.from).parents; 331 | // const input = message.body ? message.body.toLowerCase() : message.body; 332 | const media = 333 | message.isMedia || message.isMMS 334 | ? { 335 | buffer: client.decryptFile(message), 336 | extension: mime.extension(message.mimetype), 337 | } 338 | : null; 339 | const input = 340 | message.isMedia || message.isMMS 341 | ? `[media file ${media.extention}]` 342 | : message.body 343 | ? message.body.toLowerCase().replace("\n ", "") 344 | : "[undefined]"; 345 | let replies = conversation.filter( 346 | (o) => 347 | (Array.isArray(o.parent) && o.parent.includes(parent)) || 348 | o.parent === parent 349 | ); 350 | for (const reply of replies) { 351 | if (reply && message.isGroupMsg === false) { 352 | if (reply.pattern.test(input)) { 353 | if (reply.hasOwnProperty("from")) { 354 | // console.log("reply.from", reply.from); 355 | if ( 356 | (Array.isArray(reply.from) && !reply.from.includes(message.from)) || 357 | (typeof reply.from === "string" && reply.from !== message.from) 358 | ) { 359 | break; 360 | } 361 | } 362 | client.startTyping(message.from); 363 | log( 364 | "Receive", 365 | `from: ${message.from}, id: ${reply.id}, parent: ${reply.parent}, pattern: ${reply.pattern}, input: ${input}` 366 | ); 367 | sessions 368 | .find((o) => o.from === message.from) 369 | .parents.push({ id: reply.id, input: input, media: media }); 370 | if (reply.hasOwnProperty("beforeReply")) { 371 | reply.message = await reply.beforeReply( 372 | message.from, 373 | input, 374 | reply.message, 375 | parents, 376 | media 377 | ); 378 | } 379 | if (reply.hasOwnProperty("beforeForward")) { 380 | reply.forward = reply.beforeForward( 381 | message.from, 382 | reply.forward, 383 | input, 384 | parents, 385 | media 386 | ); 387 | } 388 | // TODO: Verifty 389 | // if (reply.hasOwnProperty("message")) { 390 | // reply.message = reply.message.replace(/\$input/g, input); 391 | // } 392 | await watchSendLinkPreview(client, message, reply); 393 | await watchSendButtons(client, message, reply); 394 | await watchSendImage(client, message, reply); 395 | await watchSendAudio(client, message, reply); 396 | await watchSendText(client, message, reply); 397 | await watchSendList(client, message, reply); 398 | await watchForward(client, message, reply); 399 | if (reply.hasOwnProperty("afterReply")) { 400 | reply.afterReply(message.from, input, parents, media); 401 | } 402 | if (reply.hasOwnProperty("end")) { 403 | if (reply.end) { 404 | sessions.find((o) => o.from === message.from).parent = 0; 405 | sessions.find((o) => o.from === message.from).parents = []; 406 | } 407 | } else { 408 | sessions.find((o) => o.from === message.from).parent = reply.id; 409 | // sessions 410 | // .find((o) => o.from === message.from) 411 | // .parents.push({ id: reply.id, input: input }); 412 | } 413 | if (reply.hasOwnProperty("goTo")) { 414 | let parent = sessions.find((o) => o.from === message.from).parent; 415 | let id = reply.goTo( 416 | message.from, 417 | input, 418 | reply.message, 419 | parents, 420 | media 421 | ); 422 | parent = parent ? id - 1 : null; 423 | if (parent) { 424 | sessions.find((o) => o.from === message.from).parent = parent; 425 | } 426 | // console.log("parent", parent); 427 | } 428 | if (reply.hasOwnProperty("clearParents")) { 429 | if (reply.clearParents) { 430 | sessions.find((o) => o.from === message.from).parents = []; 431 | } 432 | } 433 | client.stopTyping(message.from); 434 | } 435 | } 436 | } 437 | }); 438 | } catch (err) { 439 | client.close(); 440 | error(err); 441 | } 442 | } 443 | 444 | /** 445 | * Send link preview 446 | * @param {Object} client 447 | * @param {Object} message 448 | * @param {Object} reply 449 | */ 450 | async function watchSendLinkPreview(client, message, reply) { 451 | if (reply.hasOwnProperty("link") && reply.hasOwnProperty("message")) { 452 | await client 453 | .sendLinkPreview(message.from, reply.link, reply.message) 454 | .then((result) => 455 | log("Send", `(sendLinkPreview): ${reply.message.substring(0, 40)}...`) 456 | ) 457 | .catch((err) => error(`(sendLinkPreview): ${err}`)); 458 | } 459 | } 460 | 461 | /** 462 | * Send buttons 463 | * @param {Object} client 464 | * @param {Object} message 465 | * @param {Object} reply 466 | */ 467 | async function watchSendButtons(client, message, reply) { 468 | if ( 469 | reply.hasOwnProperty("buttons") && 470 | reply.hasOwnProperty("description") && 471 | reply.hasOwnProperty("message") 472 | ) { 473 | await client 474 | .sendButtons( 475 | message.from, 476 | reply.message, 477 | reply.buttons, 478 | reply.description 479 | ) 480 | .then((result) => 481 | log("Send", `(sendButtons): ${reply.message.substring(0, 40)}...`) 482 | ) 483 | .catch((err) => error("(sendButtons):", err)); 484 | } 485 | } 486 | 487 | /** 488 | * Send image file (jpg, png, gif) 489 | * @param {Object} client 490 | * @param {Object} message 491 | * @param {Object} reply 492 | */ 493 | async function watchSendImage(client, message, reply) { 494 | if (reply.hasOwnProperty("image")) { 495 | if ( 496 | reply.image.hasOwnProperty("base64") && 497 | reply.image.hasOwnProperty("filename") 498 | ) { 499 | await client 500 | .sendImageFromBase64( 501 | message.from, 502 | reply.image.base64, 503 | reply.image.filename 504 | ) 505 | .then((result) => 506 | log("Send", `(sendImage b64): ${reply.image.filename}`) 507 | ) 508 | .catch((err) => error("(sendImage b64):", err)); 509 | } else { 510 | const filename = reply.image.split("/").pop(); 511 | await client 512 | .sendImage(message.from, reply.image, filename, "") 513 | .then((result) => log("Send", `(sendImage): ${reply.image}`)) 514 | .catch((err) => error("(sendImage):", err)); 515 | } 516 | } 517 | } 518 | 519 | /** 520 | * Send audio file MP3 521 | * @param {Object} client 522 | * @param {Object} message 523 | * @param {Object} reply 524 | */ 525 | async function watchSendAudio(client, message, reply) { 526 | if (reply.hasOwnProperty("audio")) { 527 | if ( 528 | reply.audio.hasOwnProperty("base64") && 529 | reply.audio.hasOwnProperty("filename") 530 | ) { 531 | await client 532 | .sendVoiceBase64(message.from, reply.audio.base64) 533 | .then((result) => 534 | log("Send", `(sendAudio b64): ${reply.audio.filename}`) 535 | ) 536 | .catch((err) => error("(sendAudio b64):", err)); 537 | } else { 538 | await client 539 | .sendVoice(message.from, reply.audio) 540 | .then((result) => log("Send", `(sendAudio): ${reply.audio}`)) 541 | .catch((err) => error("(sendAudio):", err)); 542 | } 543 | } 544 | } 545 | 546 | /** 547 | * Send simple text 548 | * @param {Object} client 549 | * @param {Object} message 550 | * @param {Object} reply 551 | */ 552 | async function watchSendText(client, message, reply) { 553 | if ( 554 | !reply.hasOwnProperty("link") && 555 | !reply.hasOwnProperty("buttons") && 556 | !reply.hasOwnProperty("description") && 557 | reply.hasOwnProperty("message") 558 | ) { 559 | await client 560 | .sendText(message.from, reply.message) 561 | .then((result) => 562 | log("Send", `(sendText): ${reply.message.substring(0, 40)}...`) 563 | ) 564 | .catch((err) => error("(sendText):", err)); 565 | } 566 | } 567 | 568 | /** 569 | * Send menu list 570 | * @param {Object} client 571 | * @param {Object} message 572 | * @param {Object} reply 573 | */ 574 | async function watchSendList(client, message, reply) { 575 | if ( 576 | reply.hasOwnProperty("list") && 577 | reply.hasOwnProperty("description") && 578 | reply.hasOwnProperty("message") 579 | ) { 580 | await client 581 | .sendListMenu( 582 | message.from, 583 | reply.message, 584 | reply.description, 585 | reply.button, 586 | reply.list 587 | ) 588 | .then((result) => 589 | log("Send", `(sendList): ${reply.message.substring(0, 40)}...`) 590 | ) 591 | .catch((err) => error("(sendList):", err)); 592 | } 593 | } 594 | 595 | /** 596 | * Forward message 597 | * @param {Object} client 598 | * @param {Object} message 599 | * @param {Object} reply 600 | */ 601 | async function watchForward(client, message, reply) { 602 | if (reply.hasOwnProperty("forward") && reply.hasOwnProperty("message")) { 603 | // await client 604 | // .forwardMessages(reply.forward, [message.id.toString()], true) 605 | // .then((result) => 606 | // log("Send", `(forward): ${reply.message.substring(0, 40)}...`) 607 | // ) 608 | // .catch((err) => error("(forward):", err)); 609 | 610 | await client 611 | .sendText(reply.forward, reply.message) 612 | .then((result) => 613 | log( 614 | "Send", 615 | `(forward): to: ${reply.forward} : ${reply.message.substring( 616 | 0, 617 | 40 618 | )}...` 619 | ) 620 | ) 621 | .catch((err) => error("(forward):", err)); 622 | 623 | // /* Debug */ 624 | // console.log("--- DEBUG --- forward", reply.forward); 625 | // await client 626 | // .sendText(message.from, "--- DEBUG --- forward: " + reply.forward) 627 | // .then((result) => 628 | // log("Send", `(DEBUG : forward): ${reply.message.substring(0, 40)}...`) 629 | // ) 630 | // .catch((err) => error("(DEBUG : forward):", err)); 631 | } 632 | } 633 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Jfa WhatsApp Chatbot](doc/whatsapp-chatbot.jpg?raw=true "Jfa Whatsapp Chatbot") 2 | 3 | # Jfa WhatsApp Chatbot 💬 4 | 5 | With this [node.js](https://nodejs.org/) micro framework using [Venom Bot](https://github.com/orkestral/venom) under the hood, 6 | you can easily create a WhatsApp Chatbot 🤖 . 7 | You will only need to edit your conversation flow in a single file. 8 | 9 | - [Jfa WhatsApp Chatbot 💬](#jfa-whatsapp-chatbot-) 10 | - [Getting Started](#getting-started) 11 | - [Install](#install) 12 | - [Docker](#docker) 13 | - [Virtual Machine](#virtual-machine) 14 | - [Local Machine](#local-machine) 15 | - [Configuration](#configuration) 16 | - [Basic](#basic) 17 | - [Advanced](#advanced) 18 | - [Commands](#commands) 19 | - [Docker](#docker-1) 20 | - [Virtual Machine](#virtual-machine-1) 21 | - [Local Machine](#local-machine-1) 22 | - [Sessions](#sessions) 23 | - [Logs](#logs) 24 | - [Docker](#docker-2) 25 | - [Virtual Machine](#virtual-machine-2) 26 | - [Local Machine](#local-machine-2) 27 | - [Conversation Flow](#conversation-flow) 28 | - [Replies Types](#replies-types) 29 | - [Send Text](#send-text) 30 | - [Send Buttons](#send-buttons) 31 | - [Send List](#send-list) 32 | - [Send Link](#send-link) 33 | - [Send Image](#send-image) 34 | - [Send Audio](#send-audio) 35 | - [Forward Message](#forward-message) 36 | - [Helpers](#helpers) 37 | - [Hooks](#hooks) 38 | - [Loops](#loops) 39 | - [Http Control Panel](#http-control-panel) 40 | - [Examples](#examples) 41 | - [Example 1](#example-1) 42 | - [Example 2](#example-2) 43 | - [Example 3](#example-3) 44 | - [Example 4](#example-4) 45 | - [Example 5](#example-5) 46 | - [Example 6](#example-6) 47 | - [Example 7](#example-7) 48 | - [Example 8](#example-8) 49 | - [Example 9](#example-9) 50 | - [Example 10](#example-10) 51 | - [Example 11](#example-11) 52 | - [Advanced](#advanced-1) 53 | - [Multiple Conversation Flows](#multiple-conversation-flows) 54 | - [Multiple Accounts](#multiple-accounts) 55 | - [Access to Venom client](#access-to-venom-client) 56 | - [Schedule Jobs](#schedule-jobs) 57 | - [Testing](#testing) 58 | - [Troubleshooting](#troubleshooting) 59 | - [Donate](#donate) 60 | - [License](#license) 61 | - [Contributing](#contributing) 62 | - [Contributors](#contributors) 63 | 64 | ## Getting Started 65 | 66 | 🛟 [Introduction video](https://www.youtube.com/watch?v=5x4drPdTzLA) 67 | 68 | 1. Create a new repository from [this template](https://github.com/jfadev/jfa-whatsapp-chatbot/generate) 69 | 1. [Install](#install) in your development environment 70 | 2. [Configure](#configuration) port(s), credentials, etc 71 | 3. Write your [conversation flow](#conversation-flow) 72 | 4. [Start](#commands) 73 | 74 | ## Install 75 | 76 | ### Docker 77 | 78 | Requirements: [docker](https://docs.docker.com/desktop/install/windows-install/) 79 | 80 | Build and Run with Dockerfile 81 | 82 | ```bash 83 | $ docker build -t wchatbot . 84 | $ docker run --name wchatbot -p 3000:3000 -v /your_project_absolute_path/src:/wchatbot/src wchatbot 85 | ``` 86 | 87 | or Build and Run with Docker Compose 88 | 89 | ```bash 90 | $ docker-compose build 91 | $ docker-compose up 92 | ``` 93 | 94 | Visit http://localhost:3000 and play with your chatbot! 95 | 96 | ### Virtual Machine 97 | 98 | Requirements: [nodejs](https://nodejs.org/) (Latest [maintenance LTS](https://github.com/nodejs/Release#release-schedule) version), [yarn](https://yarnpkg.com/) (or npm), [pm2](https://www.npmjs.com/package/pm2), [chrome/chromium](https://www.chromium.org/chromium-projects/) 99 | 100 | Use an [nginx](https://www.nginx.com/) reverse proxy to publicly expose the http control panel ([configuration example](doc/nginx/reverse-proxy.conf)). 101 | 102 | ```bash 103 | $ yarn install 104 | ``` 105 | Launch the chatbot and the http control panel 106 | 107 | ```bash 108 | $ yarn start 109 | $ yarn http-ctrl:start 110 | 111 | ``` 112 | 113 | Visit http://localhost:3000 and play with your chatbot! 114 | 115 | ### Local Machine 116 | 117 | Requirements: [nodejs](https://nodejs.org/) (Latest [maintenance LTS](https://github.com/nodejs/Release#release-schedule) version), [yarn](https://yarnpkg.com/) (or npm), [chrome/chromium](https://www.chromium.org/chromium-projects/) 118 | 119 | 120 | ```bash 121 | $ yarn install 122 | ``` 123 | Launch the chatbot and the http control panel 124 | 125 | ```bash 126 | $ yarn http-ctrl:dev:detach 127 | $ yarn dev 128 | 129 | ``` 130 | 131 | Visit http://localhost:3000 and play with your chatbot! 132 | 133 | ## Configuration 134 | 135 | Edit `./src/config.js` file 136 | 137 | ### Basic 138 | 139 | ```javascript 140 | export const chatbotOptions = { 141 | httpCtrl: { 142 | port: 3000, // httpCtrl port (http://localhost:3000/) 143 | username: "admin", // httpCtrl auth login 144 | password: "chatbot", 145 | }, 146 | }; 147 | ``` 148 | ### Advanced 149 | 150 | ```javascript 151 | export const venomOptions = { 152 | ... 153 | browserArgs: [ 154 | "--no-sandbox", // Will be passed to browser. Use --no-sandbox with Docker 155 | ], 156 | puppeteerOptions: { // Will be passed to puppeteer.launch. 157 | args: ["--no-sandbox"] // Use --no-sandbox with Docker 158 | }, 159 | ... 160 | }; 161 | ``` 162 | 163 | ## Commands 164 | 165 | ### Docker 166 | 167 | Chatbot Controls 168 | ```bash 169 | $ docker exec wchatbot yarn start 170 | $ docker exec wchatbot yarn stop 171 | $ docker exec wchatbot yarn restart 172 | $ docker exec wchatbot yarn reload 173 | ``` 174 | 175 | HTTP Control Panel Controls 176 | ```bash 177 | $ docker exec wchatbot yarn http-ctrl:start 178 | $ docker exec wchatbot yarn http-ctrl:stop 179 | $ docker exec wchatbot yarn http-ctrl:restart 180 | $ docker exec wchatbot yarn http-ctrl:reload 181 | ``` 182 | 183 | ### Virtual Machine 184 | 185 | Chatbot Controls 186 | ```bash 187 | $ yarn start 188 | $ yarn stop 189 | $ yarn restart 190 | $ yarn reload 191 | ``` 192 | 193 | HTTP Control Panel Controls 194 | ```bash 195 | $ yarn http-ctrl:start 196 | $ yarn http-ctrl:stop 197 | $ yarn http-ctrl:restart 198 | $ yarn http-ctrl:reload 199 | ``` 200 | 201 | ### Local Machine 202 | 203 | Direct in your OS without Docker 204 | 205 | Chatbot 206 | ```bash 207 | $ yarn dev 208 | $ yarn dev:detach 209 | ``` 210 | 211 | Launch HTTP Control Panel 212 | ```bash 213 | $ yarn http-ctrl:dev 214 | $ yarn http-ctrl:dev:detach 215 | ``` 216 | 217 | ## Sessions 218 | 219 | Sessions and auth tokens are write in `./tokens` folder. 220 | 221 | ## Logs 222 | 223 | Logs are write in `./logs` folder. 224 | Attention: `console.log` and `http-ctrl-console.log` only write in `./logs` folder with `yarn dev:detach` and `yarn http-ctrl:dev:detach` otherwise managed by ` pm2`. 225 | 226 | ### Docker 227 | 228 | Chatbot 229 | ```bash 230 | $ docker exec wchatbot yarn log 231 | ``` 232 | 233 | HTTP Control Panel 234 | ```bash 235 | $ docker exec wchatbot yarn http-ctrl:log 236 | ``` 237 | 238 | Conversations 239 | ```bash 240 | $ docker exec wchatbot yarn conversations 241 | ``` 242 | 243 | ### Virtual Machine 244 | 245 | Chatbot 246 | ```bash 247 | $ yarn log 248 | ``` 249 | 250 | HTTP Control Panel 251 | ```bash 252 | $ yarn http-ctrl:log 253 | ``` 254 | 255 | Conversations 256 | ```bash 257 | $ yarn conversations 258 | ``` 259 | 260 | ### Local Machine 261 | 262 | Chatbot 263 | ```bash 264 | $ yarn log:dev 265 | ``` 266 | 267 | HTTP Control Panel 268 | ```bash 269 | $ yarn log:http-ctrl:dev 270 | ``` 271 | 272 | Conversations 273 | ```bash 274 | $ yarn conversations 275 | ``` 276 | 277 | ## Conversation Flow 278 | 279 | Edit `./src/conversations/conversation.js` file. 280 | 281 | The conversation flow is an array of ordered reply objects. 282 | A reply is only triggered if its `parent` (can be an integer or an array) 283 | is equal to the `id` of the previous reply. 284 | 285 | ![Replies Relations](doc/replies-relations.jpg?raw=true "Replies Relations") 286 | 287 | To indicate that a reply is the end of the conversation add the following property: 288 | 289 | | Property | Type | Description | 290 | |----------|---------|------------------------------| 291 | | `end` | Boolean | The end of the conversation | 292 | 293 | You can protect so that only one number or a list of numbers is answered with: 294 | 295 | | Property | Type | Description | 296 | |----------|---------|------------------------------| 297 | | `from` | String / Array | Only answer this or these numbers | 298 | 299 | A reply necessarily needs the following properties: 300 | 301 | ### Replies Types 302 | 303 | #### Send Text 304 | 305 | | Property | Type | Description | 306 | |----------|---------|----------------------------------------------------------------------------------------| 307 | | `id` | Integer | Reply `id` is used to link with `parent` | 308 | | `parent` | Integer | Id of the reply parent or ids array `[2, 3]`. If it has no parent it is `0` by default | 309 | | `pattern`| RegExp | Regular expression to match in lower case | 310 | | `message`| String | Reply text message | 311 | 312 | Example 313 | 314 | ```javascript 315 | [ 316 | { 317 | id: 1, 318 | parent: 0, 319 | pattern: /.*/, // Match with all text 320 | message: "Hi I am a Chatbot!", 321 | } 322 | ] 323 | ``` 324 | 325 | #### Send Buttons 326 | 327 | >**Attention:** It is currently not working!. 328 | 329 | | Property | Type | Description | 330 | |--------------|---------|------------------------------------------------------------------------------------| 331 | | `id` | Integer | Reply `id` is used to link with `parent` | 332 | | `parent` | Integer | Id of the reply parent or ids array `[2, 3]`. If it has no parent it is `0` by default | 333 | | `pattern` | RegExp | Regular expression to match in lower case | 334 | | `message` | String | Reply text message | 335 | | `description`| String | Reply text subtitle | 336 | | `buttons` | Array | Button object, look at the example | 337 | 338 | Example 339 | 340 | ```javascript 341 | [ 342 | { 343 | id: 1, 344 | parent: 0, 345 | pattern: /.*/, 346 | message: "Hello!", 347 | description: "Can I help with something?", 348 | buttons: buttons([ 349 | "Website", 350 | "LinkedIn", 351 | "Github", 352 | ]), 353 | } 354 | ] 355 | ``` 356 | 357 | #### Send List 358 | 359 | >**Attention:** It is currently not working!. 360 | 361 | | Property | Type | Description | 362 | |--------------|---------|----------------------------------------------------------------------------------------| 363 | | `id` | Integer | Reply `id` is used to link with `parent` | 364 | | `parent` | Integer | Id of the reply parent or ids array `[2, 3]`. If it has no parent it is `0` by default | 365 | | `pattern` | RegExp | Regular expression to match in lower case | 366 | | `message` | String | Reply text message | 367 | | `description`| String | Reply text subtitle | 368 | | `button` | String | List button text | 369 | | `list` | Array | List object, look at the example | 370 | 371 | Example 372 | 373 | ```javascript 374 | [ 375 | { 376 | id: 1, 377 | parent: 0, 378 | pattern: /other country/, 379 | message: "Choice one country", 380 | description: "Choice one option!", 381 | button: "Countries list", 382 | list: list([ 383 | "Argentina", 384 | "Belize", 385 | "Bolivia", 386 | ]), 387 | }, 388 | ] 389 | ``` 390 | 391 | #### Send Link 392 | 393 | | Property | Type | Description | 394 | |----------|---------|----------------------------------------------------------------------------------------| 395 | | `id` | Integer | Reply `id` is used to link with `parent` | 396 | | `parent` | Integer | Id of the reply parent or ids array `[2, 3]`. If it has no parent it is `0` by default | 397 | | `pattern`| RegExp | Regular expression to match in lower case | 398 | | `message`| String | Reply text message | 399 | | `link` | String | URL of generated link preview | 400 | 401 | Example 402 | 403 | ```javascript 404 | [ 405 | { 406 | id: 2, 407 | parent: 1, // Relation with id: 1 408 | pattern: /github/, 409 | message: "Check my Github repositories!", 410 | link: "https://github.com/jfadev", 411 | } 412 | ] 413 | ``` 414 | 415 | #### Send Image 416 | 417 | | Property | Type | Description | 418 | |----------|---------|----------------------------------------------------------------------------------------| 419 | | `id` | Integer | Reply `id` is used to link with `parent` | 420 | | `parent` | Integer | Id of the reply parent or ids array `[2, 3]`. If it has no parent it is `0` by default | 421 | | `pattern`| RegExp | Regular expression to match in lower case | 422 | | `image` | Path / Object | Path or Object returned by `remoteImg()` funtion | 423 | 424 | Example 425 | 426 | ```javascript 427 | [ 428 | { 429 | id: 1, 430 | parent: 0, 431 | pattern: /.*/, // Match all 432 | image: remoteImg("https://remote-server.com/menu.jpg"), 433 | // image: "./images/menu.jpg", 434 | } 435 | ] 436 | ``` 437 | 438 | #### Send Audio 439 | 440 | | Property | Type | Description | 441 | |----------|---------|----------------------------------------------------------------------------------------| 442 | | `id` | Integer | Reply `id` is used to link with `parent` | 443 | | `parent` | Integer | Id of the reply parent or ids array `[2, 3]`. If it has no parent it is `0` by default | 444 | | `pattern`| RegExp | Regular expression to match in lower case | 445 | | `audio` | Path / Object | Path or Object returned by `remoteAudio()` funtion. | 446 | 447 | Example 448 | 449 | ```javascript 450 | [ 451 | { 452 | id: 1, 453 | parent: 0, 454 | pattern: /.*/, // Match all 455 | audio: remoteAudio("https://remote-server.com/audio.mp3"), 456 | // audio: "./audios/audio.mp3", 457 | } 458 | ] 459 | ``` 460 | 461 | #### Forward Message 462 | 463 | | Property | Type | Description | 464 | |----------|---------|----------------------------------------------------------------------------------------| 465 | | `id` | Integer | Reply `id` is used to link with `parent` | 466 | | `parent` | Integer | Id of the reply parent or ids array `[2, 3]`. If it has no parent it is `0` by default | 467 | | `pattern`| RegExp | Regular expression to match in lower case | 468 | | `message`| String | Reply text message | 469 | | `forward`| String | Number where the message is forwarded | 470 | 471 | Example 472 | 473 | ```javascript 474 | [ 475 | { 476 | id: 1, 477 | parent: 0, 478 | pattern: /forward/, 479 | message: "Text to forward", 480 | forward: "55368275082750726@c.us", // forward this message to this number 481 | } 482 | ] 483 | ``` 484 | 485 | ### Helpers 486 | 487 | | Helper | Return | Description | 488 | |------------------------|--------|-----------------------------------------------------------------------------------| 489 | | `buttons(buttonTexts)` | Array | Generate buttons | 490 | | `remoteTxt(url)` | String | Return a remote TXT file | 491 | | `remoteJson(url)` | JSON | Return a remote JSON file | 492 | | `remoteImg(url)` | Object | Return a remote Image file | 493 | | `remoteAudio(url)` | Object | Return a remote Audio file | 494 | | `list(listRows)` | Array | Generate list | 495 | | `inp(id, parents)` | String | Return input string by reply id. Use in beforeReply, afterReply and beforeForward | 496 | | `med(id, parents)` | Media / null | Return Media ({buffer, extension}) by reply id. Use in beforeReply, afterReply and beforeForward | 497 | 498 | ### Hooks 499 | 500 | | Property | Type | Description | 501 | |-------------------------------------------------------|----------|----------------------------------------| 502 | | `beforeReply(from, input, output, parents, media)` | Function | Inject custom code before a reply | 503 | | `afterReply(from, input, parents, media)` | Function | Inject custom code after a reply | 504 | | `beforeForward(from, forward, input, parents, media)` | Function | Inject custom code before a forward | 505 | 506 | ### Loops 507 | 508 | | Property | Type | Description | 509 | |-------------------------------------------------------|----------|----------------------------------------| 510 | | `goTo(from, input, output, parents, media)` | Function | Should return the reply id where to jump | 511 | | `clearParents` | Boolean | Clear parents data, use with goTo() | 512 | 513 | ## Http Control Panel 514 | 515 | With the control panel you can log in, start, stop or restart the bot and monitor the logs. 516 | 517 | Set your `username` and `password` to access your control panel in file `./src/config.js` 518 | 519 | ```javascript 520 | export const chatbotOptions = { 521 | httpCtrl: { 522 | port: 3000, // httpCtrl port (http://localhost:3000/) 523 | username: "admin", 524 | password: "chatbot" 525 | } 526 | }; 527 | ``` 528 | 529 | Use an nginx reverse proxy to publicly expose the http control panel ([configuration example](doc/nginx/reverse-proxy.conf)). 530 | 531 | ![Http Control Panel](doc/whatsapp-chatbot-control.jpg?raw=true "Http Control Panel") 532 | 533 | 534 | ## Examples 535 | 536 | Edit your file `./src/conversations/conversation.js` and create your custom conversation workflow. 537 | 538 | ### Example 1 539 | 540 | [doc/examples/conversation1.js](doc/examples/conversation1.js) 541 | 542 | ```javascript 543 | import { buttons } from "../helpers"; 544 | 545 | /** 546 | * Chatbot conversation flow 547 | * Example 1 548 | */ 549 | export default [ 550 | { 551 | id: 1, 552 | parent: 0, 553 | pattern: /hello|hi|howdy|good day|good morning|hey|hi-ya|how are you|how goes it|howdy\-do/, 554 | message: "Hello! Thank you for contacting me, I am a Chatbot 🤖 , we will gladly assist you.", 555 | description: "Can I help with something?", 556 | buttons: buttons([ 557 | "Website", 558 | "Linkedin", 559 | "Github", 560 | "Donate", 561 | "Leave a Message", 562 | ]), 563 | }, 564 | { 565 | id: 2, 566 | parent: 1, // Relation with id: 1 567 | pattern: /website/, 568 | message: "Visit my website and learn more about me!", 569 | link: "https://jordifernandes.com/", 570 | end: true, 571 | }, 572 | { 573 | id: 3, 574 | parent: 1, // Relation with id: 1 575 | pattern: /linkedin/, 576 | message: "Visit my LinkedIn profile!", 577 | link: "https://www.linkedin.com/in/jfadev", 578 | end: true, 579 | }, 580 | { 581 | id: 4, 582 | parent: 1, // Relation with id: 1 583 | pattern: /github/, 584 | message: "Check my Github repositories!", 585 | link: "https://github.com/jfadev", 586 | end: true, 587 | }, 588 | { 589 | id: 5, 590 | parent: 1, // Relation with id: 1 591 | pattern: /donate/, 592 | message: "A tip is always good!", 593 | link: "https://jordifernandes.com/donate/", 594 | end: true, 595 | }, 596 | { 597 | id: 6, 598 | parent: 1, // Relation with id: 1 599 | pattern: /leave a message/, 600 | message: "Write your message, I will contact you as soon as possible!", 601 | }, 602 | { 603 | id: 7, 604 | parent: 6, // Relation with id: 6 605 | pattern: /.*/, // Match with all text 606 | message: "Thank you very much, your message will be sent to Jordi! Sincerely the Chatbot 🤖 !", 607 | end: true, 608 | }, 609 | ]; 610 | ``` 611 | 612 | ### Example 2 613 | 614 | [doc/examples/conversation2.js](doc/examples/conversation2.js) 615 | 616 | ```javascript 617 | import { buttons, remoteTxt, remoteJson } from "../helpers"; 618 | 619 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 620 | 621 | /** 622 | * Chatbot conversation flow 623 | * Example 2 624 | */ 625 | export default [ 626 | { 627 | id: 1, 628 | parent: 0, 629 | pattern: /.*/, 630 | message: "Hello! I am a Delivery Chatbot.", 631 | description: "Choice one option!", 632 | buttons: buttons([ 633 | "See today's menu?", 634 | "Order directly!", 635 | "Talk to a human!", 636 | ]), 637 | }, 638 | { 639 | id: 2, 640 | parent: 1, // Relation with id: 1 641 | pattern: /menu/, 642 | message: remoteTxt(`${customEndpoint}/menu.txt`), 643 | // message: remoteJson(`${customEndpoint}/menu.json`)[0].message, 644 | end: true, 645 | }, 646 | { 647 | id: 3, 648 | parent: 1, // Relation with id: 1 649 | pattern: /order/, 650 | message: "Make a order!", 651 | link: `${customEndpoint}/delivery-order.php`, 652 | end: true, 653 | }, 654 | { 655 | id: 4, 656 | parent: 1, // Relation with id: 1 657 | pattern: /human/, 658 | message: "Please call the following WhatsApp number: +1 206 555 0100", 659 | end: true, 660 | }, 661 | ]; 662 | ``` 663 | 664 | ### Example 3 665 | 666 | [doc/examples/conversation3.js](doc/examples/conversation3.js) 667 | 668 | ```javascript 669 | import fetch from "sync-fetch"; 670 | import { remoteImg } from "../helpers"; 671 | 672 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 673 | 674 | /** 675 | * Chatbot conversation flow 676 | * Example 3 677 | */ 678 | export default [ 679 | { 680 | id: 1, 681 | parent: 0, 682 | pattern: /.*/, // Match all 683 | message: "Hello! I am a Delivery Chatbot. Send a menu item number!", 684 | }, 685 | { 686 | id: 2, 687 | parent: 0, // Same parent (send reply id=1 and id=2) 688 | pattern: /.*/, // Match all 689 | image: remoteImg(`${customEndpoint}/menu.jpg`), 690 | }, 691 | { 692 | id: 3, 693 | parent: 1, // Relation with id: 1 694 | pattern: /\d+/, // Match any number 695 | message: "You are choice item number $input. How many units do you want?", // Inject input value ($input) in message 696 | }, 697 | { 698 | id: 4, 699 | parent: 2, // Relation with id: 2 700 | pattern: /\d+/, // Match any number 701 | message: "You are choice $input units. How many units do you want?", 702 | // Inject custom code or overwrite output 'message' property before reply 703 | beforeReply(from, input, output, parents) { 704 | // Example check external api and overwrite output 'message' 705 | const response = fetch( 706 | `${customEndpoint}/delivery-check-stock.php/?item=${input}&qty=${parents.pop()}` 707 | ).json(); 708 | return response.stock === 0 709 | ? "Item number $input is not available in this moment!" 710 | : output; 711 | }, 712 | end: true, 713 | }, 714 | ]; 715 | ``` 716 | 717 | ### Example 4 718 | 719 | [doc/examples/conversation4.js](doc/examples/conversation4.js) 720 | 721 | ```javascript 722 | import { remoteImg } from "../helpers"; 723 | 724 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 725 | 726 | /** 727 | * Chatbot conversation flow 728 | * Example 4 729 | */ 730 | export default [ 731 | { 732 | id: 1, 733 | parent: 0, 734 | pattern: /.*/, // Match all 735 | message: "Image local and remote! Send [local] or [remote]", 736 | }, 737 | { 738 | id: 2, 739 | parent: 1, 740 | pattern: /local/, 741 | image: "./images/image1.jpg", 742 | end: true, 743 | }, 744 | { 745 | id: 3, 746 | parent: 1, 747 | pattern: /remote/, 748 | image: remoteImg(`${customEndpoint}/image1.jpg`), 749 | end: true, 750 | }, 751 | ]; 752 | ``` 753 | 754 | ### Example 5 755 | 756 | [doc/examples/conversation5.js](doc/examples/conversation5.js) 757 | 758 | ```javascript 759 | import { remoteImg } from "../helpers"; 760 | 761 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 762 | 763 | /** 764 | * Chatbot conversation flow 765 | * Example 5 766 | */ 767 | export default [ 768 | { 769 | id: 1, 770 | parent: 0, 771 | pattern: /.*/, // Match all 772 | message: "Audio local and remote! Send [local] or [remote]", 773 | }, 774 | { 775 | id: 2, 776 | parent: 1, 777 | pattern: /local/, 778 | audio: "./audios/audio1.mp3", 779 | end: true, 780 | }, 781 | { 782 | id: 3, 783 | parent: 1, 784 | pattern: /remote/, 785 | audio: remoteAudio(`${customEndpoint}/audio1.mp3`), 786 | end: true, 787 | }, 788 | ]; 789 | ``` 790 | 791 | ### Example 6 792 | 793 | [doc/examples/conversation6.js](doc/examples/conversation6.js) 794 | 795 | ```javascript 796 | import fetch from "sync-fetch"; 797 | 798 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 799 | 800 | /** 801 | * Chatbot conversation flow 802 | * Example 6 803 | */ 804 | export default [ 805 | { 806 | id: 1, 807 | parent: 0, 808 | pattern: /.*/, // Match all 809 | message: "", 810 | // Inject custom code or overwrite output 'message' property before reply 811 | beforeReply(from, input, output, parents) { 812 | // Get reply from external api and overwrite output 'message' 813 | const response = fetch(`${customEndpoint}/ai-reply.php/?input=${input}`).json(); 814 | return response.message; 815 | }, 816 | end: true, 817 | }, 818 | ]; 819 | ``` 820 | 821 | ### Example 7 822 | 823 | [doc/examples/conversation7.js](doc/examples/conversation7.js) 824 | 825 | ```javascript 826 | import fetch from "sync-fetch"; 827 | 828 | const customEndpoint = "https://jordifernandes.com/examples/chatbot"; 829 | 830 | /** 831 | * Chatbot conversation flow 832 | * Example 7 833 | */ 834 | export default [ 835 | { 836 | id: 1, 837 | parent: 0, 838 | pattern: /.*/, // Match all 839 | message: "Hello!", 840 | // Inject custom code after reply 841 | afterReply(from, input, parents) { 842 | // Send WhatApp number to external api 843 | const response = fetch(`${customEndpoint}/number-lead.php/`, { 844 | method: "POST", 845 | body: JSON.stringify({ number: from }), 846 | headers: { "Content-Type": "application/json" }, 847 | }).json(); 848 | console.log('response:', response); 849 | }, 850 | end: true, 851 | }, 852 | ]; 853 | ``` 854 | 855 | ### Example 8 856 | 857 | [doc/examples/conversation8.js](doc/examples/conversation8.js) 858 | 859 | ```javascript 860 | import { buttons, inp } from "../helpers"; 861 | 862 | /** 863 | * Chatbot conversation flow 864 | * Example 8 865 | */ 866 | export default [ 867 | { 868 | id: 1, 869 | parent: 0, 870 | pattern: /.*/, 871 | message: "Choice one option", 872 | description: "choice option:", 873 | buttons: buttons(["Option 1", "Option 2"]), 874 | }, 875 | { 876 | id: 2, 877 | parent: 1, 878 | pattern: /.*/, 879 | message: "We have received your request. Thanks.\n\n", 880 | beforeReply(from, input, output, parents) { 881 | output += `Your option: ${inp(2, parents)}`; 882 | return output; 883 | }, 884 | forward: "5512736862295@c.us", // default number or empty 885 | beforeForward(from, forward, input, parents) { // Overwrite forward number 886 | switch (inp(2, parents)) { // Access to replies inputs by id 887 | case "option 1": 888 | forward = "5511994751001@c.us"; 889 | break; 890 | case "option 2": 891 | forward = "5584384738389@c.us"; 892 | break; 893 | default: 894 | forward = "5512736862295@c.us"; 895 | break; 896 | } 897 | return forward; 898 | }, 899 | end: true, 900 | }, 901 | ]; 902 | ``` 903 | 904 | ### Example 9 905 | 906 | [doc/examples/conversation9.js](doc/examples/conversation9.js) 907 | 908 | ```javascript 909 | /** 910 | * Chatbot conversation flow 911 | * Example 9 912 | */ 913 | export default [ 914 | { 915 | id: 1, 916 | parent: 0, 917 | pattern: /.*/, // Match all 918 | message: "", 919 | // Inject custom code or overwrite output 'message' property before reply 920 | beforeReply(from, input, output, parents, media) { 921 | if (media) { 922 | console.log("media buffer", media.buffer); 923 | return `You send file with .${media.extension} extension!`; 924 | } else { 925 | return "Send a picture please!"; 926 | } 927 | }, 928 | end: true, 929 | }, 930 | ]; 931 | ``` 932 | 933 | ### Example 10 934 | 935 | [doc/examples/conversation10.js](doc/examples/conversation10.js) 936 | 937 | ```javascript 938 | import { promises as fs } from "fs"; 939 | 940 | /** 941 | * Chatbot conversation flow 942 | * Example 10 943 | */ 944 | export default [ 945 | { 946 | id: 1, 947 | parent: 0, 948 | pattern: /\b(?!photo\b)\w+/, // different to photo 949 | message: `Write "photo" for starting.`, 950 | }, 951 | { 952 | id: 2, 953 | parent: [0, 1], 954 | pattern: /photo/, 955 | message: `Hi I'm a Chatbot, send a photo(s)`, 956 | }, 957 | { 958 | id: 3, 959 | parent: 2, 960 | pattern: /\b(?!finalize\b)\w+/, // different to finalize 961 | message: "", 962 | async beforeReply(from, input, output, parents, media) { 963 | const uniqId = (new Date()).getTime(); 964 | // Download media 965 | if (media) { 966 | const dirName = "./downloads"; 967 | const fileName = `${uniqId}.${media.extension}`; 968 | const filePath = `${dirName}/${fileName}`; 969 | await fs.mkdir(dirName, { recursive: true }); 970 | await fs.writeFile(filePath, await media.buffer); 971 | return `Photo download successfully! Send another or write "finalize".`; 972 | } else { 973 | return `Try send again or write "finalize".`; 974 | } 975 | }, 976 | goTo(from, input, output, parents, media) { 977 | return 3; // return to id = 3 978 | }, 979 | }, 980 | { 981 | id: 4, 982 | parent: 2, 983 | pattern: /finalize/, 984 | message: "Thank's you!", 985 | end: true, 986 | }, 987 | ]; 988 | ``` 989 | 990 | ### Example 11 991 | 992 | [doc/examples/conversation11.js](doc/examples/conversation11.js) 993 | 994 | ```javascript 995 | import { inp, med } from "../helpers"; 996 | import { promises as fs } from "fs"; 997 | 998 | const menu = "Menu:\n\n" + 999 | "1. Send Text\n" + 1000 | "2. Send Image\n"; 1001 | 1002 | /** 1003 | * Chatbot conversation flow 1004 | * Example 11 1005 | */ 1006 | export default [ 1007 | { 1008 | id: 1, 1009 | parent: 0, 1010 | pattern: /\/admin/, 1011 | from: "5584384738389@c.us", // only respond to this number 1012 | message: menu 1013 | }, 1014 | { 1015 | id: 2, 1016 | parent: [1, 5], 1017 | pattern: /.*/, 1018 | message: "", 1019 | async beforeReply(from, input, output, parents, media) { 1020 | switch (input) { 1021 | case "1": 1022 | return `Write your text:`; 1023 | case "2": 1024 | return `Send your image:`; 1025 | } 1026 | }, 1027 | }, 1028 | { 1029 | id: 3, 1030 | parent: 2, 1031 | pattern: /.*/, 1032 | message: `Write "/save" to save or cancel with "/cancel".`, 1033 | }, 1034 | { 1035 | id: 4, 1036 | parent: 3, 1037 | pattern: /\/save/, 1038 | message: "", 1039 | async beforeReply(from, input, output, parents, media) { 1040 | let txt = ""; 1041 | let img = null; 1042 | let filePath = null; 1043 | const type = inp(2, parents); 1044 | if (type === "1") { 1045 | txt = inp(3, parents); 1046 | } else if (type === "2") { 1047 | img = med(3, parents); // media from parent replies 1048 | } 1049 | if (img) { 1050 | const uniqId = new Date().getTime(); 1051 | const dirName = "."; 1052 | const fileName = `${uniqId}.${img.extension}`; 1053 | filePath = `${dirName}/${fileName}`; 1054 | await fs.writeFile(filePath, await img.buffer); 1055 | } else { 1056 | const uniqId = new Date().getTime(); 1057 | const dirName = "."; 1058 | const fileName = `${uniqId}.txt`; 1059 | await fs.writeFile(filePath, txt); 1060 | } 1061 | return `Ok, text or image saved. Thank you very much!`; 1062 | }, 1063 | end: true, 1064 | }, 1065 | { 1066 | id: 5, 1067 | parent: 3, 1068 | pattern: /\/cancel/, 1069 | message: menu, 1070 | goTo(from, input, output, parents, media) { 1071 | return 2; 1072 | }, 1073 | clearParents: true, // reset parents 1074 | }, 1075 | ]; 1076 | ``` 1077 | 1078 | ## Advanced 1079 | 1080 | ### Multiple Conversation Flows 1081 | 1082 | Edit `./src/main.js` file. 1083 | 1084 | ```javascript 1085 | import { session } from "./core"; 1086 | import info from "./conversations/info"; 1087 | import delivery from "./conversations/delivery"; 1088 | 1089 | session("chatbotSession", info); 1090 | session("chatbotSession", delivery); 1091 | ``` 1092 | 1093 | ### Multiple Accounts 1094 | 1095 | Edit `./src/main.js` file. 1096 | 1097 | ```javascript 1098 | import { session } from "./core"; 1099 | import commercial from "./conversations/commercial"; 1100 | import delivery from "./conversations/delivery"; 1101 | 1102 | session("commercial_1", commercial); 1103 | session("commercial_2", commercial); 1104 | session("delivery", delivery); 1105 | ``` 1106 | 1107 | Edit `./src/httpCtrl.js` file. 1108 | 1109 | ```javascript 1110 | import { httpCtrl } from "./core"; 1111 | 1112 | httpCtrl("commercial_1", 3000); 1113 | httpCtrl("commercial_2", 3001); 1114 | httpCtrl("delivery", 3002); 1115 | 1116 | ``` 1117 | 1118 | ### Access to Venom client 1119 | 1120 | Edit `./src/main.js` file. 1121 | 1122 | ```javascript 1123 | import { session } from "./core"; 1124 | import conversation from "./conversations/conversation"; 1125 | 1126 | // Run conversation flow and return a Venom client 1127 | const chatbot = await session("chatbotSession", conversation); 1128 | 1129 | ``` 1130 | 1131 | ### Schedule Jobs 1132 | 1133 | Edit `./src/main.js` file. 1134 | 1135 | ```javascript 1136 | import schedule from "node-schedule"; // Add node-schedule in your project 1137 | import { session, log } from "./core"; 1138 | import { jobsOptions } from "./config"; 1139 | import conversation from "./conversations/conversation"; 1140 | 1141 | // Run conversation flow and return a Venom client 1142 | const chatbot = await session("chatbotSession", conversation); 1143 | 1144 | const job1 = schedule.scheduleJob( 1145 | jobsOptions.job1.rule, // "*/15 * * * *" 1146 | async () => { 1147 | // custom logic example 1148 | await chatbot.sendText("000000000000@c.us", "test"); 1149 | } 1150 | ); 1151 | ``` 1152 | 1153 | ## Testing 1154 | 1155 | Unit tests writes with [jest](https://jestjs.io) 1156 | ```bash 1157 | $ yarn test 1158 | ``` 1159 | 1160 | Test you conversation flow array structure with [conversation.test.js](/src/conversations/conversation.test.js) file as example. 1161 | ```bash 1162 | $ yarn test src/conversations/conversation 1163 | ``` 1164 | 1165 | ## Troubleshooting 1166 | 1167 | >**Attention:** Do not log in to whatsapp web with the same account that the chatbot uses. This will make the chatbot unable to hear the messages. 1168 | 1169 | >**Attention:** You need a whatsapp account for the chatbot and a different account to be able to talk to it. 1170 | 1171 | ## Donate 1172 | 1173 | [https://jordifernandes.com/donate/](https://jordifernandes.com/donate/) 1174 | 1175 | ## License 1176 | 1177 | [MIT License](LICENSE) 1178 | 1179 | ## Contributing 1180 | 1181 | Pull requests are welcome :) 1182 | 1183 | ## Contributors 1184 | 1185 | - [Jordi Fernandes (@jfadev)](https://github.com/jfadev) --------------------------------------------------------------------------------