diff --git a/.env.example b/.env.example index b86384f..c5be3f2 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,6 @@ BUSINESS_PHONE_NUMBER_ID=12345678912323 DIFY_BASE_URL=https://api.dify.ai/v1 DIFY_API_KEY=app-Jdasdsdsd98n98787y RASA_BASE_URL=http://localhost:5005/webhooks/rest/webhook -CONNECTION_PLATFORM=dify \ No newline at end of file +CONNECTION_PLATFORM=dify +SESSION_DATABASE=in-memory # in-memory | redis +REDIS_URL=redis://0.0.0.0:6379 \ No newline at end of file diff --git a/.gitignore b/.gitignore index fe2c6b6..b2c5e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ node_modules/ dist # test -coverage \ No newline at end of file +coverage + +# Redis +redis-data \ No newline at end of file diff --git a/README.md b/README.md index cd918a9..0451986 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,13 @@ Here is the diagram to understand the flow: ![diagram](./docs/diagram.png) ## Features + - Webhook endpoint to receive messages from WhatsApp - Integration with Dify and Rasa for natural language processing and response generation - Verification of incoming webhook requests ## Prerequisites + Ensure you have the following installed: Node.js (v18.x or later) @@ -21,32 +23,40 @@ npm (v10.x or later) ## Getting Started 1. Clone the Repository + ```bash git clone https://github.com/yourusername/whatsapp-chatbot-connector.git cd whatsapp-chatbot-connector ``` 2. Install Dependencies + ```bash npm install ``` 3. Set Environment Variables + ```bash cp .env.example .env ``` + then fill in the appropriate values in the .env file. -Choose to connect to which platform by filling the `CONNECTION_PLATFORM` variable in the .env file. +Choose to connect to which platform by filling the `CONNECTION_PLATFORM` variable in the .env file. + ``` CONNECTION_PLATFORM=dify ``` + or + ``` CONNECTION_PLATFORM=rasa ``` 4. Running the Server + ```bash npm run dev ``` @@ -63,19 +73,21 @@ Now just use your WhatsApp app to send a text message to the WhatsApp Business n ## Environment Variables -| Variable Name | Description | Example | -| --- | --- | --- | -| NODE_ENV | Environment variable to set the node environment. | development | -| WEBHOOK_VERIFY_TOKEN | Webhook verification token. The value should be the same as the one you set in the WhatsApp Business API. Detail in picture below, poin **number 2** -| GRAPH_API_TOKEN | Graph API token. The value should be the same as the one you set in the WhatsApp Business API. Detail in picture below, poin **number 5** | abacdefghijk | -| BUSINESS_PHONE_NUMBER_ID | Business phone number ID. The value should be obtained from WhatsApp Business API. Detail in picture below, poin **number 6** | 12345678912323 | -| DIFY_BASE_URL | Dify base URL. | https://api.dify.ai/v1 | -| DIFY_API_KEY | Dify API key. | app-Jdasdsdsd98n98787y | -| RASA_BASE_URL | Rasa base URL. | http://localhost:5005/webhooks/rest/webhook | -| CONNECTION_PLATFORM | Platform to connect to. value should be `dify` or `rasa` | dify | - +| Variable Name | Description | Example | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| NODE_ENV | Environment variable to set the node environment. | development | +| WEBHOOK_VERIFY_TOKEN | Webhook verification token. The value should be the same as the one you set in the WhatsApp Business API. Detail in picture below, poin **number 2** | +| GRAPH_API_TOKEN | Graph API token. The value should be the same as the one you set in the WhatsApp Business API. Detail in picture below, poin **number 5** | abacdefghijk | +| BUSINESS_PHONE_NUMBER_ID | Business phone number ID. The value should be obtained from WhatsApp Business API. Detail in picture below, poin **number 6** | 12345678912323 | +| DIFY_BASE_URL | Dify base URL. | https://api.dify.ai/v1 | +| DIFY_API_KEY | Dify API key. | app-Jdasdsdsd98n98787y | +| RASA_BASE_URL | Rasa base URL. | http://localhost:5005/webhooks/rest/webhook | +| CONNECTION_PLATFORM | Platform to connect to. value should be `dify` or `rasa` | dify | +| SESSION_DATABASE | Database to store session. value should be `in-memory` or `redis` | in-memory | +| REDIS_URL | Redis URL. Required if `SESSION_DATABASE` is set to `redis` | redis://localhost:6379 | ## WhatsApp Business API Configuration + ![configuration](./docs/whatsapp-configuration.png) ![api setup](./docs/whatsap-api-setup.png) @@ -84,4 +96,5 @@ Now just use your WhatsApp app to send a text message to the WhatsApp Business n You can deploy this app to any server that runs Node.js. The easiest one is to use Vercel. Just clone this repo and connect it from Vercel Dashboard then you are good to go. ## License -This project is licensed under the MIT License. See the LICENSE file for details. \ No newline at end of file + +This project is licensed under the MIT License. See the LICENSE file for details. diff --git a/docker-compose.yaml b/docker-compose.yaml index 028cbe5..3cbb7c1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,9 +4,18 @@ name: wa-connector services: wa-connector: image: wa-connector:latest - container_name: wa-connector + container_name: wa-connector ports: - - "5007:5007" + - "5007:5007" env_file: - .env # Load environment variables from the .env file + redis: + image: redis:latest + container_name: redis + ports: + - "6379:6379" + volumes: + - ./redis-data:/data + - ./redis.conf:/usr/local/etc/redis/redis.conf + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] diff --git a/package-lock.json b/package-lock.json index 81f8ac4..077bf0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "morgan": "^1.10.0", - "ts-node": "^10.9.2" + "redis": "^4.6.15", + "ts-node": "^10.9.2", + "zod": "^3.23.8" }, "devDependencies": { "@eslint/js": "^8.56.0", @@ -1477,6 +1479,64 @@ "node": ">= 8" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.5.17", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.17.tgz", + "integrity": "sha512-IPvU9A31qRCZ7lds/x+ksuK/UMndd0EASveAvCvEtFFKIZjZ+m/a4a0L7S28KEWoR5ka8526hlSghDo4Hrc2Hg==", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", + "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", + "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", + "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1966,6 +2026,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -2346,6 +2419,14 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3229,6 +3310,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3237,6 +3332,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3996,42 +4099,6 @@ "fsevents": "^2.3.2" } }, - "node_modules/jest-haste-map/node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/jest-haste-map/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/jest-haste-map/node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/jest-leak-detector": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", @@ -4667,6 +4734,15 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", "dev": true }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -5114,6 +5190,22 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/redis": { + "version": "4.6.15", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.15.tgz", + "integrity": "sha512-2NtuOpMW3tnYzBw6S8mbXSX7RPzvVFCA2wFJq9oErushO2UeBkxObk+uvo7gv7n0rhWeOj/IzrHO8TjcFlRSOg==", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.5.17", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.6", + "@redis/search": "1.1.6", + "@redis/time-series": "1.0.5" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6160,6 +6252,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a46905c..3c6bac6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "morgan": "^1.10.0", - "ts-node": "^10.9.2" + "redis": "^4.6.15", + "ts-node": "^10.9.2", + "zod": "^3.23.8" }, "devDependencies": { "@eslint/js": "^8.56.0", diff --git a/redis.conf b/redis.conf new file mode 100644 index 0000000..b5189c6 --- /dev/null +++ b/redis.conf @@ -0,0 +1,8 @@ +# Disable RDB snapshots for this configuration +save "" + +# Enable append-only file (AOF) persistence +appendonly yes + +# Append data to the AOF file after every write operation +appendfsync always diff --git a/services/dify.ts b/services/dify.ts index b1ead35..367d4b9 100644 --- a/services/dify.ts +++ b/services/dify.ts @@ -1,7 +1,7 @@ import axios from "axios"; import dotenv from "dotenv"; import { Request } from "express"; -import { getUserSession, setUserSession } from "./session"; +import { getSession, setSession } from "./session"; dotenv.config(); @@ -47,7 +47,7 @@ export const queryToDify = async ({ query: string; }) => { const waId = req.body.entry?.[0]?.changes[0]?.value?.contacts?.[0]?.wa_id; - const user = getUserSession(waId); + const user = await getSession(waId); const res = await sendQuery({ userId: waId, @@ -56,11 +56,8 @@ export const queryToDify = async ({ }); if (!user) { - setUserSession({ - id: waId, - conversationId: res.data.conversation_id, - }); + await setSession({ waId: waId, conversationId: res.data.conversation_id }); } - return {text: res.data.answer}; + return { text: res.data.answer }; }; diff --git a/services/session.ts b/services/session.ts index b002c4b..5c0d27f 100644 --- a/services/session.ts +++ b/services/session.ts @@ -1,16 +1,40 @@ -// TODO: save users session in redis or database -type UserType = { id: string; conversationId: string }; -const users: UserType[] = []; - -// Function to set user data in the session -export function setUserSession(user: UserType) { - users.push(user); - return; +import { z } from "zod"; + +import { config } from "../utils/config"; +import InMemoryDatabase from "../utils/session/in-memory"; +import redis from "../utils/session/redis"; + +const schema = z.object({ + waId: z.string(), + conversationId: z.string(), +}); + +type Session = z.input; + +const inMemory = new InMemoryDatabase(); + +export async function setSession(session: Session) { + if (config.SESSION_DATABASE === "in-memory") { + inMemory.set(session.waId, session); + } else if (config.SESSION_DATABASE === "redis") { + await redis.set(session.waId, JSON.stringify(session)); + } } -// Function to get user data from the session -export function getUserSession( - userId: string -): { [key: string]: string } | null { - return users.find((user) => user.id === userId) || null; +export async function getSession(waId: Session["waId"]) { + if (config.SESSION_DATABASE === "in-memory") { + const item = inMemory.get(waId); + if (item === null) return null; + + const result = await schema.parseAsync(item); + + return result; + } else if (config.SESSION_DATABASE === "redis") { + const item = await redis.get(waId); + if (item === null) return null; + + const result = await schema.parseAsync(JSON.parse(item)); + + return result; + } } diff --git a/utils/config.ts b/utils/config.ts new file mode 100644 index 0000000..dc3775c --- /dev/null +++ b/utils/config.ts @@ -0,0 +1,14 @@ +import dotenv from "dotenv"; +import { z } from "zod"; + +dotenv.config(); + +const schema = z.object({ + NODE_ENV: z + .enum(["test", "development", "production"]) + .default("development"), + SESSION_DATABASE: z.enum(["in-memory", "redis"]).default("in-memory"), + REDIS_URL: z.string().default("redis://localhost:6379"), +}); + +export const config = schema.parse(process.env); diff --git a/utils/session/in-memory.ts b/utils/session/in-memory.ts new file mode 100644 index 0000000..93b71d7 --- /dev/null +++ b/utils/session/in-memory.ts @@ -0,0 +1,15 @@ +export default class InMemoryDatabase { + private storage: { [key: string]: T }; + + constructor() { + this.storage = {}; + } + + get(key: string) { + return this.storage[key] || null; + } + + set(key: string, value: T) { + this.storage[key] = value; + } +} diff --git a/utils/session/redis.ts b/utils/session/redis.ts new file mode 100644 index 0000000..0509211 --- /dev/null +++ b/utils/session/redis.ts @@ -0,0 +1,14 @@ +import { createClient } from "redis"; +import { config } from "../config"; + +const redis = createClient({ url: config.REDIS_URL }); + +if (config.SESSION_DATABASE === "redis") { + redis.on("error", (err) => console.error("Redis Client Error", err)); + + (async () => { + await redis.connect(); + })(); +} + +export default redis;