├── public └── .gitignore ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── src ├── auth │ ├── interface.ts │ ├── router.ts │ ├── entities │ │ ├── credential.ts │ │ └── state.ts │ ├── controller.ts │ └── service.ts ├── util │ ├── http-status.ts │ ├── response.ts │ ├── validator.ts │ ├── handler.ts │ └── baileys.ts ├── db │ ├── entities.ts │ ├── datasource.ts │ ├── migrations │ │ ├── 1702200040189-add-deleted-data.ts │ │ ├── 1701850059394-unique.ts │ │ ├── 1702084032175-messages.ts │ │ ├── 1701934832212-state-primary-column.ts │ │ └── 1701839162576-auth.ts │ └── migrations.ts ├── whatsapp │ ├── dto │ │ └── message.dto.ts │ ├── service.ts │ ├── router.ts │ ├── services │ │ ├── file.ts │ │ └── db.ts │ ├── validator │ │ └── message.ts │ ├── entities │ │ └── message.ts │ ├── interface.ts │ ├── bases │ │ ├── message.ts │ │ ├── media.ts │ │ └── service.ts │ └── controller.ts ├── app.ts ├── config │ └── config.ts └── index.ts ├── example.env ├── tsconfig.json ├── LICENSE ├── .eslintrc.js ├── webpack.config.js ├── README.md └── package.json /public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | whatsapp_session 3 | sessions 4 | dist 5 | .env* -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | echo "Formating..." 5 | npm run format 6 | npm run lint 7 | 8 | echo "Checking..." 9 | npm run build 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "arrowParens": "avoid", 4 | "singleQuote": true, 5 | "semi": false, 6 | "useTabs": false, 7 | "printWidth": 120, 8 | "tabWidth": 4, 9 | "bracketSpacing": true, 10 | "bracketSameLine": false 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/interface.ts: -------------------------------------------------------------------------------- 1 | export interface Session { 2 | id: number 3 | active: boolean 4 | createdAt: Date 5 | updatedAt: Date 6 | connected: boolean 7 | user?: { 8 | name?: string 9 | phone?: string 10 | } 11 | platform?: string 12 | } 13 | -------------------------------------------------------------------------------- /src/util/http-status.ts: -------------------------------------------------------------------------------- 1 | export enum HttpStatus { 2 | OK = 200, 3 | Created = 201, 4 | Accepted = 202, 5 | NoContent = 204, 6 | BadRequest = 400, 7 | Unauthorized = 401, 8 | Forbidden = 403, 9 | NotFound = 404, 10 | MethodNotAllowed = 405, 11 | Conflict = 409, 12 | InternalServerError = 500, 13 | } 14 | -------------------------------------------------------------------------------- /src/db/entities.ts: -------------------------------------------------------------------------------- 1 | import { AuthCredential } from 'src/auth/entities/credential' 2 | import { AuthState } from 'src/auth/entities/state' 3 | import { Message } from 'src/whatsapp/entities/message' 4 | 5 | /** 6 | * add manual entities that has been created so webpack can compile it 7 | */ 8 | export const entities = [AuthCredential, AuthState, Message] 9 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | APPLICATION_NAME=string|optional 2 | APPLICATION_VERSION=string|optional 3 | PORT=number|optional 4 | 5 | QR_TERMINAL=true,false|optional 6 | BOT_PASSWORD=string|optional 7 | 8 | # if db env is set, the wa auth method will be set to postgres 9 | DB_HOST=string|optional 10 | DB_PORT=number|optional 11 | DB_USERNAME=string|optional 12 | DB_PASSWORD=string|optional 13 | DB_NAME=string|optional -------------------------------------------------------------------------------- /src/util/response.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | import { HttpStatus } from './http-status' 3 | 4 | type DataType = T extends void | null | undefined | Promise ? never : T 5 | 6 | interface Json { 7 | data: T 8 | status: HttpStatus 9 | } 10 | 11 | export type ResponseJson = Response> 12 | 13 | export function responseJson(res: ResponseJson, data: DataType, status = HttpStatus.OK): ResponseJson { 14 | return res.status(status).json({ 15 | data, 16 | status, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/whatsapp/dto/message.dto.ts: -------------------------------------------------------------------------------- 1 | interface SendDto { 2 | sendTo: string 3 | } 4 | 5 | export interface SendTextDto extends SendDto { 6 | message: string 7 | } 8 | 9 | export interface SendContactDto extends SendDto { 10 | name: string 11 | phoneNumber: string 12 | } 13 | 14 | export interface SendLocationDto extends SendDto { 15 | lat: number 16 | long: number 17 | } 18 | 19 | export interface SendFileDto extends SendDto { 20 | caption?: string 21 | file: Buffer 22 | fileName: string 23 | mimetype: string 24 | } 25 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import authRouter from './auth/router' 3 | import { errorHandler } from './util/handler' 4 | import { getGroupData, logout, printQR, status } from './whatsapp/controller' 5 | import whatsappRouter from './whatsapp/router' 6 | 7 | const app = express() 8 | 9 | app.use(express.json()) 10 | 11 | app.get('/status', status) 12 | app.get('/qr-code', printQR) 13 | app.delete('/logout', logout) 14 | 15 | app.get('/group/:id', getGroupData) 16 | 17 | app.use('/send', whatsappRouter) 18 | app.use('/auth', authRouter) 19 | 20 | app.use(errorHandler) 21 | 22 | export default app 23 | -------------------------------------------------------------------------------- /src/whatsapp/service.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION_NAME, APPLICATION_VERSION, WA_AUTH_METHOD } from 'src/config/config' 2 | import { WhatsappServiceDBAuth } from './services/db' 3 | import { WhatsappServiceFileAuth } from './services/file' 4 | import { WhatsappBaseService } from './bases/service' 5 | 6 | let whatsappService: WhatsappBaseService 7 | 8 | if (WA_AUTH_METHOD === 'db') { 9 | whatsappService = new WhatsappServiceDBAuth(APPLICATION_NAME, APPLICATION_VERSION) 10 | } else { 11 | whatsappService = new WhatsappServiceFileAuth(APPLICATION_NAME, APPLICATION_VERSION) 12 | } 13 | 14 | export default whatsappService 15 | -------------------------------------------------------------------------------- /src/db/datasource.ts: -------------------------------------------------------------------------------- 1 | import { DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USERNAME } from 'src/config/config' 2 | import { DataSource } from 'typeorm' 3 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies' 4 | import { entities } from './entities' 5 | import { migrations } from './migrations' 6 | 7 | export default new DataSource({ 8 | type: 'postgres', 9 | host: DB_HOST, 10 | port: DB_PORT, 11 | username: DB_USERNAME, 12 | password: DB_PASSWORD, 13 | database: DB_NAME, 14 | entities, 15 | migrations, 16 | namingStrategy: new SnakeNamingStrategy(), 17 | synchronize: false, 18 | migrationsRun: true, 19 | }) 20 | -------------------------------------------------------------------------------- /src/db/migrations/1702200040189-add-deleted-data.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class AddDeletedData1702200040189 implements MigrationInterface { 4 | name = 'AddDeletedData1702200040189' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(` 8 | ALTER TABLE "messages" 9 | ADD "is_deleted" boolean NOT NULL DEFAULT false 10 | `) 11 | } 12 | 13 | public async down(queryRunner: QueryRunner): Promise { 14 | await queryRunner.query(` 15 | ALTER TABLE "messages" DROP COLUMN "is_deleted" 16 | `) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/whatsapp/router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import multer from 'multer' 3 | import { sendContact, sendFile, sendImage, sendLocation, sendText } from 'src/whatsapp/controller' 4 | 5 | const storage = multer.memoryStorage() 6 | const upload = multer({ storage: storage }) 7 | 8 | const whatsappRouter = Router() 9 | 10 | whatsappRouter.post('/text', sendText) 11 | whatsappRouter.post('/contact', sendContact) 12 | whatsappRouter.post('/location', sendLocation) 13 | 14 | whatsappRouter.post('/file', upload.fields([{ name: 'file', maxCount: 1 }]), sendFile) 15 | whatsappRouter.post('/image', upload.fields([{ name: 'file', maxCount: 1 }]), sendImage) 16 | 17 | export default whatsappRouter 18 | -------------------------------------------------------------------------------- /src/db/migrations/1701850059394-unique.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class Unique1701850059394 implements MigrationInterface { 4 | name = 'Unique1701850059394' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(` 8 | ALTER TABLE "states" 9 | ADD CONSTRAINT "UQ_8d93053db706b4f57d703cb94be" UNIQUE ("credential_id", "key") 10 | `) 11 | } 12 | 13 | public async down(queryRunner: QueryRunner): Promise { 14 | await queryRunner.query(` 15 | ALTER TABLE "states" DROP CONSTRAINT "UQ_8d93053db706b4f57d703cb94be" 16 | `) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/db/migrations.ts: -------------------------------------------------------------------------------- 1 | import { Auth1701839162576 } from './migrations/1701839162576-auth' 2 | import { Unique1701850059394 } from './migrations/1701850059394-unique' 3 | import { StatePrimaryColumn1701934832212 } from './migrations/1701934832212-state-primary-column' 4 | import { Messages1702084032175 } from './migrations/1702084032175-messages' 5 | import { AddDeletedData1702200040189 } from './migrations/1702200040189-add-deleted-data' 6 | 7 | /** 8 | * add manual migrations that has been created so webpack can compile it 9 | */ 10 | export const migrations = [ 11 | Auth1701839162576, 12 | Unique1701850059394, 13 | StatePrimaryColumn1701934832212, 14 | Messages1702084032175, 15 | AddDeletedData1702200040189, 16 | ] 17 | -------------------------------------------------------------------------------- /src/whatsapp/services/file.ts: -------------------------------------------------------------------------------- 1 | import { useMultiFileAuthState } from '@whiskeysockets/baileys' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import { WhatsappBaseService } from '../bases/service' 5 | import { AuthState } from '../interface' 6 | 7 | export class WhatsappServiceFileAuth extends WhatsappBaseService { 8 | private session_directory = 'sessions' 9 | 10 | protected async makeAuthState(): Promise { 11 | return useMultiFileAuthState(this.session_directory) 12 | } 13 | 14 | protected async removeSession(): Promise { 15 | try { 16 | fs.rmSync(path.resolve(process.cwd(), this.session_directory), { recursive: true, force: true }) 17 | } catch {} 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/util/validator.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi' 2 | 3 | export async function joiValidate(schema: ObjectSchema, data: any): Promise { 4 | return await schema.validateAsync(data) 5 | } 6 | 7 | export async function joiValidateNumber(val: any): Promise { 8 | val = +val 9 | 10 | const schema = Joi.object({ 11 | val: Joi.number().required(), 12 | }) 13 | await schema.validateAsync({ 14 | val, 15 | }) 16 | 17 | return val 18 | } 19 | 20 | export async function joiValidateString(val: any): Promise { 21 | const schema = Joi.object({ 22 | val: Joi.string().required(), 23 | }) 24 | await schema.validateAsync({ 25 | val, 26 | }) 27 | 28 | return `${val}` 29 | } 30 | -------------------------------------------------------------------------------- /src/auth/router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { WA_AUTH_METHOD } from 'src/config/config' 3 | import { activateSession, deactivateSession, getAllSessions } from './controller' 4 | import { responseJson } from 'src/util/response' 5 | import { HttpStatus } from 'src/util/http-status' 6 | 7 | const authRouter = Router() 8 | 9 | authRouter.use((_req, res, next) => { 10 | if (WA_AUTH_METHOD === 'file') { 11 | return responseJson(res, 'authentication is not using a database session', HttpStatus.InternalServerError) 12 | } 13 | 14 | return next() 15 | }) 16 | 17 | authRouter.get('/', getAllSessions) 18 | authRouter.put('/:id/activate', activateSession) 19 | authRouter.put('/:id/deactivate', deactivateSession) 20 | 21 | export default authRouter 22 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import { config as loadConfig } from 'dotenv' 2 | 3 | loadConfig() 4 | 5 | const { APPLICATION_NAME, APPLICATION_VERSION, DB_HOST, DB_USERNAME, DB_PASSWORD, DB_NAME } = process.env 6 | 7 | const QR_TERMINAL = process.env.QR_TERMINAL?.toLowerCase() === 'true' 8 | const PORT = +process.env.PORT || 5000 9 | const BOT_PASSWORD = process.env.BOT_PASSWORD?.trim() 10 | const DB_PORT = +process.env.DB_PORT 11 | 12 | const WA_AUTH_METHOD: 'db' | 'file' = DB_HOST && DB_NAME && DB_PORT && DB_PASSWORD && DB_USERNAME ? 'db' : 'file' 13 | 14 | export { 15 | PORT, 16 | QR_TERMINAL, 17 | APPLICATION_NAME, 18 | APPLICATION_VERSION, 19 | BOT_PASSWORD, 20 | DB_HOST, 21 | DB_PORT, 22 | DB_USERNAME, 23 | DB_PASSWORD, 24 | DB_NAME, 25 | WA_AUTH_METHOD, 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": false, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": ".", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "esModuleInterop": true 21 | }, 22 | "include": ["src/**/*"], 23 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ffmpegPath from 'ffmpeg-static' 2 | import app from './app' 3 | import { PORT, WA_AUTH_METHOD } from './config/config' 4 | import datasource from './db/datasource' 5 | import whatsappService from './whatsapp/service' 6 | 7 | process.env.FFMPEG_PATH = ffmpegPath 8 | 9 | async function initDB() { 10 | try { 11 | await datasource.initialize() 12 | console.log('Database connected') 13 | } catch (error) { 14 | console.error('error when connecting database') 15 | throw error 16 | } 17 | } 18 | 19 | async function bootstrap() { 20 | if (WA_AUTH_METHOD === 'db') { 21 | await initDB() 22 | } 23 | 24 | try { 25 | await whatsappService.initialize() 26 | } catch (error) { 27 | console.error(`Error when init whatsapp`) 28 | throw error 29 | } 30 | 31 | app.listen(PORT, () => console.log(`Server listen on port ${PORT}`)) 32 | } 33 | 34 | bootstrap() 35 | -------------------------------------------------------------------------------- /src/whatsapp/validator/message.ts: -------------------------------------------------------------------------------- 1 | import joi from 'joi' 2 | import { SendContactDto, SendFileDto, SendLocationDto, SendTextDto } from '../dto/message.dto' 3 | 4 | export const sendTextValidator = joi.object({ 5 | message: joi.string().required(), 6 | sendTo: joi.string().required(), 7 | }) 8 | 9 | export const sendContactValidator = joi.object({ 10 | name: joi.string().required(), 11 | phoneNumber: joi.string().required(), 12 | sendTo: joi.string().required(), 13 | }) 14 | 15 | export const sendLocationValidator = joi.object({ 16 | lat: joi.number().required(), 17 | long: joi.number().required(), 18 | sendTo: joi.string().required(), 19 | }) 20 | 21 | export const sendFileValidator = joi.object({ 22 | caption: joi.string().optional().allow(''), 23 | sendTo: joi.string().required(), 24 | file: joi.binary().encoding('utf-8').required(), 25 | fileName: joi.string().required(), 26 | mimetype: joi.string().required(), 27 | }) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Fauzan 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 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin', 'unused-imports'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | ignorePatterns: ['.eslintrc.js', 'src/db/seeder/indonesia-location.ts'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | '@typescript-eslint/no-unused-vars': 'off', 22 | 'unused-imports/no-unused-imports': 'error', 23 | 'unused-imports/no-unused-vars': [ 24 | 'warn', 25 | { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }, 26 | ], 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const nodeExternal = require('webpack-node-externals') 3 | const TerserPlugin = require('terser-webpack-plugin') 4 | 5 | module.exports = { 6 | entry: './src/index.ts', 7 | mode: 'production', 8 | target: 'node', 9 | output: { 10 | path: path.resolve(__dirname, 'dist'), //set output dir to /build 11 | filename: 'index.js', //name of build app 12 | }, 13 | resolve: { 14 | modules: [path.resolve(__dirname), 'node_modules'], 15 | extensions: ['.ts', '.js', '.json'], 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.ts$/, 21 | exclude: /node_modules/, 22 | loader: 'ts-loader', 23 | }, 24 | ], 25 | }, 26 | externals: [nodeExternal()], 27 | optimization: { 28 | minimizer: [ 29 | new TerserPlugin({ 30 | terserOptions: { 31 | // keep the function/class name (typeorm need this) 32 | keep_fnames: true, 33 | }, 34 | }), 35 | ], 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /src/auth/entities/credential.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationCreds } from '@whiskeysockets/baileys' 2 | import { prepareDataToRead, prepareDataToWrite } from 'src/util/baileys' 3 | import { 4 | AfterInsert, 5 | AfterLoad, 6 | AfterUpdate, 7 | BeforeInsert, 8 | BeforeUpdate, 9 | Column, 10 | CreateDateColumn, 11 | Entity, 12 | PrimaryGeneratedColumn, 13 | UpdateDateColumn, 14 | } from 'typeorm' 15 | 16 | @Entity({ name: 'credentials' }) 17 | export class AuthCredential { 18 | @PrimaryGeneratedColumn() 19 | id: number 20 | 21 | @Column({ nullable: true, type: 'json' }) 22 | value: AuthenticationCreds 23 | 24 | @Column({ default: false }) 25 | active: boolean 26 | 27 | @CreateDateColumn({ type: 'timestamptz' }) 28 | createdAt!: Date 29 | 30 | @UpdateDateColumn({ type: 'timestamptz' }) 31 | updatedAt!: Date 32 | 33 | @BeforeInsert() 34 | @BeforeUpdate() 35 | private parseJsonWrite() { 36 | if (this.value) { 37 | this.value = prepareDataToWrite(this.value) 38 | } 39 | } 40 | 41 | @AfterInsert() 42 | @AfterUpdate() 43 | @AfterLoad() 44 | private parseJsonRead() { 45 | if (this.value) { 46 | this.value = prepareDataToRead(this.value) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/db/migrations/1702084032175-messages.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class Messages1702084032175 implements MigrationInterface { 4 | name = 'Messages1702084032175' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(` 8 | CREATE TABLE "messages" ( 9 | "key" character varying NOT NULL, 10 | "value" json, 11 | "credential_id" integer NOT NULL, 12 | "sender" character varying NOT NULL, 13 | "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), 14 | "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), 15 | CONSTRAINT "PK_c8bdc6479dc84d0717f0c649197" PRIMARY KEY ("key") 16 | ) 17 | `) 18 | await queryRunner.query(` 19 | ALTER TABLE "messages" 20 | ADD CONSTRAINT "FK_70a1de90cea6a7393c0b9e2b5b5" FOREIGN KEY ("credential_id") REFERENCES "credentials"("id") ON DELETE CASCADE ON UPDATE NO ACTION 21 | `) 22 | } 23 | 24 | public async down(queryRunner: QueryRunner): Promise { 25 | await queryRunner.query(` 26 | ALTER TABLE "messages" DROP CONSTRAINT "FK_70a1de90cea6a7393c0b9e2b5b5" 27 | `) 28 | await queryRunner.query(` 29 | DROP TABLE "messages" 30 | `) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/auth/entities/state.ts: -------------------------------------------------------------------------------- 1 | import { prepareDataToRead, prepareDataToWrite } from 'src/util/baileys' 2 | import { 3 | AfterInsert, 4 | AfterLoad, 5 | AfterUpdate, 6 | BeforeInsert, 7 | BeforeUpdate, 8 | Column, 9 | CreateDateColumn, 10 | Entity, 11 | JoinColumn, 12 | ManyToOne, 13 | PrimaryColumn, 14 | UpdateDateColumn, 15 | } from 'typeorm' 16 | import { AuthCredential } from './credential' 17 | 18 | @Entity({ name: 'states' }) 19 | export class AuthState { 20 | @PrimaryColumn() 21 | key: string 22 | 23 | @Column({ nullable: true, type: 'json' }) 24 | value: any 25 | 26 | @ManyToOne(() => AuthCredential, credential => credential.id, { onDelete: 'CASCADE' }) 27 | @JoinColumn() 28 | credential: AuthCredential 29 | 30 | @Column() 31 | credentialId: number 32 | 33 | @CreateDateColumn({ type: 'timestamptz' }) 34 | createdAt!: Date 35 | 36 | @UpdateDateColumn({ type: 'timestamptz' }) 37 | updatedAt!: Date 38 | 39 | @BeforeInsert() 40 | @BeforeUpdate() 41 | private parseJsonWrite() { 42 | if (this.value) { 43 | this.value = prepareDataToWrite(this.value) 44 | } 45 | } 46 | 47 | @AfterInsert() 48 | @AfterUpdate() 49 | @AfterLoad() 50 | private parseJsonRead() { 51 | if (this.value) { 52 | this.value = prepareDataToRead(this.value) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/auth/controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'express' 2 | import { responseJson } from 'src/util/response' 3 | import { joiValidateNumber } from 'src/util/validator' 4 | import authService from './service' 5 | import { HttpStatus } from 'src/util/http-status' 6 | 7 | export const getAllSessions: Handler = async (_req, res, next) => { 8 | try { 9 | const data = await authService.getSessions() 10 | return responseJson(res, data) 11 | } catch (error) { 12 | next(error) 13 | } 14 | } 15 | 16 | export const activateSession: Handler = async (req, res, next) => { 17 | try { 18 | const id = await joiValidateNumber(req.params.id) 19 | const data = await authService.activateSession(id) 20 | 21 | if (!data) { 22 | return responseJson(res, 'session already active', HttpStatus.Forbidden) 23 | } 24 | 25 | return responseJson(res, data) 26 | } catch (error) { 27 | next(error) 28 | } 29 | } 30 | 31 | export const deactivateSession: Handler = async (req, res, next) => { 32 | try { 33 | const id = await joiValidateNumber(req.params.id) 34 | const data = await authService.deactivateSession(id) 35 | 36 | if (!data) { 37 | return responseJson(res, 'session not active yet', HttpStatus.Forbidden) 38 | } 39 | 40 | return responseJson(res, data) 41 | } catch (error) { 42 | next(error) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/db/migrations/1701934832212-state-primary-column.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class StatePrimaryColumn1701934832212 implements MigrationInterface { 4 | name = 'StatePrimaryColumn1701934832212' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(` 8 | ALTER TABLE "states" DROP CONSTRAINT "UQ_8d93053db706b4f57d703cb94be" 9 | `) 10 | await queryRunner.query(` 11 | ALTER TABLE "states" DROP CONSTRAINT "PK_09ab30ca0975c02656483265f4f" 12 | `) 13 | await queryRunner.query(` 14 | ALTER TABLE "states" DROP COLUMN "id" 15 | `) 16 | await queryRunner.query(` 17 | ALTER TABLE "states" 18 | ADD CONSTRAINT "PK_e48c775a333c28b421c470a4857" PRIMARY KEY ("key") 19 | `) 20 | } 21 | 22 | public async down(queryRunner: QueryRunner): Promise { 23 | await queryRunner.query(` 24 | ALTER TABLE "states" DROP CONSTRAINT "PK_e48c775a333c28b421c470a4857" 25 | `) 26 | await queryRunner.query(` 27 | ALTER TABLE "states" 28 | ADD "id" SERIAL NOT NULL 29 | `) 30 | await queryRunner.query(` 31 | ALTER TABLE "states" 32 | ADD CONSTRAINT "PK_09ab30ca0975c02656483265f4f" PRIMARY KEY ("id") 33 | `) 34 | await queryRunner.query(` 35 | ALTER TABLE "states" 36 | ADD CONSTRAINT "UQ_8d93053db706b4f57d703cb94be" UNIQUE ("key", "credential_id") 37 | `) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/whatsapp/entities/message.ts: -------------------------------------------------------------------------------- 1 | import { AuthCredential } from 'src/auth/entities/credential' 2 | import { prepareDataToRead, prepareDataToWrite } from 'src/util/baileys' 3 | import { 4 | AfterInsert, 5 | AfterLoad, 6 | AfterUpdate, 7 | BeforeInsert, 8 | BeforeUpdate, 9 | Column, 10 | CreateDateColumn, 11 | Entity, 12 | JoinColumn, 13 | ManyToOne, 14 | PrimaryColumn, 15 | UpdateDateColumn, 16 | } from 'typeorm' 17 | import { WhatsappMessage } from '../interface' 18 | 19 | @Entity({ name: 'messages' }) 20 | export class Message { 21 | @PrimaryColumn() 22 | key: string 23 | 24 | @Column({ nullable: true, type: 'json' }) 25 | value: WhatsappMessage 26 | 27 | @ManyToOne(() => AuthCredential, credential => credential.id, { onDelete: 'CASCADE' }) 28 | @JoinColumn() 29 | credential: AuthCredential 30 | 31 | @Column() 32 | credentialId: number 33 | 34 | @Column() 35 | sender: string 36 | 37 | @Column({ default: false }) 38 | isDeleted: boolean 39 | 40 | @CreateDateColumn({ type: 'timestamptz' }) 41 | createdAt!: Date 42 | 43 | @UpdateDateColumn({ type: 'timestamptz' }) 44 | updatedAt!: Date 45 | 46 | @BeforeInsert() 47 | @BeforeUpdate() 48 | private parseJsonWrite() { 49 | if (this.value) { 50 | this.value = prepareDataToWrite(this.value) 51 | } 52 | } 53 | 54 | @AfterInsert() 55 | @AfterUpdate() 56 | @AfterLoad() 57 | private parseJsonRead() { 58 | if (this.value) { 59 | this.value = prepareDataToRead(this.value) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/whatsapp/interface.ts: -------------------------------------------------------------------------------- 1 | import makeWASocket, { 2 | AnyMessageContent, 3 | AuthenticationState, 4 | Contact, 5 | GroupMetadata, 6 | GroupParticipant as GroupMember, 7 | WAMessageUpdate, 8 | proto, 9 | } from '@whiskeysockets/baileys' 10 | 11 | export type WhatsappSocket = ReturnType 12 | 13 | export class WhatsappError extends Error { 14 | constructor(error: string) { 15 | super(error) 16 | } 17 | } 18 | 19 | export interface StatusWhatsappService { 20 | isConnected: boolean 21 | contactConnected?: Contact 22 | qrcode?: string 23 | } 24 | 25 | export interface WhatsappMessageQuoted { 26 | sendToJid?: string 27 | message?: string 28 | } 29 | 30 | export interface WhatsappMessage extends proto.IWebMessageInfo { 31 | quoted?: WhatsappMessageQuoted 32 | } 33 | 34 | export interface WhatsappMessageUpdate extends WAMessageUpdate { 35 | update: Partial 36 | } 37 | 38 | export interface AuthState { 39 | state: AuthenticationState 40 | saveCreds: () => Promise 41 | } 42 | 43 | export interface ExtractStickerMediaData { 44 | message: AnyMessageContent 45 | targetJid: string 46 | } 47 | 48 | export interface ExtractViewOnceMediaData { 49 | message: { 50 | forward: WhatsappMessage 51 | force?: boolean 52 | } 53 | targetJid: string 54 | } 55 | 56 | export interface ValueMessageMedia { 57 | media: proto.Message.IImageMessage | proto.Message.IVideoMessage 58 | type: 'image' | 'video' 59 | viewOnce: boolean 60 | } 61 | 62 | export type NewMessageListener = (message: WhatsappMessage) => Promise 63 | 64 | export type GroupParticipant = GroupMember 65 | 66 | export interface GroupData extends GroupMetadata { 67 | participants: GroupParticipant[] 68 | } 69 | -------------------------------------------------------------------------------- /src/db/migrations/1701839162576-auth.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class Auth1701839162576 implements MigrationInterface { 4 | name = 'Auth1701839162576' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(` 8 | CREATE TABLE "credentials" ( 9 | "id" SERIAL NOT NULL, 10 | "value" json, 11 | "active" boolean NOT NULL DEFAULT false, 12 | "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), 13 | "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), 14 | CONSTRAINT "PK_1e38bc43be6697cdda548ad27a6" PRIMARY KEY ("id") 15 | ) 16 | `) 17 | await queryRunner.query(` 18 | CREATE TABLE "states" ( 19 | "id" SERIAL NOT NULL, 20 | "key" character varying NOT NULL, 21 | "value" json, 22 | "credential_id" integer NOT NULL, 23 | "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), 24 | "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), 25 | CONSTRAINT "PK_09ab30ca0975c02656483265f4f" PRIMARY KEY ("id") 26 | ) 27 | `) 28 | await queryRunner.query(` 29 | ALTER TABLE "states" 30 | ADD CONSTRAINT "FK_b0bfa05b4e97321b46de4a8324f" FOREIGN KEY ("credential_id") REFERENCES "credentials"("id") ON DELETE CASCADE ON UPDATE NO ACTION 31 | `) 32 | } 33 | 34 | public async down(queryRunner: QueryRunner): Promise { 35 | await queryRunner.query(` 36 | ALTER TABLE "states" DROP CONSTRAINT "FK_b0bfa05b4e97321b46de4a8324f" 37 | `) 38 | await queryRunner.query(` 39 | DROP TABLE "states" 40 | `) 41 | await queryRunner.query(` 42 | DROP TABLE "credentials" 43 | `) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/util/handler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { ValidationError } from 'joi' 3 | import { ErrorCode, MulterError } from 'multer' 4 | import { HttpStatus } from './http-status' 5 | 6 | function translateMulterError(errorCode: ErrorCode) { 7 | let httpStatus: number 8 | let message: string 9 | 10 | switch (errorCode) { 11 | case 'LIMIT_PART_COUNT': 12 | httpStatus = 413 // Request Entity Too Large 13 | message = 'Too many parts in the request' 14 | break 15 | case 'LIMIT_FILE_SIZE': 16 | httpStatus = 413 // Request Entity Too Large 17 | message = 'File size exceeds the limit' 18 | break 19 | case 'LIMIT_FILE_COUNT': 20 | httpStatus = 413 // Request Entity Too Large 21 | message = 'Too many files in the request' 22 | break 23 | case 'LIMIT_FIELD_KEY': 24 | case 'LIMIT_FIELD_VALUE': 25 | case 'LIMIT_FIELD_COUNT': 26 | httpStatus = 400 // Bad Request 27 | message = 'Invalid field in the request' 28 | break 29 | case 'LIMIT_UNEXPECTED_FILE': 30 | httpStatus = 400 // Bad Request 31 | message = 'Unexpected file in the request' 32 | break 33 | default: 34 | httpStatus = 500 // Internal Server Error 35 | message = 'Internal Server Error' 36 | } 37 | 38 | return { httpStatus, message } 39 | } 40 | 41 | export const errorHandler = (error: Error, _req: Request, res: Response, _next: NextFunction) => { 42 | if (error instanceof ValidationError) { 43 | return res.status(HttpStatus.BadRequest).json({ data: error.message, error: error.details }) 44 | } 45 | 46 | if (error instanceof MulterError) { 47 | const customError = translateMulterError(error.code) 48 | 49 | if (customError.httpStatus !== HttpStatus.InternalServerError) { 50 | return res.status(customError.httpStatus).json({ data: customError.message }) 51 | } 52 | } 53 | 54 | return res.status(HttpStatus.InternalServerError).json({ 55 | data: 'internal server error', 56 | error: { 57 | ...error, 58 | message: error.message, 59 | context: error.name, 60 | }, 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Whatsapp Service 2 | 3 | Effortlessly send messages using our user-friendly REST API. Additionally, introduce our Sticker Converter Bot to enhance your chats. Connect, converse, and convert with ease—all in one place! 4 | 5 | This service use Baileys as core package 6 | 7 | ## Core Features 8 | 9 | 1. REST APIs send messages, including text, images, locations, etc 10 | 2. Sticker Bot 11 | 12 | > Bot for converting images to stickers. 13 | 14 | 3. View once downloader Bot 15 | 16 | > Bot for downloading view once messages. 17 | 18 | 4. View once forwarder Bot 19 | 20 | > Bot for forwarding view once messages to connected users. 21 | 22 | 5. Deleted message forwarder Bot 23 | > Bot for forwarding all deleted messages to connected users. 24 | 25 | ### Bot Triggers 26 | 27 | 1. Sticker Bot 28 | - Every WhatsApp image message appears with the caption `#sticker` or `#convert_sticker`. The bot then immediately 29 | converts and sends it to the sender. 30 | - Every WhatsApp image message replied with the caption `#sticker` or `#convert_sticker`. The bot then immediately 31 | converts and sends it to the sender. 32 | 2. View once downloader Bot 33 | - Every WhatsApp view once message appears with the caption `#dvo`. The bot then immediately converts 34 | and sends it to the 35 | - Every WhatsApp view once message replied with the caption `#dvo`. The bot then immediately converts 36 | and sends it to the sender. 37 | 3. View once forwarder Bot 38 | - Every view once message that appears is immediately converted and sent to the authenticated user. 39 | - NB. This is not the default bot, you should update the code to run it. 40 | 4. Deleted message forwarder 41 | - Every deleted messages will be automatically forwarded to the authenticated user along with the original content. 42 | - NB. This feature is exclusively supported with the database authentication method. 43 | 44 | ## Auth Method 45 | 46 | 1. File method 47 | 2. Database method (postgresql) 48 | 49 | ## How to use 50 | 51 | 1. Clone the repository. 52 | 2. Install dependencies. 53 | 3. Copy `example.env` to `.env` and replace the values with the required configurations. 54 | 4. Run the application. 55 | 5. Link WhatsApp to the application via the QR code displayed in the terminal (set the environment variable QR_TERMINAL 56 | to true). 57 | 6. The application is connected. 58 | 7. The application is ready, and the bot is listening for new messages. 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whatsapp", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --mode production", 8 | "dev": "nodemon -i sessions --exec ts-node -r tsconfig-paths/register src/index.ts", 9 | "start": "node ./dist/index.js", 10 | "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", 11 | "migration:revert": "npm run typeorm -- migration:revert -d ./src/db/datasource.ts", 12 | "migration:generate": "npm run typeorm -- migration:generate -d ./src/db/datasource.ts -p ./src/db/migrations/%npm_config_name%", 13 | "migration:create": "npm run typeorm -- migration:create ./src/db/migrations/%npm_config_name%", 14 | "migrate": "npm run typeorm -- migration:run -d ./src/db/datasource.ts", 15 | "format": "prettier --write \"src/**/*.ts\"", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "prepare": "husky install || echo \"husky not installed\"" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC", 22 | "devDependencies": { 23 | "@types/express": "^4.17.21", 24 | "@types/multer": "^1.4.11", 25 | "@types/node": "^20.11.30", 26 | "@types/qrcode": "^1.5.5", 27 | "@types/qrcode-terminal": "^0.12.2", 28 | "@typescript-eslint/eslint-plugin": "^7.3.1", 29 | "@typescript-eslint/parser": "^7.3.1", 30 | "eslint": "^8.57.0", 31 | "eslint-config-prettier": "^9.1.0", 32 | "eslint-plugin-prettier": "^5.1.3", 33 | "eslint-plugin-unused-imports": "^3.1.0", 34 | "husky": "^9.0.11", 35 | "nodemon": "^3.1.0", 36 | "prettier": "^3.2.5", 37 | "terser-webpack-plugin": "^5.3.10", 38 | "ts-loader": "^9.5.1", 39 | "ts-node": "^10.9.2", 40 | "tsconfig-paths": "^4.2.0", 41 | "typescript": "^5.4.3", 42 | "webpack": "^5.91.0", 43 | "webpack-cli": "^5.1.4", 44 | "webpack-node-externals": "^3.0.0" 45 | }, 46 | "dependencies": { 47 | "@whiskeysockets/baileys": "^6.6.0", 48 | "dotenv": "^16.4.5", 49 | "express": "^4.19.2", 50 | "ffmpeg-static": "^5.2.0", 51 | "joi": "^17.13.0", 52 | "multer": "^1.4.5-lts.1", 53 | "pg": "^8.11.5", 54 | "pino": "^8.20.0", 55 | "qrcode": "^1.5.3", 56 | "qrcode-terminal": "^0.12.0", 57 | "reflect-metadata": "^0.2.2", 58 | "typeorm": "^0.3.20", 59 | "typeorm-naming-strategies": "^4.1.0", 60 | "wa-sticker-formatter": "^4.4.4" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/whatsapp/bases/message.ts: -------------------------------------------------------------------------------- 1 | import { jidNormalizedUser, proto } from '@whiskeysockets/baileys' 2 | import { AuthCredential } from 'src/auth/entities/credential' 3 | import datasource from 'src/db/datasource' 4 | import { sanitizePhoneNumber } from 'src/util/baileys' 5 | import { QueryFailedError, Repository } from 'typeorm' 6 | import { Message } from '../entities/message' 7 | import { WhatsappMessage } from '../interface' 8 | 9 | export class WhatsappMessageService { 10 | private messageRepository: Repository 11 | 12 | constructor() { 13 | this.messageRepository = datasource.getRepository(Message) 14 | } 15 | 16 | private stringifyKey(credential: AuthCredential, key: proto.IMessageKey): string { 17 | return Buffer.from(JSON.stringify({ ...key, credentialId: credential.id })).toString('base64') 18 | } 19 | 20 | async createMessage(credential: AuthCredential, key: proto.IMessageKey, value: WhatsappMessage) { 21 | try { 22 | const senderJid = jidNormalizedUser(key.participant || key.remoteJid) 23 | 24 | return await this.messageRepository.save( 25 | this.messageRepository.create({ 26 | credentialId: credential.id, 27 | key: this.stringifyKey(credential, key), 28 | value, 29 | sender: sanitizePhoneNumber(senderJid), 30 | }), 31 | ) 32 | } catch (error) { 33 | if (error instanceof QueryFailedError) { 34 | if (error.message.includes('duplicate key value violates unique constraint')) { 35 | return null 36 | } 37 | } 38 | throw error 39 | } 40 | } 41 | 42 | async getMessage(credential: AuthCredential, key: proto.IMessageKey): Promise { 43 | const message = await this.messageRepository.findOne({ 44 | where: { 45 | credentialId: credential.id, 46 | key: this.stringifyKey(credential, key), 47 | }, 48 | }) 49 | 50 | return message?.value || null 51 | } 52 | 53 | async deleteMessage(credential: AuthCredential, key: proto.IMessageKey): Promise { 54 | const res = await this.messageRepository.update( 55 | { 56 | credentialId: credential.id, 57 | key: this.stringifyKey(credential, key), 58 | isDeleted: false, 59 | }, 60 | { isDeleted: true }, 61 | ) 62 | return !!res?.affected 63 | } 64 | } 65 | 66 | const whatsappMessageService = new WhatsappMessageService() 67 | export default whatsappMessageService 68 | -------------------------------------------------------------------------------- /src/whatsapp/controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'express' 2 | import QRCode from 'qrcode' 3 | import { HttpStatus } from 'src/util/http-status' 4 | import { responseJson } from 'src/util/response' 5 | import { joiValidate, joiValidateString } from 'src/util/validator' 6 | import whatsappService from './service' 7 | import { sendContactValidator, sendFileValidator, sendLocationValidator, sendTextValidator } from './validator/message' 8 | 9 | export const status: Handler = (_req, res, next) => { 10 | try { 11 | const status = whatsappService.getStatus() 12 | return responseJson(res, status) 13 | } catch (error) { 14 | next(error) 15 | } 16 | } 17 | 18 | export const printQR: Handler = async (_req, res, next) => { 19 | try { 20 | const { qrcode, isConnected } = whatsappService.getStatus() 21 | 22 | if (isConnected) { 23 | return responseJson(res, 'whatsapp has been connected', HttpStatus.BadRequest) 24 | } 25 | 26 | if (!qrcode && !isConnected) { 27 | return responseJson(res, 'qrcode not generated yet', HttpStatus.InternalServerError) 28 | } 29 | 30 | const buffer = await QRCode.toBuffer(qrcode) 31 | 32 | res.setHeader('Content-Type', 'image/png') 33 | 34 | return res.send(buffer) 35 | } catch (error) { 36 | next(error) 37 | } 38 | } 39 | 40 | export const logout: Handler = async (_req, res, next) => { 41 | try { 42 | const data = await whatsappService.logout() 43 | return responseJson(res, data) 44 | } catch (error) { 45 | next(error) 46 | } 47 | } 48 | 49 | export const sendText: Handler = async (req, res, next) => { 50 | try { 51 | const dto = await joiValidate(sendTextValidator, req.body) 52 | 53 | const data = await whatsappService.sendText(dto) 54 | return responseJson(res, data) 55 | } catch (error) { 56 | next(error) 57 | } 58 | } 59 | 60 | export const sendContact: Handler = async (req, res, next) => { 61 | try { 62 | const dto = await joiValidate(sendContactValidator, req.body) 63 | 64 | const data = await whatsappService.sendContact(dto) 65 | return responseJson(res, data) 66 | } catch (error) { 67 | next(error) 68 | } 69 | } 70 | 71 | export const sendLocation: Handler = async (req, res, next) => { 72 | try { 73 | const dto = await joiValidate(sendLocationValidator, req.body) 74 | 75 | const data = await whatsappService.sendLocation(dto) 76 | return responseJson(res, data) 77 | } catch (error) { 78 | next(error) 79 | } 80 | } 81 | 82 | export const sendFile: Handler = async (req, res, next) => { 83 | try { 84 | const file: Express.Multer.File = req.files['file']?.[0] 85 | 86 | const dto = await joiValidate(sendFileValidator, { 87 | file: file?.buffer, 88 | fileName: file?.originalname, 89 | mimetype: file?.mimetype, 90 | ...req.body, 91 | }) 92 | 93 | const data = await whatsappService.sendFile(dto) 94 | return responseJson(res, data) 95 | } catch (error) { 96 | next(error) 97 | } 98 | } 99 | 100 | export const getGroupData: Handler = async (req, res, next) => { 101 | try { 102 | const id = await joiValidateString(req.params.id) 103 | 104 | const data = await whatsappService.groupData(id) 105 | 106 | if (!data) { 107 | return responseJson(res, 'not found', HttpStatus.NotFound) 108 | } 109 | 110 | return responseJson(res, data) 111 | } catch (error) { 112 | next(error) 113 | } 114 | } 115 | 116 | export const sendImage: Handler = async (req, res, next) => { 117 | try { 118 | const file: Express.Multer.File = req.files['file']?.[0] 119 | 120 | const dto = await joiValidate(sendFileValidator, { 121 | file: file?.buffer, 122 | fileName: file?.originalname, 123 | mimetype: file?.mimetype, 124 | ...req.body, 125 | }) 126 | 127 | const data = await whatsappService.sendImage(dto) 128 | return responseJson(res, data) 129 | } catch (error) { 130 | next(error) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/util/baileys.ts: -------------------------------------------------------------------------------- 1 | import { BufferJSON, downloadMediaMessage, jidNormalizedUser, proto } from '@whiskeysockets/baileys' 2 | import { createHash } from 'crypto' 3 | import { writeFile } from 'fs/promises' 4 | import path from 'path' 5 | import { WhatsappMessage } from 'src/whatsapp/interface' 6 | 7 | export function sanitizePhoneNumber(number: string) { 8 | if (typeof number == 'undefined' || number == '') { 9 | return '' 10 | } 11 | 12 | number = number.split(':')[0] 13 | number = number.replace(/\D/g, '') 14 | 15 | if (number.startsWith('08')) { 16 | number = '62' + number.substring(1) 17 | } 18 | 19 | return number 20 | } 21 | 22 | export function formatToJid(number: string) { 23 | const jid = jidNormalizedUser(number) 24 | if (jid) { 25 | return jid 26 | } 27 | 28 | const sanitized = sanitizePhoneNumber(number) 29 | if (!sanitized) { 30 | return '' 31 | } 32 | 33 | return sanitized + '@s.whatsapp.net' 34 | } 35 | 36 | export function extractJidFromMessage(message: WhatsappMessage): string { 37 | const extract = () => { 38 | if (message?.quoted?.sendToJid?.endsWith('@s.whatsapp.net') || message?.quoted?.sendToJid?.endsWith('@g.us')) { 39 | return message.quoted.sendToJid 40 | } 41 | if (message?.key?.remoteJid?.endsWith('@s.whatsapp.net')) { 42 | return message.key.remoteJid 43 | } 44 | if (message?.key?.participant?.endsWith('@s.whatsapp.net') && message?.key?.remoteJid?.endsWith('@g.us')) { 45 | return message.key.remoteJid 46 | } 47 | return '' 48 | } 49 | return formatToJid(extract()) 50 | } 51 | 52 | export function getCaptionAttribute(caption: string, attr: string): string { 53 | if (!caption?.includes(`${attr}:`)) return '' 54 | 55 | const attrRegex = new RegExp(`.*${attr}:`, 'g') 56 | return caption.split(attrRegex)[1]?.split('\n')[0]?.trim() 57 | } 58 | 59 | export function prepareDataToWrite(value: T): T { 60 | return JSON.parse(JSON.stringify(value, BufferJSON.replacer)) 61 | } 62 | 63 | export function prepareDataToRead(value: T): T { 64 | return JSON.parse(JSON.stringify(value), BufferJSON.reviver) 65 | } 66 | 67 | export function extractViewOnce(message: WhatsappMessage): WhatsappMessage { 68 | message = deepCopy(message) 69 | 70 | const viewOnce = message?.message?.viewOnceMessage || message?.message?.viewOnceMessageV2 71 | if (!viewOnce) { 72 | return null 73 | } 74 | 75 | for (const key in viewOnce.message) { 76 | const data = viewOnce.message[key] 77 | if (data?.viewOnce) { 78 | data.viewOnce = false 79 | } 80 | } 81 | message.message = viewOnce.message 82 | 83 | return message 84 | } 85 | 86 | export function parseTimeStamp(timestamp: number): string { 87 | if (typeof timestamp !== 'number') return '' 88 | 89 | const date = new Date(timestamp * 1000) // Multiply by 1000 to convert from seconds to milliseconds 90 | 91 | const formattedDate = [ 92 | date.getDate().toString().padStart(2, '0'), 93 | (date.getMonth() + 1).toString().padStart(2, '0'), // Months are zero-based 94 | date.getFullYear(), 95 | ].join('/') 96 | 97 | const formattedTime = [ 98 | date.getHours().toString().padStart(2, '0'), 99 | date.getMinutes().toString().padStart(2, '0'), 100 | date.getSeconds().toString().padStart(2, '0'), 101 | ].join(':') 102 | 103 | return `${formattedDate} ${formattedTime}` 104 | } 105 | 106 | export function deepCopy(value: T): T { 107 | return JSON.parse(JSON.stringify(value, BufferJSON.replacer), BufferJSON.reviver) 108 | } 109 | 110 | export function isValidMessageSend(key: proto.IMessageKey): boolean { 111 | return key?.remoteJid?.endsWith('@s.whatsapp.net') || key?.remoteJid?.endsWith('@g.us') 112 | } 113 | 114 | export function bufferId(buffer: Buffer): string { 115 | const hash = createHash('sha256') 116 | hash.update(buffer) 117 | return hash.digest('hex') 118 | } 119 | 120 | export async function saveMessageMediaToPublic(message: WhatsappMessage) { 121 | const buffer = await downloadMediaMessage(message, 'buffer', {}) 122 | 123 | await writeFile( 124 | path.join(process.cwd(), 'public', `${new Date().getTime()}.${message?.message?.videoMessage ? 'mp4' : 'jpg'}`), 125 | buffer, 126 | ) 127 | } 128 | -------------------------------------------------------------------------------- /src/auth/service.ts: -------------------------------------------------------------------------------- 1 | import datasource from 'src/db/datasource' 2 | import whatsappService from 'src/whatsapp/service' 3 | import { IsNull, Not, Repository } from 'typeorm' 4 | import { AuthCredential } from './entities/credential' 5 | import { AuthState } from './entities/state' 6 | import { Session } from './interface' 7 | 8 | export class AuthService { 9 | private credentialRepository: Repository 10 | private stateRepository: Repository 11 | 12 | constructor() { 13 | this.credentialRepository = datasource.getRepository(AuthCredential) 14 | this.stateRepository = datasource.getRepository(AuthState) 15 | } 16 | 17 | async getActiveCredential(): Promise { 18 | const activeCreds = await this.credentialRepository.findOne({ 19 | where: { 20 | active: true, 21 | }, 22 | }) 23 | 24 | if (activeCreds) { 25 | return activeCreds 26 | } 27 | 28 | const notConnectedCredential = await this.credentialRepository.findOne({ 29 | where: { 30 | value: IsNull(), 31 | }, 32 | }) 33 | 34 | if (notConnectedCredential) { 35 | notConnectedCredential.active = true 36 | await this.credentialRepository.save(notConnectedCredential) 37 | return notConnectedCredential 38 | } 39 | 40 | const credential = await this.credentialRepository.save( 41 | this.credentialRepository.create({ 42 | active: true, 43 | }), 44 | ) 45 | 46 | return credential 47 | } 48 | 49 | async removeActiveCredential(): Promise { 50 | const res = await this.credentialRepository.delete({ 51 | active: true, 52 | }) 53 | return !!res.affected 54 | } 55 | 56 | async saveCredential(credential: AuthCredential, newValue: any) { 57 | credential.value = newValue 58 | 59 | return this.credentialRepository.save(this.credentialRepository.create(credential)) 60 | } 61 | 62 | async getStateValue(credential: AuthCredential, key: string): Promise { 63 | const state = await this.stateRepository.findOne({ 64 | where: { 65 | credentialId: credential.id, 66 | key, 67 | }, 68 | }) 69 | 70 | return state?.value || null 71 | } 72 | 73 | async setStateValue(credential: AuthCredential, key: string, value: any): Promise { 74 | const existState = await this.stateRepository.findOne({ 75 | where: { 76 | credentialId: credential.id, 77 | key, 78 | }, 79 | }) 80 | 81 | await this.stateRepository.save( 82 | this.stateRepository.create({ 83 | ...(existState || {}), 84 | credentialId: credential.id, 85 | key, 86 | value, 87 | }), 88 | ) 89 | } 90 | 91 | async removeStateValue(credential: AuthCredential, key: string): Promise { 92 | try { 93 | await this.stateRepository.delete({ 94 | credentialId: credential.id, 95 | key, 96 | }) 97 | } catch {} 98 | } 99 | 100 | async getSessions(): Promise { 101 | const sessions = await this.credentialRepository.find({ 102 | order: { 103 | active: 'DESC', 104 | updatedAt: 'DESC', 105 | }, 106 | }) 107 | return sessions.map(this.buildSession) 108 | } 109 | 110 | async activateSession(id: number): Promise { 111 | const session = await this.credentialRepository.findOne({ where: { id } }) 112 | 113 | if (session?.active) return false 114 | 115 | await this.credentialRepository.update({ id: Not(id), active: true }, { active: false }) 116 | 117 | session.active = true 118 | await this.credentialRepository.save(session) 119 | 120 | await whatsappService.reInitialize() 121 | 122 | return true 123 | } 124 | 125 | async deactivateSession(id: number): Promise { 126 | const session = await this.credentialRepository.findOne({ where: { id } }) 127 | 128 | if (!session?.active || !session.value?.me?.id) { 129 | return false 130 | } 131 | 132 | session.active = false 133 | await this.credentialRepository.save(session) 134 | 135 | await whatsappService.reInitialize() 136 | 137 | return true 138 | } 139 | 140 | private buildSession(credential: AuthCredential): Session { 141 | let user: { name?: string; phone?: string } 142 | if (credential.value?.me) { 143 | user = { 144 | name: credential.value.me.name, 145 | phone: credential.value.me.id.split(':')[0], 146 | } 147 | } 148 | 149 | return { 150 | id: credential.id, 151 | active: credential.active, 152 | createdAt: credential.createdAt, 153 | updatedAt: credential.updatedAt, 154 | connected: !!credential.value?.me?.id, 155 | user, 156 | platform: credential.value?.platform, 157 | } 158 | } 159 | } 160 | 161 | const authService = new AuthService() 162 | export default authService 163 | -------------------------------------------------------------------------------- /src/whatsapp/services/db.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthenticationCreds, 3 | SignalDataSet, 4 | SignalDataTypeMap, 5 | initAuthCreds, 6 | isJidGroup, 7 | proto, 8 | } from '@whiskeysockets/baileys' 9 | import { AuthCredential } from 'src/auth/entities/credential' 10 | import authService from 'src/auth/service' 11 | import { 12 | deepCopy, 13 | extractViewOnce, 14 | formatToJid, 15 | isValidMessageSend, 16 | parseTimeStamp, 17 | sanitizePhoneNumber, 18 | } from 'src/util/baileys' 19 | import whatsappMessageService from '../bases/message' 20 | import { WhatsappBaseService } from '../bases/service' 21 | import { AuthState, WhatsappMessage, WhatsappMessageUpdate } from '../interface' 22 | 23 | export class WhatsappServiceDBAuth extends WhatsappBaseService { 24 | private credential: AuthCredential 25 | 26 | protected async makeAuthState(): Promise { 27 | const credential = await authService.getActiveCredential() 28 | const creds: AuthenticationCreds = credential?.value || initAuthCreds() 29 | 30 | this.credential = credential 31 | 32 | return { 33 | state: { 34 | creds, 35 | keys: { 36 | get: (type, ids) => this.getStateData(type, ids), 37 | set: data => this.setStateData(data), 38 | }, 39 | }, 40 | saveCreds: async () => { 41 | await authService.saveCredential(credential, creds) 42 | }, 43 | } 44 | } 45 | 46 | protected async removeSession(): Promise { 47 | await authService.removeActiveCredential() 48 | } 49 | 50 | async saveMessage(message: WhatsappMessage) { 51 | const jid = formatToJid(message?.key?.participant || message?.key?.remoteJid) 52 | 53 | if ( 54 | !isValidMessageSend(message.key) || 55 | !jid || 56 | message.key.fromMe || 57 | !message.message || 58 | message.message.protocolMessage 59 | ) { 60 | return false 61 | } 62 | 63 | await whatsappMessageService.createMessage(this.credential, message.key, message) 64 | return true 65 | } 66 | 67 | async sendDeletedMessage(key: proto.IMessageKey, recursive?: number): Promise { 68 | const jid = formatToJid(key?.participant || key?.remoteJid) 69 | 70 | // set recursive set to 6 time because each recursive 5 second so in 30 seconds function will stop 71 | if (recursive > 6 || !isValidMessageSend(key) || !jid || key.fromMe) { 72 | return false 73 | } 74 | 75 | const waMessage = await whatsappMessageService.getMessage(this.credential, key) 76 | if (!waMessage) { 77 | setTimeout(() => this.sendDeletedMessage(key, (recursive || 0) + 1), 1000 * 5) 78 | return false 79 | } 80 | 81 | const forwardMessage = extractViewOnce(waMessage) || deepCopy(waMessage) 82 | const messageResult = await this.sendMessage( 83 | this.contactConnected.id, 84 | { forward: forwardMessage }, 85 | { quoted: waMessage }, 86 | ) 87 | 88 | const phoneNumber = sanitizePhoneNumber(jid) 89 | 90 | const isGroupMessage = isJidGroup(key.remoteJid) 91 | const group = isGroupMessage ? await this.socket.groupMetadata(key.remoteJid) : null 92 | const descriptions = [ 93 | isGroupMessage ? 'Deleted Group Message' : 'Deleted Message', 94 | isGroupMessage ? `group: ${group.subject} (${group.participants?.length} members)` : '', 95 | `phone: ${phoneNumber}`, 96 | `name: ${waMessage.pushName}`, 97 | parseTimeStamp(waMessage.messageTimestamp as number), 98 | ].filter(Boolean) 99 | 100 | await this.sendMessage(this.contactConnected.id, { text: descriptions.join('\n') }, { quoted: messageResult }) 101 | await whatsappMessageService.deleteMessage(this.credential, key) 102 | return true 103 | } 104 | 105 | protected newMessageListeners = async (message: WhatsappMessage): Promise => { 106 | return await Promise.all([ 107 | this.convertAndSendSticker(message), 108 | this.downloadViewOnce(message), 109 | this.saveMessage(message), 110 | // uncomment this if you want forward every view once come 111 | // this.forwardViewOnce(message), 112 | ]) 113 | } 114 | 115 | protected updateMessageListeners = async (message: WhatsappMessageUpdate): Promise => { 116 | const listeners = [] 117 | 118 | if (!message?.update?.message) { 119 | listeners.push(this.sendDeletedMessage(message.key)) 120 | } 121 | 122 | return await Promise.all(listeners) 123 | } 124 | 125 | private fixKey(type: string, id: string) { 126 | return Buffer.from(JSON.stringify({ credentialId: this.credential.id, type, id })).toString('base64') 127 | } 128 | 129 | private async getStateData( 130 | type: T, 131 | ids: string[], 132 | ): Promise> { 133 | const data: Record = {} 134 | 135 | await Promise.all( 136 | ids.map(async id => { 137 | let value = await authService.getStateValue(this.credential, this.fixKey(type, id)) 138 | 139 | if (type === 'app-state-sync-key' && value) { 140 | value = proto.Message.AppStateSyncKeyData.fromObject(value) 141 | } 142 | 143 | data[id] = value 144 | }), 145 | ) 146 | 147 | return data 148 | } 149 | 150 | private async setStateData(data: SignalDataSet) { 151 | const tasks: Promise[] = [] 152 | 153 | for (const type in data) { 154 | for (const id in data[type]) { 155 | const value = data[type][id] 156 | const key = this.fixKey(type, id) 157 | tasks.push( 158 | value 159 | ? authService.setStateValue(this.credential, key, value) 160 | : authService.removeStateValue(this.credential, key), 161 | ) 162 | } 163 | } 164 | 165 | await Promise.all(tasks) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/whatsapp/bases/media.ts: -------------------------------------------------------------------------------- 1 | import { downloadMediaMessage, proto } from '@whiskeysockets/baileys' 2 | import { BOT_PASSWORD } from 'src/config/config' 3 | import { bufferId, deepCopy, extractJidFromMessage, getCaptionAttribute } from 'src/util/baileys' 4 | import Sticker, { StickerTypes } from 'wa-sticker-formatter' 5 | import { 6 | ExtractStickerMediaData, 7 | ExtractViewOnceMediaData, 8 | ValueMessageMedia, 9 | WhatsappMessage, 10 | WhatsappMessageQuoted, 11 | } from '../interface' 12 | 13 | export class MediaMessage { 14 | private message: WhatsappMessage 15 | 16 | constructor(message: WhatsappMessage) { 17 | this.message = deepCopy(message) 18 | } 19 | 20 | static getMessageMedia(message: WhatsappMessage['message']): ValueMessageMedia { 21 | if (message?.imageMessage) { 22 | return { media: message.imageMessage, type: 'image', viewOnce: false } 23 | } 24 | if (message?.videoMessage) { 25 | return { media: message.videoMessage, type: 'video', viewOnce: false } 26 | } 27 | 28 | if (message?.viewOnceMessageV2?.message?.imageMessage) { 29 | return { media: message?.viewOnceMessageV2?.message?.imageMessage, type: 'image', viewOnce: true } 30 | } 31 | if (message?.viewOnceMessageV2?.message?.videoMessage) { 32 | return { media: message?.viewOnceMessageV2?.message?.videoMessage, type: 'video', viewOnce: true } 33 | } 34 | 35 | if (message?.viewOnceMessage?.message?.imageMessage) { 36 | return { media: message?.viewOnceMessage?.message?.imageMessage, type: 'image', viewOnce: true } 37 | } 38 | if (message?.viewOnceMessage?.message?.videoMessage) { 39 | return { media: message?.viewOnceMessage?.message?.videoMessage, type: 'video', viewOnce: true } 40 | } 41 | 42 | return null 43 | } 44 | 45 | async extractStickerMedia(pack: string, author?: string): Promise { 46 | if (!this.shouldConvertSticker()) { 47 | return null 48 | } 49 | 50 | const targetJid = extractJidFromMessage(this.message) 51 | if (!targetJid) { 52 | return null 53 | } 54 | 55 | const media = await downloadMediaMessage(this.message, 'buffer', {}) 56 | 57 | const sticker = await this.convertSticker(media as Buffer, pack, author) 58 | if (!sticker) { 59 | return null 60 | } 61 | 62 | return { targetJid, message: { sticker } } 63 | } 64 | 65 | private async convertSticker(buffer: Buffer, pack: string, author?: string): Promise { 66 | const { type } = MediaMessage.getMessageMedia(this.message.message) 67 | 68 | const getQuality = () => { 69 | if (type === 'image') { 70 | return 50 71 | } 72 | if (buffer.length < 500 * 1024) { 73 | return 50 74 | } 75 | if (buffer.length < 1500 * 1024) { 76 | return 20 77 | } 78 | return 10 79 | } 80 | 81 | const sticker = new Sticker(buffer, { 82 | quality: getQuality(), 83 | type: StickerTypes.CROPPED, 84 | author, 85 | pack, 86 | id: bufferId(buffer), 87 | }) 88 | 89 | const media = await sticker.toBuffer() 90 | if (type === 'image' || media.length < 1024 * 1000) { 91 | return media 92 | } 93 | 94 | return null 95 | } 96 | 97 | async extractViewOnceMedia(): Promise { 98 | if (!this.shouldConvertViewOnceMedia()) { 99 | return null 100 | } 101 | 102 | const targetJid = extractJidFromMessage(this.message) 103 | if (!targetJid) { 104 | return null 105 | } 106 | 107 | const viewOnce = this.message?.message?.viewOnceMessage || this.message?.message?.viewOnceMessageV2 108 | 109 | for (const key in viewOnce.message) { 110 | const data = viewOnce.message[key] 111 | if (data?.viewOnce) { 112 | data.viewOnce = false 113 | } 114 | } 115 | this.message.message = viewOnce.message 116 | 117 | // saveMessageMediaToPublic(this.message) 118 | 119 | return { targetJid, message: { forward: this.message } } 120 | } 121 | 122 | private checkPassword(caption: string): boolean { 123 | if (!BOT_PASSWORD) return true 124 | 125 | return getCaptionAttribute(caption, 'password') === BOT_PASSWORD 126 | } 127 | 128 | private checkQuotedMessage() { 129 | const quoMessage = this.message?.message?.extendedTextMessage 130 | 131 | const media = MediaMessage.getMessageMedia(quoMessage?.contextInfo?.quotedMessage) 132 | if (!media) return 133 | 134 | const caption = quoMessage.text.trim() 135 | const destination = getCaptionAttribute(caption, 'destination') 136 | 137 | const quoted: WhatsappMessageQuoted = { message: caption } 138 | switch (destination.toLowerCase()) { 139 | case 'sender': 140 | quoted.sendToJid = quoMessage.contextInfo.participant 141 | break 142 | case 'me': 143 | quoted.sendToJid = this.message.key?.participant 144 | break 145 | } 146 | 147 | this.message.message = quoMessage.contextInfo.quotedMessage 148 | this.message.quoted = quoted 149 | } 150 | 151 | private shouldConvertSticker(): boolean { 152 | this.checkQuotedMessage() 153 | 154 | const stickerMedia = MediaMessage.getMessageMedia(this.message.message) 155 | 156 | if (stickerMedia?.viewOnce || (stickerMedia?.media as proto.Message.IVideoMessage)?.seconds > 10) { 157 | return false 158 | } 159 | 160 | const baseCaption = this.message?.quoted?.message || stickerMedia?.media?.caption 161 | const caption = baseCaption?.trim?.() 162 | if ( 163 | !caption?.toLowerCase()?.startsWith('#convert_sticker') && 164 | !caption?.toLowerCase()?.startsWith('#sticker') 165 | ) { 166 | return false 167 | } 168 | 169 | return this.checkPassword(caption) 170 | } 171 | 172 | private shouldConvertViewOnceMedia(): boolean { 173 | this.checkQuotedMessage() 174 | 175 | const viewOnceMedia = MediaMessage.getMessageMedia(this.message.message) 176 | 177 | if (!viewOnceMedia?.viewOnce) { 178 | return false 179 | } 180 | 181 | const baseCaption = this.message?.quoted?.message || viewOnceMedia?.media?.caption 182 | const caption = baseCaption?.trim?.() 183 | if (!caption?.toLowerCase()?.startsWith('#dvo')) { 184 | return false 185 | } 186 | 187 | return this.checkPassword(caption) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/whatsapp/bases/service.ts: -------------------------------------------------------------------------------- 1 | import makeWASocket, { 2 | AnyMessageContent, 3 | AuthenticationState, 4 | ConnectionState, 5 | Contact, 6 | DisconnectReason, 7 | MessageUpsertType, 8 | MiscMessageGenerationOptions, 9 | delay, 10 | fetchLatestBaileysVersion, 11 | jidDecode, 12 | jidNormalizedUser, 13 | promiseTimeout, 14 | } from '@whiskeysockets/baileys' 15 | import { pino } from 'pino' 16 | import QRCodeTerminal from 'qrcode-terminal' 17 | import { QR_TERMINAL } from 'src/config/config' 18 | import { extractViewOnce, formatToJid, sanitizePhoneNumber } from 'src/util/baileys' 19 | import { SendContactDto, SendFileDto, SendLocationDto, SendTextDto } from '../dto/message.dto' 20 | import { 21 | AuthState, 22 | GroupData, 23 | StatusWhatsappService, 24 | WhatsappError, 25 | WhatsappMessage, 26 | WhatsappMessageUpdate, 27 | WhatsappSocket, 28 | } from '../interface' 29 | import { MediaMessage } from './media' 30 | 31 | export abstract class WhatsappBaseService { 32 | protected contactConnected: Contact 33 | protected socket: WhatsappSocket 34 | protected qrcode: string 35 | 36 | constructor( 37 | protected serviceName = 'Whatsapp Service', 38 | protected serviceVersion = '0.0.1', 39 | ) {} 40 | 41 | async initialize() { 42 | if (this.socket) { 43 | return 44 | } 45 | this.socket = await this.createNewSocket() 46 | console.log(`Whatsapp service "${this.serviceName}" v${this.serviceVersion} ready`) 47 | } 48 | 49 | async reInitialize() { 50 | this.socket.ev.removeAllListeners('connection.update') 51 | this.socket.ev.removeAllListeners('creds.update') 52 | this.socket.ev.removeAllListeners('messages.upsert') 53 | this.socket.ev.removeAllListeners('messages.update') 54 | 55 | this.socket = await this.createNewSocket() 56 | 57 | console.log(`Reinitialize Whatsapp service "${this.serviceName}" v${this.serviceVersion}`) 58 | } 59 | 60 | async groupData(id: string) { 61 | if (!id.endsWith('@g.us')) { 62 | id = `${id}@g.us` 63 | } 64 | try { 65 | const group: GroupData = await this.socket.groupMetadata(id) 66 | 67 | if (!group) { 68 | return null 69 | } 70 | 71 | group.participants = await Promise.all( 72 | group.participants?.map(async participant => { 73 | try { 74 | participant.imgUrl = await this.socket.profilePictureUrl(participant.id, 'image') 75 | return participant 76 | } catch { 77 | return participant 78 | } 79 | }), 80 | ) 81 | 82 | return group 83 | } catch (error) { 84 | return null 85 | } 86 | } 87 | 88 | async sendText(dto: SendTextDto) { 89 | return this.sendMessage(dto.sendTo, { text: dto.message }) 90 | } 91 | 92 | async sendContact(dto: SendContactDto) { 93 | const waid = sanitizePhoneNumber(dto.phoneNumber) 94 | 95 | const vcard = [ 96 | 'BEGIN:VCARD', 97 | 'VERSION:3.0', 98 | `FN:${dto.name}`, 99 | `TEL;type=CELL;type=VOICE;waid=${waid}:${dto.phoneNumber}`, 100 | 'END:VCARD', 101 | ].join('\n') 102 | 103 | return this.sendMessage(dto.sendTo, { 104 | contacts: { 105 | displayName: dto.name, 106 | contacts: [{ vcard }], 107 | }, 108 | }) 109 | } 110 | 111 | async sendLocation(dto: SendLocationDto) { 112 | return this.sendMessage(dto.sendTo, { 113 | location: { degreesLatitude: dto.lat, degreesLongitude: dto.long }, 114 | }) 115 | } 116 | 117 | async sendFile(dto: SendFileDto) { 118 | return this.sendMessage(dto.sendTo, { 119 | document: dto.file, 120 | caption: dto.caption, 121 | fileName: dto.fileName, 122 | mimetype: dto.mimetype, 123 | }) 124 | } 125 | 126 | async sendImage(dto: SendFileDto) { 127 | return this.sendMessage(dto.sendTo, { 128 | image: dto.file, 129 | caption: dto.caption, 130 | }) 131 | } 132 | 133 | async convertAndSendSticker(message: WhatsappMessage) { 134 | if (formatToJid(message?.key?.remoteJid) === formatToJid(this.contactConnected.id)) { 135 | return false 136 | } 137 | 138 | const media = new MediaMessage(message) 139 | 140 | const sticker = await media.extractStickerMedia(`${this.serviceName} X ${message.pushName}`, this.serviceName) 141 | if (!sticker) { 142 | return false 143 | } 144 | 145 | console.log(`Sending sticker to ${sticker.targetJid}`) 146 | await this.sendMessage(sticker.targetJid, sticker.message, { quoted: message }) 147 | return true 148 | } 149 | 150 | async downloadViewOnce(message: WhatsappMessage) { 151 | const media = new MediaMessage(message) 152 | 153 | const viewOnce = await media.extractViewOnceMedia() 154 | if (!viewOnce) { 155 | return false 156 | } 157 | 158 | console.log(`Sending view once media to ${viewOnce.targetJid}`) 159 | await this.sendMessage(viewOnce.targetJid, viewOnce.message, { quoted: message }) 160 | return true 161 | } 162 | 163 | async forwardViewOnce(message: WhatsappMessage) { 164 | const forwardMessage = extractViewOnce(message) 165 | if (!forwardMessage || !this.contactConnected.id) { 166 | return false 167 | } 168 | 169 | // saveMessageMediaToPublic(message) 170 | 171 | const targetJid = jidNormalizedUser(this.contactConnected.id) 172 | console.log(`Forward view once to ${targetJid}`) 173 | await this.sendMessage(targetJid, { forward: forwardMessage }, { quoted: message }) 174 | return true 175 | } 176 | 177 | protected abstract removeSession(): Promise 178 | 179 | async logout() { 180 | this.checkIsConnected() 181 | 182 | await this.socket.logout() 183 | 184 | await this.removeSession() 185 | 186 | delete this.contactConnected 187 | await this.reInitialize() 188 | 189 | return true 190 | } 191 | 192 | getStatus(): StatusWhatsappService { 193 | return { 194 | isConnected: !this.qrcode && !!this.contactConnected?.id, 195 | contactConnected: this.contactConnected, 196 | qrcode: this.qrcode, 197 | } 198 | } 199 | 200 | protected async sendMessage( 201 | phoneNumber: string, 202 | content: AnyMessageContent, 203 | options?: MiscMessageGenerationOptions, 204 | recursive?: number, 205 | ): Promise { 206 | try { 207 | this.checkIsConnected() 208 | 209 | // create 1 minute timeout for whatsapp send message 210 | return await promiseTimeout(1000 * 15, async (resolve, reject) => { 211 | try { 212 | const jid = formatToJid(phoneNumber) 213 | await this.socket.presenceSubscribe(jid) 214 | await delay(1 * 1000) 215 | await this.socket.sendPresenceUpdate('composing', jid) 216 | await delay(2 * 1000) 217 | await this.socket.sendPresenceUpdate('paused', jid) 218 | await delay(1 * 1000) 219 | const message = await this.socket.sendMessage(jid, content, options) 220 | resolve(message) 221 | } catch (error) { 222 | reject(error) 223 | } 224 | }) 225 | } catch (error) { 226 | // check if can reload and the recursive not at maximum 227 | if (this.isShouldResend(error) && (recursive || 0) < 20) { 228 | await delay(500) 229 | return await this.sendMessage(phoneNumber, content, options, (recursive || 0) + 1) 230 | } 231 | 232 | throw error 233 | } 234 | } 235 | 236 | protected abstract makeAuthState(): Promise 237 | 238 | private async createNewSocket(): Promise { 239 | const { state, saveCreds } = await this.makeAuthState() 240 | const { version } = await fetchLatestBaileysVersion() 241 | 242 | const socket = makeWASocket({ 243 | version: version, 244 | auth: state, 245 | logger: pino({ enabled: false }) as any, 246 | defaultQueryTimeoutMs: undefined, 247 | qrTimeout: 1000 * 60 * 60 * 24, 248 | browser: [this.serviceName, 'Desktop', this.serviceVersion], 249 | markOnlineOnConnect: false, 250 | }) 251 | 252 | socket.ev.on('connection.update', update => this.onConnectionUpdate(socket, state, update)) 253 | socket.ev.on('creds.update', saveCreds) 254 | socket.ev.on('messages.upsert', chats => this.onNewMessage(chats)) 255 | socket.ev.on('messages.update', chats => this.onUpdateMessage(chats)) 256 | 257 | return socket 258 | } 259 | 260 | protected newMessageListeners = async (message: WhatsappMessage): Promise => { 261 | return await Promise.all([ 262 | this.convertAndSendSticker(message), 263 | this.downloadViewOnce(message), 264 | // uncomment this if you want forward every view once come 265 | // this.forwardViewOnce(message), 266 | ]) 267 | } 268 | 269 | private async onNewMessage(messages: { messages: WhatsappMessage[]; type: MessageUpsertType }) { 270 | return Promise.all( 271 | messages?.messages?.map(async message => { 272 | try { 273 | await this.newMessageListeners(message) 274 | } catch (error) { 275 | console.error(`new message listener error: ${error}`) 276 | } 277 | }), 278 | ) 279 | } 280 | 281 | protected updateMessageListeners = async (message: WhatsappMessageUpdate): Promise => { 282 | return await Promise.all([!!message]) 283 | } 284 | 285 | private async onUpdateMessage(messages: WhatsappMessageUpdate[]) { 286 | return Promise.all( 287 | messages?.map(async message => { 288 | try { 289 | await this.updateMessageListeners(message) 290 | } catch (error) { 291 | console.error(`update message listener error: ${error}`) 292 | } 293 | }), 294 | ) 295 | } 296 | 297 | private async onConnectionUpdate( 298 | socket: WhatsappSocket, 299 | state: AuthenticationState, 300 | update: Partial, 301 | ): Promise { 302 | const { connection, lastDisconnect } = update 303 | 304 | this.qrcode = update.qr 305 | if (update.qr && QR_TERMINAL) { 306 | console.log('\n') 307 | QRCodeTerminal.generate(update.qr, { small: true }) 308 | console.log('Scan QRCode to connect your whatsapp\n') 309 | } 310 | 311 | if (connection === 'close') { 312 | const statusCode = (lastDisconnect?.error as any)?.output?.statusCode 313 | 314 | if (statusCode === DisconnectReason.loggedOut) { 315 | await this.removeSession() 316 | delete this.contactConnected 317 | 318 | console.log('Whatsapp logged out') 319 | } 320 | 321 | console.log(`Connection close (${statusCode}): ${lastDisconnect?.error}`) 322 | if (statusCode !== 403) { 323 | await this.reInitialize() 324 | } 325 | return 326 | } 327 | 328 | if (connection === 'open') { 329 | this.contactConnected = { 330 | ...state.creds.me, 331 | id: jidDecode(state.creds.me?.id)?.user, 332 | } 333 | delete this.qrcode 334 | 335 | await socket.sendPresenceUpdate('unavailable') 336 | 337 | console.log(`Whatsapp connected to ${this.contactConnected.id}`) 338 | return 339 | } 340 | } 341 | 342 | private checkIsConnected() { 343 | const status = this.getStatus() 344 | 345 | if (!status.isConnected) throw new WhatsappError('Whatsapp not connected yet') 346 | } 347 | 348 | private isShouldResend(error: any): boolean { 349 | if (error === 1006) return true 350 | 351 | const payload = error?.output?.payload 352 | 353 | if (!payload) return false 354 | 355 | return ( 356 | payload.statusCode === 428 && 357 | payload.error === 'Precondition Required' && 358 | payload.message === 'Connection Closed' 359 | ) 360 | } 361 | } 362 | --------------------------------------------------------------------------------