├── .dockerignore ├── .editorconfig ├── .env.example ├── .env.test ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc.json ├── .watchmanconfig ├── Dockerfile ├── README.md ├── __tests__ ├── routes │ ├── blacklist.ts │ ├── index.ts │ ├── media.ts │ ├── messages.ts │ ├── templates.ts │ └── webook.ts └── services │ ├── blacklist.ts │ ├── client.ts │ ├── client_baileys.ts │ ├── commander.ts │ ├── config.ts │ ├── config_redis.ts │ ├── data_store_file.ts │ ├── incoming_baileys.ts │ ├── listener_baileys.ts │ ├── media_store_file.ts │ ├── message_filter.ts │ ├── outgoing_cloud_api.ts │ ├── session_store_file.ts │ ├── socket.ts │ ├── template.ts │ └── transformer.ts ├── data ├── medias │ └── .gitkeep ├── sessions │ └── .gitkeep └── stores │ └── .gitkeep ├── develop.Dockerfile ├── docker-compose.yml ├── examples ├── bulk │ ├── default.csv │ ├── sisodonto.csv │ ├── sisodonto.xlsx │ ├── sisodonto_batch.csv │ ├── sisodonto_with_audio.csv │ ├── sisodonto_with_image.csv │ └── sisodonto_with_speech.csv ├── chatwoot-uno │ ├── README.md │ └── prints │ │ ├── channel.png │ │ ├── configuration.png │ │ ├── connect.png │ │ ├── create.png │ │ └── read.png ├── chatwoot │ ├── README.md │ ├── docker-compose.yml │ └── prints │ │ ├── copy_token.png │ │ ├── copy_uno_token.png │ │ ├── create_channel.png │ │ ├── create_contact.png │ │ ├── read_qrcode.png │ │ └── update_inbox.png ├── docker-compose.yml ├── monitoring │ └── docker-compose.yml ├── scripts │ ├── apps-model.yaml │ ├── chatwoot-model.yaml │ ├── docker-model.yaml │ ├── envSetup.sh │ ├── serverSetup.sh │ └── uno-model.yaml ├── typebot │ ├── README.md │ └── prints │ │ ├── add_phone.png │ │ ├── callback.png │ │ ├── config_uno.png │ │ ├── exemple_list_typebot.png │ │ ├── lists.png │ │ ├── phone_number.png │ │ ├── publish.png │ │ ├── put_token.png │ │ └── whatsapp_menu.png └── unochat │ ├── .env │ ├── README.md │ ├── docker-compose.yml │ └── prints │ ├── copy_token.png │ ├── copy_uno_token.png │ ├── create_channel.png │ ├── create_contact.png │ ├── create_inbox.png │ ├── read_qrcode.png │ └── update_inbox.png ├── jest.config.js ├── license.txt ├── logos ├── BebasNeue-Regular.otf ├── Unoapi_logo.svg ├── unoapi.pdf ├── unoapi_logo.jpg └── unoapi_logo.png ├── nodemon.json ├── package.json ├── picpay.png ├── public └── index.html ├── settings.json ├── src ├── amqp.ts ├── app.ts ├── bridge.ts ├── broker.ts ├── bulker.ts ├── cloud.ts ├── controllers │ ├── blacklist_controller.ts │ ├── connect_controller.ts │ ├── contacts_controller.ts │ ├── index_controller.ts │ ├── marketing_messages_controller.ts │ ├── media_controller.ts │ ├── messages_controller.ts │ ├── pairing_code_controller.ts │ ├── phone_number_controller.ts │ ├── registration_controller.ts │ ├── session_controller.ts │ ├── templates_controller.ts │ ├── webhook_controller.ts │ └── webhook_fake_controller.ts ├── defaults.ts ├── i18n.ts ├── index.ts ├── jobs │ ├── add_to_blacklist.ts │ ├── bind_bridge.ts │ ├── broadcast.ts │ ├── bulk_parser.ts │ ├── bulk_report.ts │ ├── bulk_sender.ts │ ├── bulk_status.ts │ ├── bulk_webhook.ts │ ├── commander.ts │ ├── incoming.ts │ ├── listener.ts │ ├── logout.ts │ ├── media.ts │ ├── notification.ts │ ├── outgoing.ts │ └── reload.ts ├── locales │ ├── en.json │ ├── pt.json │ └── pt_BR.json ├── qrcode.d.ts ├── router.ts ├── services │ ├── auth_state.ts │ ├── auto_connect.ts │ ├── blacklist.ts │ ├── broadcast.ts │ ├── broadcast_amqp.ts │ ├── client.ts │ ├── client_baileys.ts │ ├── client_forward.ts │ ├── config.ts │ ├── config_by_env.ts │ ├── config_redis.ts │ ├── contact.ts │ ├── contact_baileys.ts │ ├── contact_dummy.ts │ ├── data_store.ts │ ├── data_store_file.ts │ ├── data_store_redis.ts │ ├── incoming.ts │ ├── incoming_amqp.ts │ ├── incoming_baileys.ts │ ├── inject_route.ts │ ├── inject_route_dummy.ts │ ├── listener.ts │ ├── listener_amqp.ts │ ├── listener_baileys.ts │ ├── logger.ts │ ├── logout.ts │ ├── logout_amqp.ts │ ├── logout_baileys.ts │ ├── media_store.ts │ ├── media_store_file.ts │ ├── media_store_s3.ts │ ├── message_filter.ts │ ├── middleware.ts │ ├── middleware_next.ts │ ├── on_new_login_alert.ts │ ├── on_new_login_generate_token.ts │ ├── outgoing.ts │ ├── outgoing_amqp.ts │ ├── outgoing_cloud_api.ts │ ├── redis.ts │ ├── reload.ts │ ├── reload_amqp.ts │ ├── reload_baileys.ts │ ├── response.ts │ ├── security.ts │ ├── send_error.ts │ ├── session.ts │ ├── session_file.ts │ ├── session_redis.ts │ ├── session_store.ts │ ├── session_store_file.ts │ ├── session_store_redis.ts │ ├── socket.ts │ ├── store.ts │ ├── store_file.ts │ ├── store_redis.ts │ ├── template.ts │ └── transformer.ts ├── standalone.ts ├── store │ ├── make-in-memory-store.ts │ ├── make-ordered-dictionary.ts │ └── object-repository.ts ├── waker.ts ├── web.ts └── worker.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .cache 4 | *.md 5 | .editorconfig 6 | docker-compose.yml 7 | Dockerfile 8 | develop.Dockerfile 9 | README.md 10 | .env 11 | node_modules 12 | yarn-error.log -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = false -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # WEBHOOK_URL=http://localhost:3000/webhooks/whatsapp 2 | # WEBHOOK_TOKEN=GoGnZZGn6DRtUQoWnUzgxDZL 3 | # WEBHOOK_HEADER=api_access_token 4 | IGNORE_GROUP_MESSAGES=true 5 | IGNORE_BROADCAST_STATUSES=true 6 | 7 | STORAGE_BUCKET_NAME=unoapi 8 | STORAGE_ACCESS_KEY_ID=my-minio 9 | STORAGE_SECRET_ACCESS_KEY=2NVQWHTTT3asdasMgqapGchy6yAMZn 10 | STORAGE_REGION=us-east-1 11 | STORAGE_ENDPOINT=http://localhost:9000 12 | STORAGE_FORCE_PATH_STYLE=true 13 | 14 | MINIO_SERVER_URL=http://localhost:9000 15 | MINIO_BROWSER_REDIRECT_URL=http://localhost:9001 16 | MINIO_SITE_REGION=$STORAGE_REGION 17 | MINIO_ROOT_USER=$STORAGE_ACCESS_KEY_ID 18 | MINIO_ROOT_PASSWORD=$STORAGE_SECRET_ACCESS_KEY 19 | UNOAPI_AUTH_TOKEN=jsdoijohiewr948hwodjqsjdjldasdlk 20 | 21 | REJECT_CALLS=Oi, não consigo atender ligações no whatsapp, poderia me mandar uma mensagem? 22 | REJECT_CALLS_WEBHOOK=Eu estava te ligando no whatsapp... 23 | SEND_CONNECTION_STATUS=true 24 | 25 | # Name that will be displayed on smartphone connection 26 | CONFIG_SESSION_PHONE_CLIENT=Unoapi 27 | 28 | # Browser Name = Chrome | Firefox | Edge | Opera | Safari 29 | 30 | CONFIG_SESSION_PHONE_NAME=Chrome 31 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | *.lock 3 | .eslintrc.json -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["plugin:@typescript-eslint/recommended", "prettier", "plugin:prettier/recommended"], 4 | "parserOptions": { 5 | "ecmaVersion": 2022, 6 | "sourceType": "module" 7 | }, 8 | "env": { 9 | "node": true 10 | }, 11 | "rules": { 12 | "no-var": "error", 13 | "indent": [ 14 | "error", 15 | 2, 16 | { 17 | "SwitchCase": 1 18 | } 19 | ], 20 | "no-console": "error", 21 | "no-multi-spaces": "error", 22 | "space-in-parens": "error", 23 | "no-multiple-empty-lines": "error", 24 | "prefer-const": "error" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [clairton] 4 | custom: ['00020126580014br.gov.bcb.pix01360e42d192-f4d6-4672-810b-41d69eba336e5204000053039865406100.005802BR5924CLAIRTON RODRIGO HEINZEN6005Xaxim610989825-00062290525MWPT78825270172816811340663042E8F'] 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [ main develop ] 6 | tags: 7 | - 'v*.*.*' 8 | 9 | pull_request: 10 | branches: [ main develop ] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 21 20 | cache: yarn 21 | - run: yarn install 22 | - run: yarn lint 23 | - run: yarn format 24 | - run: yarn test 25 | 26 | build: 27 | needs: test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Docker meta 34 | id: meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: | 38 | ${{ github.repository }} 39 | tags: | 40 | type=schedule 41 | type=ref,event=branch 42 | type=ref,event=pr 43 | type=semver,pattern={{version}} 44 | type=semver,pattern={{major}}.{{minor}} 45 | type=semver,pattern={{major}} 46 | type=sha 47 | 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v3 50 | 51 | - name: Login to Docker Hub 52 | if: github.event_name != 'pull_request' 53 | uses: docker/login-action@v3 54 | with: 55 | username: ${{ secrets.DOCKERHUB_USERNAME }} 56 | password: ${{ secrets.DOCKERHUB_TOKEN }} 57 | 58 | - name: Set up Docker Buildx 59 | uses: docker/setup-buildx-action@v3 60 | 61 | - name: Build and push 62 | uses: docker/build-push-action@v5 63 | with: 64 | platforms: linux/amd64,linux/arm64 65 | context: . 66 | file: ./Dockerfile 67 | push: ${{ github.event_name != 'pull_request' }} 68 | tags: ${{ steps.meta.outputs.tags }} 69 | labels: ${{ steps.meta.outputs.labels }} 70 | 71 | - name: Docker Hub Description 72 | uses: peter-evans/dockerhub-description@v3 73 | with: 74 | username: ${{ secrets.DOCKERHUB_USERNAME }} 75 | password: ${{ secrets.DOCKERHUB_PASSWORD }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/* 3 | dist/* 4 | yarn-error.log 5 | coverage/* 6 | data/medias/* 7 | data/stores/* 8 | data/sessions/* 9 | .env 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 150, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS builder 2 | 3 | ENV NODE_ENV=development 4 | 5 | RUN apk --update --no-cache add git 6 | 7 | WORKDIR /app 8 | 9 | ADD ./package.json ./package.json 10 | ADD ./yarn.lock ./yarn.lock 11 | RUN yarn 12 | 13 | ADD ./src ./src 14 | ADD ./public ./public 15 | ADD ./tsconfig.json ./tsconfig.json 16 | RUN yarn build 17 | 18 | FROM node:22-alpine 19 | 20 | LABEL \ 21 | maintainer="Clairton Rodrigo Heinzen " \ 22 | org.opencontainers.image.title="Unoapi Cloud" \ 23 | org.opencontainers.image.description="Unoapi Cloud" \ 24 | org.opencontainers.image.authors="Clairton Rodrigo Heinzen " \ 25 | org.opencontainers.image.url="https://github.com/clairton/unoapi-cloud" \ 26 | org.opencontainers.image.vendor="https://clairton.eti.br" \ 27 | org.opencontainers.image.licenses="GPLv3" 28 | 29 | ENV NODE_ENV=production 30 | 31 | RUN addgroup -S u && adduser -S u -G u 32 | WORKDIR /home/u/app 33 | 34 | COPY --from=builder /app/dist ./dist 35 | COPY --from=builder /app/public ./public 36 | COPY --from=builder /app/package.json ./package.json 37 | COPY --from=builder /app/yarn.lock ./yarn.lock 38 | 39 | 40 | RUN apk --update --no-cache add git ffmpeg 41 | RUN yarn 42 | RUN apk del git 43 | 44 | ENTRYPOINT yarn start 45 | -------------------------------------------------------------------------------- /__tests__/routes/blacklist.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { mock } from 'jest-mock-extended' 3 | import { App } from '../../src/app' 4 | import { Incoming } from '../../src/services/incoming' 5 | import { Outgoing } from '../../src/services/outgoing' 6 | import { defaultConfig, getConfig } from '../../src/services/config' 7 | import { SessionStore } from '../../src/services/session_store' 8 | import { OnNewLogin } from '../../src/services/socket' 9 | import { Reload } from '../../src/services/reload' 10 | import { Logout } from '../../src/services/logout' 11 | 12 | const addToBlacklist = jest.fn().mockReturnValue(Promise.resolve(true)) 13 | 14 | const sessionStore = mock() 15 | const getConfigTest: getConfig = async (_phone: string) => { 16 | return defaultConfig 17 | } 18 | 19 | describe('blacklist routes', () => { 20 | test('update', async () => { 21 | const incoming = mock() 22 | const outgoing = mock() 23 | const onNewLogin = mock() 24 | const reload = mock() 25 | const logout = mock() 26 | const app: App = new App(incoming, outgoing, '', getConfigTest, sessionStore, onNewLogin, addToBlacklist, reload, logout) 27 | const res = await request(app.server).post('/2/blacklist/1').send({ttl: 1, to: '3'}) 28 | expect(addToBlacklist).toHaveBeenCalledWith('2', '1', '3', 1); 29 | expect(res.status).toEqual(200) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /__tests__/routes/index.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { mock } from 'jest-mock-extended' 3 | 4 | import { App } from '../../src/app' 5 | import { Incoming } from '../../src/services/incoming' 6 | import { getConfig } from '../../src/services/config' 7 | import { Outgoing } from '../../src/services/outgoing' 8 | import { SessionStore } from '../../src/services/session_store' 9 | import { OnNewLogin } from '../../src/services/socket' 10 | import { addToBlacklist } from '../../src/services/blacklist' 11 | import { Reload } from '../../src/services/reload' 12 | import { Logout } from '../../src/services/logout' 13 | const addToBlacklist = mock() 14 | const sessionStore = mock() 15 | 16 | describe('index routes', () => { 17 | test('ping', async () => { 18 | const incoming = mock() 19 | const outgoing = mock() 20 | const getConfig = mock() 21 | const onNewLogin = mock() 22 | const reload = mock() 23 | const logout = mock() 24 | const app: App = new App(incoming, outgoing, '', getConfig, sessionStore, onNewLogin, addToBlacklist, reload, logout) 25 | const res = await request(app.server).get('/ping') 26 | expect(res.text).toEqual('pong!') 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /__tests__/routes/media.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | 3 | import { App } from '../../src/app' 4 | import { Incoming } from '../../src/services/incoming' 5 | import { DataStore } from '../../src/services/data_store' 6 | import { defaultConfig, getConfig } from '../../src/services/config' 7 | import { mock } from 'jest-mock-extended' 8 | import { writeFileSync, existsSync, mkdirSync } from 'fs' 9 | import { Outgoing } from '../../src/services/outgoing' 10 | import { MediaStore } from '../../src/services/media_store' 11 | import { getStore, Store } from '../../src/services/store' 12 | import { SessionStore } from '../../src/services/session_store' 13 | import { OnNewLogin } from '../../src/services/socket' 14 | import { addToBlacklist } from '../../src/services/blacklist' 15 | import { Reload } from '../../src/services/reload' 16 | import { Logout } from '../../src/services/logout' 17 | const addToBlacklist = mock() 18 | 19 | const sessionStore = mock() 20 | 21 | const phone = `${new Date().getTime()}` 22 | const messageId = `wa.${new Date().getTime()}` 23 | const url = `http://somehost` 24 | const mimetype = 'text/plain' 25 | const extension = 'txt' 26 | 27 | const dataStore = mock() 28 | const store = mock() 29 | const mediaStore = mock() 30 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 31 | const getTestStore: getStore = async (_phone: string, _config: object) => { 32 | store.dataStore = dataStore 33 | store.mediaStore = mediaStore 34 | return store 35 | } 36 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 37 | const getConfigTest: getConfig = async (_phone: string) => { 38 | defaultConfig.getStore = getTestStore 39 | return defaultConfig 40 | } 41 | 42 | describe('media routes', () => { 43 | let incoming: Incoming 44 | let outgoing: Outgoing 45 | let app: App 46 | 47 | beforeEach(() => { 48 | incoming = mock() 49 | outgoing = mock() 50 | const onNewLogin = mock() 51 | const reload = mock() 52 | const logout = mock() 53 | app = new App(incoming, outgoing, url, getConfigTest, sessionStore, onNewLogin, addToBlacklist, reload, logout) 54 | }) 55 | 56 | test('index', async () => { 57 | const mediaData = { 58 | messaging_product: 'whatsapp', 59 | url: `${url}/v15.0/download/${phone}/${messageId}.${extension}`, 60 | // file_name: `${phone}/${messageId}.${extension}`, 61 | mime_type: mimetype, 62 | id: `${phone}/${messageId}`, 63 | } 64 | mediaStore.getMedia.mockReturnValue(new Promise((resolve) => resolve(mediaData))) 65 | await request(app.server).get(`/v15.0/${phone}/${messageId}`).expect(200, mediaData) 66 | }) 67 | 68 | test('download', async () => { 69 | const name = `${phone}/${messageId}.${extension}` 70 | const fileName = `./data/medias/${name}` 71 | const parts = fileName.split('/') 72 | const dir: string = parts.splice(0, parts.length - 1).join('/') 73 | if (!existsSync(dir)) { 74 | mkdirSync(dir) 75 | } 76 | writeFileSync(fileName, `${new Date().getTime()}`) 77 | const endpoint = `/v15.0/download/${name}` 78 | mediaStore.downloadMedia.mockImplementation(async (r) => { 79 | return r.download(fileName, name) 80 | }) 81 | const response = await request(app.server) 82 | .get(endpoint) 83 | .expect(200) 84 | .buffer() 85 | .parse((res: request.Response, callback) => { 86 | if (res) { 87 | res.setEncoding('binary') 88 | let data = '' 89 | res.on('data', (chunk) => { 90 | data += chunk 91 | }) 92 | res.on('end', () => { 93 | callback(null, Buffer.from(data, 'binary')) 94 | }) 95 | } 96 | }) 97 | expect(response.headers['content-disposition']).toEqual(`attachment; filename="${name.split('/')[1]}"`) 98 | expect(response.headers['content-type']).toContain(mimetype) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /__tests__/routes/messages.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { mock } from 'jest-mock-extended' 3 | 4 | import { App } from '../../src/app' 5 | import { Incoming } from '../../src/services/incoming' 6 | import { Outgoing } from '../../src/services/outgoing' 7 | import { defaultConfig, getConfig } from '../../src/services/config' 8 | import { Response } from '../../src/services/response' 9 | import { getStore, Store } from '../../src/services/store' 10 | import { SessionStore } from '../../src/services/session_store' 11 | import { OnNewLogin } from '../../src/services/socket' 12 | import { addToBlacklist } from '../../src/services/blacklist' 13 | import { Reload } from '../../src/services/reload' 14 | import { Logout } from '../../src/services/logout' 15 | const addToBlacklist = mock() 16 | 17 | const sessionStore = mock() 18 | const store = mock() 19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 20 | const getConfigTest: getConfig = async (_phone: string) => { 21 | defaultConfig.getStore = getTestStore 22 | return defaultConfig 23 | } 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | const getTestStore: getStore = async (_phone: string, _config: object) => { 26 | return store 27 | } 28 | 29 | let phone: string 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | let json: any 32 | let app: App 33 | let incoming: Incoming 34 | let outgoing: Outgoing 35 | 36 | describe('messages routes', () => { 37 | beforeEach(() => { 38 | phone = `${new Date().getTime()}` 39 | json = { data: `${new Date().getTime()}` } 40 | outgoing = mock() 41 | incoming = mock() 42 | const onNewLogin = mock() 43 | const reload = mock() 44 | const logout = mock() 45 | app = new App(incoming, outgoing, '', getConfigTest, sessionStore, onNewLogin, addToBlacklist, reload, logout) 46 | }) 47 | 48 | test('whatsapp with sucess', async () => { 49 | const sendSpy = jest.spyOn(incoming, 'send') 50 | const r: Response = { ok: { any: '1' } } 51 | const p: Promise = new Promise((resolve) => resolve(r)) 52 | jest.spyOn(incoming, 'send').mockReturnValue(p) 53 | const res = await request(app.server).post(`/v15.0/${phone}/messages`).send(json) 54 | expect(res.status).toEqual(200) 55 | expect(sendSpy).toHaveBeenCalledWith(phone, json, { endpoint: 'messages' }) 56 | }) 57 | 58 | test('whatsapp with 400 status', async () => { 59 | jest.spyOn(incoming, 'send').mockRejectedValue(new Error('cannot login')) 60 | const res = await request(app.server).post(`/v15.0/${phone}/messages`).send(json) 61 | expect(res.status).toEqual(400) 62 | }) 63 | 64 | test('whatsapp with error', async () => { 65 | const response: Response = { 66 | error: { code: 1, title: 'humm' }, 67 | ok: { o: 'skjdh' }, 68 | } 69 | const p: Promise = new Promise((resolve) => resolve(response)) 70 | jest.spyOn(incoming, 'send').mockReturnValue(p) 71 | const sendSpy = jest.spyOn(outgoing, 'send') 72 | const res = await request(app.server).post(`/v15.0/${phone}/messages`).send(json) 73 | expect(sendSpy).toHaveBeenCalledWith(phone, response.error) 74 | expect(res.status).toEqual(200) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /__tests__/routes/templates.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { mock } from 'jest-mock-extended' 3 | 4 | import { App } from '../../src/app' 5 | import { Incoming } from '../../src/services/incoming' 6 | import { Outgoing } from '../../src/services/outgoing' 7 | import { getStore, Store } from '../../src/services/store' 8 | import { Config, getConfig } from '../../src/services/config' 9 | import { DataStore } from '../../src/services/data_store' 10 | import { SessionStore } from '../../src/services/session_store' 11 | import { OnNewLogin } from '../../src/services/socket' 12 | import { addToBlacklist } from '../../src/services/blacklist' 13 | import { Reload } from '../../src/services/reload' 14 | import { Logout } from '../../src/services/logout' 15 | const addToBlacklist = mock() 16 | 17 | const sessionStore = mock() 18 | const store = mock() 19 | const config = mock() 20 | const dataStore = mock() 21 | const getConfig = mock() 22 | const onNewLogin = mock() 23 | const reload = mock() 24 | const logout = mock() 25 | 26 | const loadTemplates = jest.spyOn(dataStore, 'loadTemplates') 27 | loadTemplates.mockResolvedValue([]) 28 | const getStore = jest.spyOn(config, 'getStore') 29 | getStore.mockResolvedValue(store) 30 | store.dataStore = dataStore 31 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 32 | const getConfigTest: getConfig = async (_phone: string) => { 33 | return config 34 | } 35 | 36 | describe('templates routes', () => { 37 | test('index', async () => { 38 | const incoming = mock() 39 | const outgoing = mock() 40 | const app: App = new App(incoming, outgoing, '', getConfigTest, sessionStore, onNewLogin, addToBlacklist, reload, logout) 41 | const res = await request(app.server).get('/v15.0/123/message_templates') 42 | expect(res.status).toEqual(200) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /__tests__/routes/webook.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { mock } from 'jest-mock-extended' 3 | 4 | import { App } from '../../src/app' 5 | import { Incoming } from '../../src/services/incoming' 6 | import { Outgoing } from '../../src/services/outgoing' 7 | import { defaultConfig, getConfig } from '../../src/services/config' 8 | import { SessionStore } from '../../src/services/session_store' 9 | import { OnNewLogin } from '../../src/services/socket' 10 | import { addToBlacklist } from '../../src/services/blacklist' 11 | import { Reload } from '../../src/services/reload' 12 | import { Logout } from '../../src/services/logout' 13 | const addToBlacklist = mock() 14 | 15 | const sessionStore = mock() 16 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 17 | const getConfigTest: getConfig = async (_phone: string) => { 18 | return defaultConfig 19 | } 20 | 21 | describe('webhook routes', () => { 22 | test('whatsapp', async () => { 23 | const incoming = mock() 24 | const outgoing = mock() 25 | const onNewLogin = mock() 26 | const reload = mock() 27 | const logout = mock() 28 | const app: App = new App(incoming, outgoing, '', getConfigTest, sessionStore, onNewLogin, addToBlacklist, reload, logout) 29 | const res = await request(app.server).post('/webhooks/whatsapp/123') 30 | expect(res.status).toEqual(200) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /__tests__/services/blacklist.ts: -------------------------------------------------------------------------------- 1 | 2 | jest.mock('../../src/services/redis') 3 | import { isInBlacklistInMemory, addToBlacklistInMemory, cleanBlackList, isInBlacklistInRedis } from '../../src/services/blacklist' 4 | import { redisGet, redisKeys, blacklist } from '../../src/services/redis' 5 | 6 | const redisGetMock = redisGet as jest.MockedFunction 7 | const redisKeysMock = redisKeys as jest.MockedFunction 8 | const blacklistMock = blacklist as jest.MockedFunction 9 | 10 | describe('service blacklist webhook', () => { 11 | test('return false isInBlacklistInMemory', async () => { 12 | await cleanBlackList() 13 | expect(await isInBlacklistInMemory('x', 'y', { to: 'w' })).toBe('') 14 | }) 15 | 16 | test('return addToBlacklistInMemory', async () => { 17 | await cleanBlackList() 18 | expect(await addToBlacklistInMemory('x', 'y', 'w', 100000)).toBe(true) 19 | expect(await isInBlacklistInMemory('x', 'y', { to: 'w' })).toBe('w') 20 | }) 21 | 22 | test('return false isInBlacklistInRedis', async () => { 23 | await cleanBlackList() 24 | redisKeysMock.mockReturnValue(Promise.resolve(['unoapi-webhook-blacklist:x:y:w'])) 25 | redisGetMock.mockReturnValue(Promise.resolve('1')) 26 | blacklistMock.mockReturnValue('unoapi-webhook-blacklist:::') 27 | expect(await isInBlacklistInRedis('x', 'y', { to: 'w' })).toBe('w') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /__tests__/services/client.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionInProgress } from '../../src/services/client' 2 | 3 | describe('service client ConnectionInProgress', () => { 4 | test('return a message', async () => { 5 | const message = `${new Date().getMilliseconds()}` 6 | const e: Error = new ConnectionInProgress(message) 7 | expect(e.message).toBe(message) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /__tests__/services/commander.ts: -------------------------------------------------------------------------------- 1 | import { parseDocument } from 'yaml' 2 | 3 | describe('service commander', () => { 4 | const url = 'http://localhost:3000' 5 | const header = 'api_access_token' 6 | const token = 123 7 | 8 | test('parse yml \n', async () => { 9 | const string = `url: ${url}\nheader: ${header}\ntoken: ${token}` 10 | // const object = { 11 | // header, 12 | // url, 13 | // token, 14 | // } 15 | const doc = parseDocument(string) 16 | expect(doc.toJS().header).toBe(header) 17 | expect(doc.toJS().token).toBe(token) 18 | expect(doc.toJS().url).toBe(url) 19 | }) 20 | 21 | test('parse yml', async () => { 22 | const string = ` 23 | url: ${url} 24 | header: ${header} 25 | token: ${token}` 26 | const doc = parseDocument(string) 27 | expect(doc.toJS().header).toBe(header) 28 | expect(doc.toJS().token).toBe(token) 29 | expect(doc.toJS().url).toBe(url) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /__tests__/services/config.ts: -------------------------------------------------------------------------------- 1 | import { mock } from 'jest-mock-extended' 2 | jest.mock('node-fetch') 3 | import { Store, getStore } from '../../src/services/store' 4 | import { DataStore } from '../../src/services/data_store' 5 | import { MediaStore } from '../../src/services/media_store' 6 | import { getConfig, getConfigDefault } from '../../src/services/config' 7 | 8 | let store: Store 9 | const getConfig: getConfig = getConfigDefault 10 | let getStore: getStore 11 | let phone: string 12 | 13 | const individualPayload = { 14 | key: { 15 | remoteJid: 'askjhasd@kslkjasd.xom', 16 | fromMe: false, 17 | id: 'kasjhdkjhasjkshad', 18 | }, 19 | message: { 20 | conversation: 'skdfkdshf', 21 | }, 22 | } 23 | 24 | const groupPayload = { 25 | key: { 26 | remoteJid: 'askjhasd@g.us', 27 | fromMe: false, 28 | id: 'kasjhdkjhasjkshad', 29 | }, 30 | message: { 31 | conversation: 'skdfkdshf', 32 | }, 33 | } 34 | 35 | describe('config', () => { 36 | beforeEach(() => { 37 | store = mock() 38 | store.dataStore = mock() 39 | store.mediaStore = mock() 40 | phone = `${new Date().getTime()}` 41 | }) 42 | 43 | test('getMessageMetada Indifidual', async () => { 44 | expect(await (await getConfig(phone)).getMessageMetadata(individualPayload)).toBe(individualPayload) 45 | }) 46 | 47 | test('getMessageMetada Group', async () => { 48 | expect(await (await getConfig(phone)).getMessageMetadata(groupPayload)).toBe(groupPayload) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /__tests__/services/config_redis.ts: -------------------------------------------------------------------------------- 1 | jest.mock('../../src/services/redis') 2 | import { getConfig } from '../../src/services/redis' 3 | import { getConfigRedis } from '../../src/services/config_redis' 4 | import { configs } from '../../src/services/config' 5 | import { WEBHOOK_HEADER } from '../../src/defaults' 6 | const mockGetConfig = getConfig as jest.MockedFunction 7 | 8 | describe('service config redis', () => { 9 | beforeEach(() => { 10 | configs.clear() 11 | }) 12 | 13 | test('use redis', async () => { 14 | const ignoreGroupMessages = false 15 | mockGetConfig.mockResolvedValue({ ignoreGroupMessages }) 16 | const config = await getConfigRedis(`${new Date().getTime()}`) 17 | expect(config.ignoreGroupMessages).toBe(ignoreGroupMessages) 18 | }) 19 | 20 | test('use default', async () => { 21 | mockGetConfig.mockResolvedValue({}) 22 | const config = await getConfigRedis(`${new Date().getTime()}`) 23 | expect(config.ignoreGroupMessages).toBe(true) 24 | expect(config.ignoreBroadcastMessages).toBe(false) 25 | }) 26 | 27 | // test('use env', async () => { 28 | // console.log('>>>>>>>>>>', JSON.stringify(process.env.IGNORE_GROUP_MESSAGES)) 29 | // const copy = process.env.IGNORE_GROUP_MESSAGES 30 | // process.env['IGNORE_GROUP_MESSAGES'] = 'false' 31 | // mockGetConfig.mockResolvedValue({}) 32 | // const config = await getConfigRedis(`${new Date().getTime()}`) 33 | // process.env.IGNORE_GROUP_MESSAGES = copy 34 | // expect(config.ignoreGroupMessages).toBe(false) 35 | // }) 36 | 37 | test('use webhook url redis', async () => { 38 | const url = `${new Date().getTime()}${new Date().getTime()}` 39 | mockGetConfig.mockResolvedValue({ webhooks: [{ url }] }) 40 | const config = await getConfigRedis(`${new Date().getTime()}`) 41 | expect(config.webhooks[0].url).toBe(url) 42 | }) 43 | 44 | test('use webhook header redis with value in env too', async () => { 45 | const headerEnv = `${new Date().getTime()}-env` 46 | const copy = process.env.WEBHOOK_HEADER 47 | process.env.WEBHOOK_HEADER = headerEnv 48 | const headerRedis = `${new Date().getTime()}-redis` 49 | mockGetConfig.mockResolvedValue({ webhooks: [{ url: 'http....', header: headerRedis }] }) 50 | const config = await getConfigRedis(`${new Date().getTime()}`) 51 | process.env.WEBHOOK_HEADER = copy 52 | expect(config.webhooks[0].header).toBe(headerRedis) 53 | }) 54 | 55 | test('use webhook header env where not in redis', async () => { 56 | mockGetConfig.mockResolvedValue({ webhooks: [{}] }) 57 | const config = await getConfigRedis(`${new Date().getTime()}`) 58 | expect(config.webhooks[0].header).toBe(WEBHOOK_HEADER) 59 | }) 60 | 61 | test('get media store', async () => { 62 | const phone = `${new Date().getTime()}` 63 | const config = await getConfigRedis(phone) 64 | config.useS3 = true 65 | config.useRedis = true 66 | const store = await config.getStore(phone, config) 67 | const { mediaStore } = store 68 | expect(mediaStore.type).toBe('s3') 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /__tests__/services/data_store_file.ts: -------------------------------------------------------------------------------- 1 | import { DataStore } from '../../src/services/data_store' 2 | import { getDataStoreFile } from '../../src/services/data_store_file' 3 | import { defaultConfig } from '../../src/services/config' 4 | 5 | describe('service data store file', () => { 6 | const phone = `${new Date().getMilliseconds()}` 7 | test('return a new instance', async () => { 8 | const dataStore: DataStore = await getDataStoreFile(phone, defaultConfig) 9 | expect(dataStore).toBe(dataStore) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/services/incoming_baileys.ts: -------------------------------------------------------------------------------- 1 | import { IncomingBaileys } from '../../src/services/incoming_baileys' 2 | import { Incoming } from '../../src/services/incoming' 3 | import { Listener } from '../../src/services/listener' 4 | import { getClient, Client, Contact } from '../../src/services/client' 5 | import { Config, defaultConfig, getConfig, getConfigDefault } from '../../src/services/config' 6 | import { mock } from 'jest-mock-extended' 7 | import logger from '../../src/services/logger' 8 | 9 | class DummyClient implements Client { 10 | phone: string 11 | config: Config 12 | 13 | constructor() { 14 | this.phone = `${new Date().getTime()}` 15 | this.config = defaultConfig 16 | } 17 | 18 | // eslint-disable-next-line @typescript-eslint/no-empty-function 19 | async connect(): Promise {} 20 | // eslint-disable-next-line @typescript-eslint/no-empty-function 21 | async disconnect(): Promise {} 22 | // eslint-disable-next-line @typescript-eslint/no-empty-function 23 | async logout(): Promise {} 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any 25 | async send(payload: any): Promise { 26 | return true 27 | } 28 | async getMessageMetadata(data: T) { 29 | return data 30 | } 31 | 32 | public async contacts(_numbers: string[]) { 33 | const contacts: Contact[] = [] 34 | return contacts 35 | } 36 | } 37 | 38 | const dummyClient = new DummyClient() 39 | 40 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 41 | const getClientDummy: getClient = async ({ 42 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 43 | phone, 44 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 45 | listener, 46 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 47 | getConfig, 48 | }: { 49 | phone: string 50 | listener: Listener 51 | getConfig: getConfig 52 | }): Promise => { 53 | return dummyClient 54 | } 55 | 56 | const onNewLogin = async (phone: string) => { 57 | logger.info('New login %s', phone) 58 | } 59 | 60 | describe('service incoming baileys', () => { 61 | test('send', async () => { 62 | const phone = `${new Date().getTime()}` 63 | const service: Listener = mock() 64 | const baileys: Incoming = new IncomingBaileys(service, getConfigDefault, getClientDummy, onNewLogin) 65 | const payload: object = { humm: new Date().getTime() } 66 | const send = jest.spyOn(dummyClient, 'send') 67 | await baileys.send(phone, payload, {}) 68 | expect(send).toHaveBeenCalledTimes(1) 69 | expect(send).toHaveBeenCalledWith(payload, {}) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /__tests__/services/listener_baileys.ts: -------------------------------------------------------------------------------- 1 | import { mock } from 'jest-mock-extended' 2 | import { Store, getStore } from '../../src/services/store' 3 | import { DataStore } from '../../src/services/data_store' 4 | import { MediaStore } from '../../src/services/media_store' 5 | import { Config, getConfig, defaultConfig, getMessageMetadataDefault } from '../../src/services/config' 6 | import { ListenerBaileys } from '../../src/services/listener_baileys' 7 | import { Outgoing } from '../../src/services/outgoing' 8 | import { Broadcast } from '../../src/services/broadcast' 9 | 10 | let store: Store 11 | let getConfig: getConfig 12 | let config: Config 13 | let getStore: getStore 14 | let phone 15 | let outgoing: Outgoing 16 | let service: ListenerBaileys 17 | let broadcast: Broadcast 18 | 19 | const textPayload = { 20 | key: { 21 | remoteJid: 'askjhasd@kslkjasd.xom', 22 | fromMe: false, 23 | id: 'kasjhdkjhasjkshad', 24 | }, 25 | message: { 26 | conversation: 'skdfkdshf', 27 | }, 28 | } 29 | 30 | describe('service listener baileys', () => { 31 | beforeEach(() => { 32 | config = defaultConfig 33 | config.ignoreGroupMessages = true 34 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 35 | getStore = async (_phone: string): Promise => store 36 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 37 | getConfig = async (_phone: string) => { 38 | config.getStore = getStore 39 | config.getMessageMetadata = getMessageMetadataDefault 40 | return config 41 | } 42 | store = mock() 43 | broadcast = mock() 44 | outgoing = mock() 45 | store.dataStore = mock() 46 | store.mediaStore = mock() 47 | phone = `${new Date().getMilliseconds()}` 48 | service = new ListenerBaileys(outgoing, broadcast, getConfig) 49 | }) 50 | 51 | test('send call sendOne when text', async () => { 52 | const func = jest.spyOn(service, 'sendOne') 53 | await service.process(phone, [textPayload], 'notify') 54 | expect(func).toHaveBeenCalledTimes(1) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /__tests__/services/media_store_file.ts: -------------------------------------------------------------------------------- 1 | import { DataStore } from '../../src/services/data_store' 2 | import { getDataStore } from '../../src/services/data_store' 3 | import { mock } from 'jest-mock-extended' 4 | import { getMediaStoreFile } from '../../src/services/media_store_file' 5 | import { MediaStore } from '../../src/services/media_store' 6 | import { defaultConfig } from '../../src/services/config' 7 | const phone = `${new Date().getTime()}` 8 | const messageId = `wa.${new Date().getTime()}` 9 | const url = `http://somehost` 10 | const mimetype = 'text/plain' 11 | const extension = 'txt' 12 | 13 | const message = { 14 | messaging_product: 'whatsapp', 15 | id: `${phone}/${messageId}`, 16 | mime_type: mimetype 17 | } 18 | const dataStore = mock() 19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 20 | const getTestDataStore: getDataStore = async (_phone: string, _config: unknown): Promise => { 21 | return dataStore 22 | } 23 | 24 | describe('media routes', () => { 25 | let mediaStore: MediaStore 26 | 27 | beforeEach(() => { 28 | dataStore.loadMediaPayload.mockReturnValue(new Promise((resolve) => resolve(message))) 29 | mediaStore = getMediaStoreFile(phone, defaultConfig, getTestDataStore) 30 | }) 31 | 32 | test('getMedia', async () => { 33 | const response = { 34 | url: `${url}/v15.0/download/${phone}/${messageId}.${extension}`, 35 | ...message 36 | } 37 | expect(await mediaStore.getMedia(url, messageId)).toStrictEqual(response) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /__tests__/services/outgoing_cloud_api.ts: -------------------------------------------------------------------------------- 1 | import { mock } from 'jest-mock-extended' 2 | jest.mock('../../src/services/blacklist') 3 | jest.mock('node-fetch') 4 | import { OutgoingCloudApi } from '../../src/services/outgoing_cloud_api' 5 | import { Outgoing } from '../../src/services/outgoing' 6 | import { Store, getStore } from '../../src/services/store' 7 | import fetch, { Response } from 'node-fetch' 8 | import { DataStore } from '../../src/services/data_store' 9 | import { MediaStore } from '../../src/services/media_store' 10 | import { Config, getConfig, defaultConfig, getMessageMetadataDefault, Webhook } from '../../src/services/config' 11 | import logger from '../../src/services/logger' 12 | import { isInBlacklistInMemory } from '../../src/services/blacklist' 13 | 14 | const mockFetch = fetch as jest.MockedFunction 15 | const isInBlacklistMock = isInBlacklistInMemory as jest.MockedFunction 16 | const webhook = mock() 17 | 18 | let store: Store 19 | let getConfig: getConfig 20 | let config: Config 21 | let getStore: getStore 22 | const url = 'http://example.com' 23 | let phone 24 | let service: Outgoing 25 | 26 | const textPayload = { 27 | text: { 28 | body: 'test' 29 | }, 30 | type: 'text', 31 | to: 'abc', 32 | } 33 | 34 | describe('service outgoing whatsapp cloud api', () => { 35 | beforeEach(() => { 36 | config = defaultConfig 37 | config.ignoreGroupMessages = true 38 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 39 | getStore = async (_phone: string): Promise => store 40 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 41 | getConfig = async (_phone: string) => { 42 | config.getStore = getStore 43 | config.getMessageMetadata = getMessageMetadataDefault 44 | return config 45 | } 46 | store = mock() 47 | store.dataStore = mock() 48 | store.mediaStore = mock() 49 | phone = `${new Date().getMilliseconds()}` 50 | service = new OutgoingCloudApi(getConfig, isInBlacklistInMemory) 51 | }) 52 | 53 | test('send text with success', async () => { 54 | const mockUrl = `${url}/${phone}` 55 | logger.debug(`Mock url ${mockUrl}`) 56 | mockFetch.mockReset() 57 | expect(fetch).toHaveBeenCalledTimes(0) 58 | const response = new Response('ok', { status: 200 }) 59 | response.ok = true 60 | mockFetch.mockResolvedValue(response) 61 | await service.send(phone, textPayload) 62 | expect(fetch).toHaveBeenCalledTimes(1) 63 | }) 64 | 65 | test('not sendHttp in webhook when is in blacklist', async () => { 66 | mockFetch.mockReset() 67 | expect(mockFetch).toHaveBeenCalledTimes(0) 68 | isInBlacklistMock.mockResolvedValue(Promise.resolve('1')) 69 | await service.sendHttp(phone, webhook, textPayload, {}) 70 | expect(mockFetch).toHaveBeenCalledTimes(0) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /__tests__/services/session_store_file.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { SessionStoreFile } from '../../src/services/session_store_file' 3 | import { MAX_CONNECT_RETRY } from '../../src/defaults' 4 | 5 | describe('service session store file', () => { 6 | test('return a phones', async () => { 7 | const name = `${new Date().getTime()}` 8 | const d = new fs.Dirent() 9 | d.name = name 10 | d.isDirectory = () => true 11 | fs.existsSync = jest.fn().mockReturnValue(true) 12 | fs.readdirSync = jest.fn().mockReturnValue([d]) 13 | const store = new SessionStoreFile() 14 | const phones = await store.getPhones() 15 | expect(phones[0]).toBe(name) 16 | }) 17 | 18 | test('return empty', async () => { 19 | fs.existsSync = jest.fn().mockReturnValue(true) 20 | fs.readdirSync = jest.fn().mockReturnValue([]) 21 | const store = new SessionStoreFile() 22 | const phones = await store.getPhones() 23 | expect(phones.length).toBe(0) 24 | }) 25 | 26 | test('return empty when nos exist dir', async () => { 27 | fs.existsSync = jest.fn().mockReturnValue(false) 28 | const store = new SessionStoreFile() 29 | const phones = await store.getPhones() 30 | expect(phones.length).toBe(0) 31 | }) 32 | test('return a standby on count and verify', async () => { 33 | const session = `${new Date().getTime()}` 34 | const store = new SessionStoreFile() 35 | const getConnectCount = store.getConnectCount 36 | store.getConnectCount = async (phone: string) => { 37 | if (session == phone) { 38 | return MAX_CONNECT_RETRY + 1 39 | } 40 | return getConnectCount(session) 41 | } 42 | expect(await store.verifyStatusStandBy(session)).toBe(true) 43 | }) 44 | test('return a no standby on count and verify', async () => { 45 | const session = `${new Date().getTime()}` 46 | const store = new SessionStoreFile() 47 | const getConnectCount = store.getConnectCount 48 | store.getConnectCount = async (phone: string) => { 49 | if (session == phone) { 50 | return MAX_CONNECT_RETRY - 2 51 | } 52 | return getConnectCount(session) 53 | } 54 | expect(!!await store.verifyStatusStandBy(session)).toBe(false) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /__tests__/services/socket.ts: -------------------------------------------------------------------------------- 1 | jest.mock('baileys') 2 | import { OnDisconnected, OnQrCode, OnReconnect, OnNotification, connect } from '../../src/services/socket' 3 | import makeWASocket, { WASocket } from 'baileys' 4 | import { mock } from 'jest-mock-extended' 5 | import { Store } from '../../src/services/store' 6 | import { defaultConfig } from '../../src/services/config' 7 | import logger from '../../src/services/logger' 8 | import { SessionStore } from '../../src/services/session_store' 9 | const mockMakeWASocket = makeWASocket as jest.MockedFunction 10 | 11 | describe('service socket', () => { 12 | let phone: string 13 | let store: Store 14 | let mockWaSocket 15 | let mockBaileysEventEmitter 16 | let mockOn 17 | let onQrCode: OnQrCode 18 | let onNotification: OnNotification 19 | let onDisconnected: OnDisconnected 20 | let onReconnect: OnReconnect 21 | const onNewLogin = async (phone: string) => { 22 | logger.info('New login', phone) 23 | } 24 | 25 | beforeEach(async () => { 26 | phone = `${new Date().getMilliseconds()}` 27 | store = mock() 28 | store.sessionStore = mock() 29 | mockWaSocket = mock() 30 | mockBaileysEventEmitter = mock() 31 | Reflect.set(mockWaSocket, 'ev', mockBaileysEventEmitter) 32 | mockOn = jest.spyOn(mockWaSocket.ev, 'on') 33 | mockMakeWASocket.mockReturnValue(mockWaSocket) 34 | onQrCode = jest.fn() 35 | onNotification = jest.fn() 36 | onDisconnected = jest.fn() 37 | onReconnect = jest.fn() 38 | }) 39 | 40 | test('call connect status connected false', async () => { 41 | const response = await connect({ 42 | phone, 43 | store, 44 | onQrCode, 45 | onNotification, 46 | onDisconnected, 47 | onReconnect, 48 | onNewLogin, 49 | attempts: 1, 50 | time: 1, 51 | config: defaultConfig, 52 | }) 53 | expect(response && response.status.attempt).toBe(1) 54 | }) 55 | 56 | test('call connect and subscribe 2 events', async () => { 57 | await connect({ phone, store, onQrCode, onNotification, onDisconnected, onReconnect, onNewLogin, attempts: 1, time: 1, config: defaultConfig }) 58 | expect(mockOn).toHaveBeenCalledTimes(2) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /__tests__/services/template.ts: -------------------------------------------------------------------------------- 1 | import { Template } from '../../src/services/template' 2 | import { Config, getConfig } from '../../src/services/config' 3 | import { defaultConfig } from '../../src/services/config' 4 | import { Store, getStore } from '../../src/services/store' 5 | import { mock } from 'jest-mock-extended' 6 | import { DataStore } from '../../src/services/data_store' 7 | 8 | describe('template', () => { 9 | const config = { ...defaultConfig } 10 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 11 | const getConfig: getConfig = async (_phone: string) => config 12 | const store = mock() 13 | store.dataStore = mock() 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | const getStore: getStore = async (phone: string, config: Config) => store 16 | config.getStore = getStore 17 | const service = new Template(getConfig) 18 | test('bind', async () => { 19 | const phone = `${new Date().getTime()}` 20 | const templateName = 'unoapi-connect' 21 | const templateConnect = { 22 | id: 2, 23 | name: templateName, 24 | status: 'APPROVED', 25 | category: 'UTILITY', 26 | language: 'pt_BR', 27 | components: [ 28 | { 29 | text: 'url: {{url}}\nheader: {{header}}\ntoken: {{token}}', 30 | type: 'BODY', 31 | parameters: [ 32 | { 33 | type: 'text', 34 | text: 'url', 35 | }, 36 | { 37 | type: 'text', 38 | text: 'header', 39 | }, 40 | { 41 | type: 'text', 42 | text: 'token', 43 | }, 44 | ], 45 | }, 46 | ], 47 | } 48 | store.dataStore.loadTemplates = async () => [templateConnect] 49 | const url = 'https://chatwoot.odontoexcellence.net/webhooks/whatsapp' 50 | const header = 'api_access_token' 51 | const token = 'kbKC5xzfuVcAtgzoVKmVHxGo' 52 | const parameters = [ 53 | { 54 | type: 'body', 55 | parameters: [ 56 | { 57 | type: 'text', 58 | text: url, 59 | }, 60 | { 61 | type: 'text', 62 | text: header, 63 | }, 64 | { 65 | type: 'text', 66 | text: token, 67 | }, 68 | ], 69 | }, 70 | ] 71 | expect((await service.bind(phone, templateName, parameters)).text).toBe(`url: ${url}\nheader: ${header}\ntoken: ${token}`) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /data/medias/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/data/medias/.gitkeep -------------------------------------------------------------------------------- /data/sessions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/data/sessions/.gitkeep -------------------------------------------------------------------------------- /data/stores/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/data/stores/.gitkeep -------------------------------------------------------------------------------- /develop.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | RUN apk --update --no-cache add git ffmpeg 4 | RUN wget -O /bin/wait-for https://raw.githubusercontent.com/eficode/wait-for/v2.2.3/wait-for 5 | RUN chmod +x /bin/wait-for 6 | 7 | WORKDIR /app 8 | 9 | ADD ./src ./src 10 | ADD ./package.json ./package.json 11 | ADD ./tsconfig.json ./tsconfig.json 12 | ADD ./nodemon.json ./nodemon.json 13 | ADD ./yarn.lock ./yarn.lock 14 | 15 | RUN yarn 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | x-base: &base 4 | build: 5 | dockerfile: develop.Dockerfile 6 | entrypoint: echo 'ok!' 7 | tty: true 8 | stdin_open: true 9 | volumes: 10 | - ./:/app 11 | working_dir: /app 12 | environment: 13 | NODE_ENV: development 14 | AMQP_URL: amqp://guest:guest@rabbitmq:5672?frameMax=8192 15 | BASE_URL: http://web:9876 16 | REDIS_URL: redis://redis:6379 17 | STORAGE_ENDPOINT: http://minio:9000 18 | GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS} 19 | 20 | x-minio: &minio 21 | image: quay.io/minio/minio:latest 22 | command: server --console-address ":9001" --address ":9000" /data 23 | env_file: .env 24 | expose: 25 | - 9000:9000 26 | - 9001:9001 27 | healthcheck: 28 | test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] 29 | interval: 30s 30 | timeout: 20s 31 | retries: 3 32 | 33 | services: 34 | web: 35 | <<: *base 36 | restart: on-failure 37 | entrypoint: sh -c 'wait-for redis:6379 rabbitmq:5672 -- yarn web-dev' 38 | ports: 39 | - 9876:9876 40 | depends_on: 41 | - worker 42 | 43 | worker: 44 | <<: *base 45 | restart: on-failure 46 | entrypoint: sh -c 'wait-for redis:6379 rabbitmq:5672 -- yarn worker-dev' 47 | depends_on: 48 | - redis 49 | - rabbitmq 50 | 51 | rabbitmq: 52 | image: rabbitmq:4-management-alpine 53 | restart: on-failure 54 | ports: 55 | - 5672:5672 56 | - 15672:15672 57 | volumes: 58 | - rabbitmq:/var/lib/rabbitmq 59 | 60 | redis: 61 | image: redis:7-alpine 62 | restart: on-failure 63 | volumes: 64 | - redis:/data 65 | command: redis-server --appendonly yes 66 | ports: 67 | - 6379:6379 68 | 69 | minio: 70 | <<: *minio 71 | restart: on-failure 72 | ports: 73 | - 9000:9000 74 | - 9001:9001 75 | volumes: 76 | - minio:/data 77 | entrypoint: | 78 | /bin/sh -c " 79 | echo 'Starting Minio and setting up project bucket...' && 80 | /usr/bin/minio server --console-address ':9001' --address ':9000' /data & 81 | ## Wait for minio to be ready 82 | until (echo > /dev/tcp/localhost/9000) >/dev/null 2>&1; do 83 | echo 'Waiting for minio to be ready...' 84 | sleep 5 85 | done && 86 | ## Configure bucket 87 | /usr/bin/mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && 88 | if ! /usr/bin/mc ls local/$STORAGE_BUCKET_NAME > /dev/null 2>&1; then 89 | /usr/bin/mc mb local/$STORAGE_BUCKET_NAME && 90 | echo 'Bucket created successfully' 91 | else 92 | echo 'Bucket already exists, skipping creation' 93 | fi && 94 | ## Keep container running 95 | wait 96 | " 97 | 98 | volumes: 99 | rabbitmq: 100 | redis: 101 | minio: 102 | -------------------------------------------------------------------------------- /examples/bulk/default.csv: -------------------------------------------------------------------------------- 1 | number;message 2 | 554999621461;Ei Clairton, aqui é a Bete da Odonto Excellence, sua parcela vence amanhã, pague em dia para não perder seu desconto! 3 | 554988890955;Teste! -------------------------------------------------------------------------------- /examples/bulk/sisodonto.csv: -------------------------------------------------------------------------------- 1 | NOME;CLIENTE;VENCIMENTO;TELEFONE;MENSAGEM;DOC 2 | CLAIRTON RODRIGO HEINZEN;XXXXXXX;02/01/2023;4933400829;Prezado #NOME, ainda não identificamos o pagamento de sua parcela. Evite juros e multas mantendo o seu pagamento em dia. Caso já tenha efetuado o pagamento, favor desconsiderar o presente aviso.;# 69283605934 # -------------------------------------------------------------------------------- /examples/bulk/sisodonto.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/bulk/sisodonto.xlsx -------------------------------------------------------------------------------- /examples/bulk/sisodonto_batch.csv: -------------------------------------------------------------------------------- 1 | NOME;CLIENTE;VENCIMENTO;TELEFONE;MENSAGEM;DOC 2 | CLAIRTON RODRIGO HEINZEN;XXXXXXX;02/01/2023;49988290955;Prezado(a) #NOME 001, ainda não identificamos o pagamento de sua parcela. Evite juros e multas mantendo o seu pagamento em dia. Caso já tenha efetuado o pagamento, favor desconsiderar o presente aviso.;# 69283605934 # 3 | SILVIA CASTAGNA HEINZEN;XXXXXXX;02/01/2023;49900000000;Prezado(a) #NOME 002, ainda não identificamos o seu pagamento. Evite juros e multas mantendo o seu pagamento em dia. Caso já tenha efetuado o pagamento, favor desconsiderar o presente aviso.;# 69283605934 # 4 | SEBASTIAO;XXXXXXX;02/01/2023;49800000000;Prezado(a) #NOME 003, ainda não identificamos o seu pagamento. Evite juros e multas mantendo o seu pagamento em dia. Caso já tenha efetuado o pagamento, favor desconsiderar o presente aviso.;# 69283605934 # -------------------------------------------------------------------------------- /examples/bulk/sisodonto_with_audio.csv: -------------------------------------------------------------------------------- 1 | NOME;CLIENTE;VENCIMENTO;TELEFONE;MENSAGEM;DOC;TIPO;URL 2 | CLAIRTON RODRIGO HEINZEN;XXXXXXX;02/01/2023;46988159229;teste;# 69283605934 #;audio;https://chatwoot-web.nyc3.digitaloceanspaces.com/celso_portioli.mp3 -------------------------------------------------------------------------------- /examples/bulk/sisodonto_with_image.csv: -------------------------------------------------------------------------------- 1 | NOME;CLIENTE;VENCIMENTO;TELEFONE;MENSAGEM;DOC;TIPO;URL 2 | CLAIRTON RODRIGO HEINZEN;XXXXXXX;02/01/2023;49988290955;Neste mês temos o evento de divulgação da Clínica em Chapecó - nesta campanha vamos presentear 30 mulheres com uma LIMPEZA DE PELE e você foi contemplada com este presente. \n obs: Seus dados foram captados em um evento social. \n Acreditamos que você merece sempre estar em sua melhor versão! \n Pode fazer o agendamento por aqui, estamos te aguardando para tirar as dúvidas! \r\n Siga a gente no instagram https://instagram.com/botolifting.chapeco;# 69283605934 #;image;https://lh3.googleusercontent.com/geougc/AF1QipPQLuVj9dEZAklDIRXFzCL5vpC8xy1F9agRi04N=h305-no -------------------------------------------------------------------------------- /examples/bulk/sisodonto_with_speech.csv: -------------------------------------------------------------------------------- 1 | NOME;CLIENTE;VENCIMENTO;TELEFONE;MENSAGEM;DOC;TIPO 2 | CLAIRTON RODRIGO HEINZEN;XXXXXXX;02/01/2023;49988290955;Oi #NOME, não identificamos o pagamento da parcela que vence em #VENCIMENTO;#7856765675657#;speech -------------------------------------------------------------------------------- /examples/chatwoot-uno/README.md: -------------------------------------------------------------------------------- 1 | # Chatwoot with Unoapi inbox 2 | 3 | 4 | Up the unoapi service with `https://github.com/clairton/unoapi-cloud/tree/main?tab=readme-ov-file#start-options` or `https://github.com/clairton/unoapi-cloud/#install-as-systemctl`, use version >= 1.17.0 5 | 6 | 7 | Get the chatwoot in `https://github.com/clairton/chatwoot` ou docker tag `clairton/chatwoot:v3.10.6-uno` change the env `UNOAPI_AUTH_TOKEN` with the same value of unoapi 8 | 9 | Got to inboxes and choose whatsapp 10 | 11 | ![image](prints/channel.png) 12 | 13 | Create with provider unoapi 14 | 15 | ![image](prints/create.png) 16 | 17 | After save, edit the channel and in tab configuration 18 | 19 | ![image](prints/configuration.png) 20 | 21 | Click em connect 22 | 23 | ![image](prints/connect.png) 24 | 25 | Read de qrcode 26 | 27 | ![image](prints/read.png) 28 | -------------------------------------------------------------------------------- /examples/chatwoot-uno/prints/channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot-uno/prints/channel.png -------------------------------------------------------------------------------- /examples/chatwoot-uno/prints/configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot-uno/prints/configuration.png -------------------------------------------------------------------------------- /examples/chatwoot-uno/prints/connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot-uno/prints/connect.png -------------------------------------------------------------------------------- /examples/chatwoot-uno/prints/create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot-uno/prints/create.png -------------------------------------------------------------------------------- /examples/chatwoot-uno/prints/read.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot-uno/prints/read.png -------------------------------------------------------------------------------- /examples/chatwoot/README.md: -------------------------------------------------------------------------------- 1 | # Unoapi Cloud with Chatwoot 2 | 3 | Get the chatwoot source or image and change the env `WHATSAPP_CLOUD_BASE_URL=http://localhost:9876` and up, or use a custom version with some features to integrate with in `https://github.com/clairton/chatwoot`: 4 | - put the agent name in message 5 | - use official whatsapp cloud api and unoapi in the same chatwoot instance 6 | - send read events to unoapi 7 | - work with groups 8 | - show message sent by another whatsapp connection 9 | - disable 24 window 10 | - sicronize user and group profile image 11 | 12 | Copy the token ![image](prints/copy_token.png) 13 | 14 | Up the unoapi service with `https://github.com/clairton/unoapi-cloud/tree/main?tab=readme-ov-file#start-options` or `https://github.com/clairton/unoapi-cloud/#install-as-systemctl` 15 | 16 | Put in .env 17 | 18 | ```env 19 | WEBHOOK_URL=http://localhost:3000/webhooks/whatsapp 20 | WEBHOOK_TOKEN=the_chatwoot_token 21 | WEBHOOK_HEADER=api_access_token 22 | ```` 23 | 24 | Change the_chatwoot_token for your token. 25 | 26 | Create a inbox in Chatwoot with Whatsapp Cloud API type, in "Phone number ID" and "Business Account ID" put the number without "+". In API Key put the same content of env UNOAPI_AUTH_TOKEN 27 | 28 | ![image](prints/create_channel.png) 29 | 30 | Create a contact with de same number, and send a message. 31 | 32 | ![image](prints/create_contact.png) 33 | 34 | In a contact with the same number read the qrcode. 35 | 36 | ![image](prints/read_qrcode.png) 37 | 38 | If you up unoapi with redis, on create instance show message with new token 39 | 40 | ![image](prints/copy_uno_token.png) 41 | 42 | Put the new auth token in chatwoot inbox config 43 | 44 | ![image](prints/update_inbox.png) 45 | -------------------------------------------------------------------------------- /examples/chatwoot/prints/copy_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot/prints/copy_token.png -------------------------------------------------------------------------------- /examples/chatwoot/prints/copy_uno_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot/prints/copy_uno_token.png -------------------------------------------------------------------------------- /examples/chatwoot/prints/create_channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot/prints/create_channel.png -------------------------------------------------------------------------------- /examples/chatwoot/prints/create_contact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot/prints/create_contact.png -------------------------------------------------------------------------------- /examples/chatwoot/prints/read_qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot/prints/read_qrcode.png -------------------------------------------------------------------------------- /examples/chatwoot/prints/update_inbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/chatwoot/prints/update_inbox.png -------------------------------------------------------------------------------- /examples/scripts/chatwoot-model.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | 4 | x-base: &base 5 | image: ${CHATWOOT_IMAGE} 6 | restart: 'no' 7 | command: echo 'ok' 8 | environment: 9 | ENABLE_ACCOUNT_SIGNUP: false 10 | REDIS_URL: redis://:${REDIS_PASS}@redis:6379 11 | DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASS}@postgres:5432/${POSTGRES_DB} 12 | ACTIVE_STORAGE_SERVICE: s3_compatible 13 | STORAGE_BUCKET_NAME: ${CW_BUCKET} 14 | STORAGE_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY} 15 | STORAGE_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY} 16 | STORAGE_REGION: ${MINIO_REGION} 17 | STORAGE_ENDPOINT: https://${MINIOAPI_SUBDOMAIN} 18 | STORAGE_FORCE_PATH_STYLE: true 19 | SECRET_KEY_BASE: ${SECRET_KEY_CW} 20 | FRONTEND_URL: https://${CHATWOOT_SUBDOMAIN} 21 | DEFAULT_LOCALE: 'pt_BR' 22 | INSTALLATION_ENV: docker 23 | NODE_ENV: production 24 | RAILS_ENV: production 25 | #MAILER_INBOUND_EMAIL_DOMAIN: 26 | #RAILS_INBOUND_EMAIL_SERVICE: 27 | #SMTP_ADDRESS: 28 | #SMTP_PASSWORD: 29 | #SMTP_PORT: 30 | #SMTP_USERNAME: 31 | #SMTP_AUTHENTICATION: 32 | #SMTP_ENABLE_STARTTLS_AUTO: 33 | RAILS_MASTER_KEY: ${SECRET_KEY_CW} 34 | WEB_CONCURRENCY: 5 35 | RAILS_MAX_THREADS: 5 36 | SIDEKIQ_CONCURRENCY: 5 37 | LOG_LEVEL: info 38 | UNOAPI_AUTH_TOKEN: ${UNOAPI_AUTH_TOKEN} 39 | ENABLE_RACK_ATTACK: false 40 | POSTGRES_STATEMENT_TIMEOUT: 600s 41 | RACK_TIMEOUT_SERVICE_TIMEOUT: 600s 42 | services: 43 | web: 44 | <<: *base 45 | command: ['bundle', 'exec', 'rails', 's', '-p', '3000', '-b', '0.0.0.0'] 46 | restart: always 47 | labels: 48 | - traefik.enable=true 49 | - traefik.http.routers.chatwootweb.rule=Host(`${CHATWOOT_SUBDOMAIN}`) 50 | - traefik.http.routers.chatwootweb.entrypoints=web,websecure 51 | - traefik.http.services.chatwootweb.loadbalancer.server.port=3000 52 | - traefik.http.routers.chatwootweb.service=chatwootweb 53 | - traefik.http.routers.chatwootweb.tls.certresolver=letsencryptresolver 54 | networks: 55 | - ${DOCKERNETWORK} 56 | deploy: 57 | resources: 58 | limits: 59 | cpus: '3.00' 60 | memory: 2048M 61 | reservations: 62 | cpus: '0.25' 63 | memory: 512M 64 | 65 | worker: 66 | <<: *base 67 | command: ['bundle', 'exec', 'sidekiq', '-C', 'config/sidekiq.yml'] 68 | restart: always 69 | networks: 70 | - ${DOCKERNETWORK} 71 | deploy: 72 | replicas: 1 73 | resources: 74 | limits: 75 | cpus: '1.00' 76 | memory: 512M 77 | reservations: 78 | cpus: '0.25' 79 | memory: 512M 80 | 81 | migrate: 82 | <<: *base 83 | restart: 'no' 84 | command: ['bundle', 'exec', 'rails', 'db:chatwoot_prepare'] 85 | networks: 86 | - ${DOCKERNETWORK} 87 | 88 | networks: 89 | ${DOCKERNETWORK}: 90 | external: true -------------------------------------------------------------------------------- /examples/scripts/docker-model.yaml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | 5 | ## --------------------------- TRAEFIK --------------------------- ## 6 | traefik: 7 | image: traefik:v3.3.3 8 | command: 9 | - "--api.dashboard=true" 10 | - "--providers.docker=true" 11 | - "--providers.docker.exposedbydefault=false" 12 | - "--providers.docker.network=${DOCKERNETWORK}" 13 | - "--entrypoints.web.address=:80" 14 | - "--entrypoints.web.http.redirections.entryPoint.to=websecure" 15 | - "--entrypoints.web.http.redirections.entryPoint.scheme=https" 16 | - "--entrypoints.web.http.redirections.entrypoint.permanent=true" 17 | - "--entrypoints.websecure.address=:443" 18 | - "--entrypoints.web.transport.respondingTimeouts.idleTimeout=3600" 19 | - "--certificatesresolvers.letsencryptresolver.acme.httpchallenge=true" 20 | - "--certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web" 21 | - "--certificatesresolvers.letsencryptresolver.acme.storage=/etc/traefik/letsencrypt/acme.json" 22 | - "--certificatesresolvers.letsencryptresolver.acme.email=${LETSENCRYPT_MAIL}" 23 | - "--log.level=DEBUG" 24 | - "--log.format=common" 25 | - "--log.filePath=/var/log/traefik/traefik.log" 26 | - "--accesslog=true" 27 | - "--accesslog.filepath=/var/log/traefik/access-log" 28 | labels: 29 | - "traefik.enable=true" 30 | - "traefik.http.middlewares.redirect-https.redirectscheme.scheme=https" 31 | - "traefik.http.middlewares.redirect-https.redirectscheme.permanent=true" 32 | - "traefik.http.routers.http-catchall.rule=HostRegexp(`{host:.+}`)" 33 | - "traefik.http.routers.http-catchall.entrypoints=web" 34 | - "traefik.http.routers.http-catchall.middlewares=redirect-https" 35 | - "traefik.http.routers.http-catchall.priority=1" 36 | volumes: 37 | - "certsVolume:/etc/traefik/letsencrypt" 38 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 39 | #- "/var/log/traefik:/var/log/traefik" 40 | ports: 41 | - "80:80" 42 | - "443:443" 43 | networks: 44 | - ${DOCKERNETWORK} 45 | 46 | ## --------------------------- PORTAINER --------------------------- ## 47 | portainer: 48 | image: portainer/portainer-ce:latest 49 | 50 | volumes: 51 | - /var/run/docker.sock:/var/run/docker.sock 52 | - portainer_data:/data 53 | 54 | networks: 55 | - ${DOCKERNETWORK} 56 | ports: 57 | - 9000:9000 58 | labels: 59 | - "traefik.enable=true" 60 | - "traefik.http.routers.portainer.rule=Host(`${PORTAINER_SUBDOMAIN}`)" 61 | - "traefik.http.services.portainer.loadbalancer.server.port=9000" 62 | - "traefik.http.routers.portainer.tls.certresolver=letsencryptresolver" 63 | - "traefik.http.routers.portainer.service=portainer" 64 | - "traefik.docker.network=${DOCKERNETWORK}" 65 | - "traefik.http.routers.portainer.entrypoints=websecure" 66 | - "traefik.http.routers.portainer.priority=1" 67 | 68 | 69 | volumes: 70 | #PORTAINER_VOLUME 71 | portainer_data: 72 | 73 | #TRAEFIK_VOLUME 74 | certsVolume: 75 | 76 | networks: 77 | ${DOCKERNETWORK}: 78 | external: true 79 | name: ${DOCKERNETWORK} 80 | -------------------------------------------------------------------------------- /examples/scripts/serverSetup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | apt update && apt upgrade -y 4 | apt install -y ca-certificates curl gnupg wget jp lsb-release 5 | 6 | install -m 0755 -d /etc/apt/keyrings 7 | 8 | apt purge -y docker-* 9 | 10 | if [ -f /etc/os-release ]; then 11 | . /etc/os-release 12 | if [ "$ID" = "debian" ]; then 13 | 14 | echo "Instalação em ambiente Debian" 15 | curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/docker.gpg 16 | chmod a+r /etc/apt/trusted.gpg.d/docker.gpg 17 | echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/trusted.gpg.d/docker.gpg] https://download.docker.com/linux/debian \ 18 | $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null 19 | 20 | elif [ "$ID" = "ubuntu" ]; then 21 | echo "Instalação em ambiente UBUNTU" 22 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc 23 | chmod a+r /etc/apt/keyrings/docker.asc 24 | echo \ 25 | "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ 26 | $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \ 27 | sudo tee /etc/apt/sources.list.d/docker.list 28 | fi 29 | fi 30 | 31 | apt update && apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y 32 | 33 | echo "Digite o nome para a rede da sua instalação docker:" 34 | read dockerNetwork 35 | dockerNetwork=${dockerNetwork:-"network"} 36 | 37 | docker network create $dockerNetwork 38 | 39 | echo "Ambiente configurado com sucesso!" -------------------------------------------------------------------------------- /examples/scripts/uno-model.yaml: -------------------------------------------------------------------------------- 1 | x-base: &base 2 | image: ${UNOAPI_IMAGE} 3 | entrypoint: echo 'ok!' 4 | networks: 5 | - ${DOCKERNETWORK} 6 | environment: 7 | AMQP_URL: amqp://${RABBITMQ_USER}:${RABBITMQ_PASS}@rabbitmq:5672 8 | REDIS_URL: redis://:${REDIS_PASS}@redis:6379 9 | BASE_URL: https://${UNOAPI_SUBDOMAIN} #Dominio da Unoapi 10 | STORAGE_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY} #Access key do Bucket 11 | STORAGE_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY} #Secret Key do Bucket 12 | STORAGE_BUCKET_NAME: ${UNOAPI_BUCKET} #Nome do Bucket 13 | STORAGE_ENDPOINT: https://${MINIOAPI_SUBDOMAIN} #Domínio do Bucket 14 | STORAGE_FORCE_PATH_STYLE: true #true para minio 15 | STORAGE_REGION: ${MINIO_REGION} #Região do bucket 16 | IGNORE_GROUP_MESSAGES: true #Ignorar Mensagem de Grupos -> true OU false 17 | IGNORE_OWN_MESSAGES: false #Ignorar Mensagens Próprias de outros dispositivos, ex: App ou Web -> true OU false 18 | UNOAPI_AUTH_TOKEN: ${UNOAPI_AUTH_TOKEN} 19 | REJECT_CALLS: "" #Mensagem enviada ao Rejeitar Chamadas -> Vazio não rejeita as chamadas no app 20 | REJECT_CALLS_WEBHOOK: "Ligação Recebida" #Aviso de ligação recebida no webhook 21 | SEND_CONNECTION_STATUS: false #Receber status da conexãa -> true OU false 22 | LOG_LEVEL: debug #Nível de log da api 23 | UNO_LOG_LEVEL: debug #Nível do log da Unoapi 24 | IGNORE_YOURSELF_MESSAGES: false #Ignorar suas mensagens de outros webhooks da uno -> true OU false 25 | restart: 'no' 26 | 27 | services: 28 | cloud: 29 | <<: *base 30 | entrypoint: yarn cloud 31 | restart: always 32 | labels: 33 | - traefik.enable=true 34 | - traefik.http.routers.unoapiweb.rule=Host(`${UNOAPI_SUBDOMAIN}`) 35 | - traefik.http.routers.unoapiweb.entrypoints=web,websecure 36 | - traefik.http.services.unoapiweb.loadbalancer.server.port=9876 37 | - traefik.http.routers.unoapiweb.service=unoapiweb 38 | - traefik.http.routers.unoapiweb.tls.certresolver=letsencryptresolver 39 | deploy: 40 | resources: 41 | limits: 42 | cpus: '0.50' 43 | memory: 256M 44 | reservations: 45 | cpus: '0.25' 46 | memory: 128M 47 | 48 | networks: 49 | ${DOCKERNETWORK}: 50 | external: true -------------------------------------------------------------------------------- /examples/typebot/README.md: -------------------------------------------------------------------------------- 1 | # Unoapi Cloud with Typebot 2 | 3 | Get the typebot source or image and change the env `WHATSAPP_CLOUD_API_URL` to you Unoapi url (ex: `http://localhost:9876` 4 | 5 | In your typebot flow go to Share > Whatsapp ![image](prints/whatsapp_menu.png) 6 | 7 | Click on Add WA Phone Number ![image](prints/add_phone.png) 8 | 9 | Click in continue 10 | 11 | In the System User Token, put the token from the Unoapi env (authToken ) ![image](prints/put_token.png) 12 | 13 | In the Phone number ID, put the phone number without the + sign, ex 55999999999 ) ![image](prints/phone_number.png) 14 | 15 | Put the callback url and token in the unoapi redis or .env config, exemple below. 16 | 17 | ![image](prints/callback.png) 18 | 19 | ![image](prints/config_uno.png) 20 | 21 | ```env 22 | "webhooks":[ 23 | { 24 | "urlAbsolute":"YOUR TYPEBOT URL" 25 | "token":"Bearer YOR TOKEN", 26 | "header":"Authorization" 27 | } 28 | ], 29 | ```` 30 | 31 | And click in submit on typebot ![image](prints/callback.png) 32 | 33 | After, enable the integration on typebot and click in Publish. ![image](prints/publish.png) 34 | 35 | # Lists with typebot 36 | 37 | ### Some observations before using list on Typebot, by default Typebot is not ready to work with lists, so has some limitations. 38 | 39 | * Use max of 3 itens in the list, if you use more will be send another list 40 | 41 | ## How to use 42 | 43 | To use lists, you need to use the text bubble followed by button input. ![image](prints/lists.png) 44 | 45 | ![image](prints/exemple_list_typebot.png) 46 | 47 | ## Config unoapi to not send message to type for some numbers 48 | To work with this, set a unique id field in webhook json in redis or if use envs config, the id of webhook is a string `default` 49 | 50 | For exemplo, if your session number is Y and you want do webhook with id W to never send more message to number X 51 | 52 | ttl param is in seconds 53 | 54 | To remove a phone number your black, send ttl with 0, with ttl -1, never expire 55 | 56 | ```sh 57 | curl -i -X POST \ 58 | 'http://localhost:9876/Y/blacklist/W' \ 59 | -H 'Content-Type: application/json' \ 60 | -H 'Authorization: 1' \ 61 | -d '{ 62 | "ttl": -1, 63 | "to": "X" 64 | }' 65 | ``` 66 | 67 | Or in url format 68 | ```sh 69 | curl -i -X POST 'http://localhost:9876/5549988290955/blacklist/type?to=5549999621461&ttl=-1&access_token=1' 70 | ``` -------------------------------------------------------------------------------- /examples/typebot/prints/add_phone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/add_phone.png -------------------------------------------------------------------------------- /examples/typebot/prints/callback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/callback.png -------------------------------------------------------------------------------- /examples/typebot/prints/config_uno.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/config_uno.png -------------------------------------------------------------------------------- /examples/typebot/prints/exemple_list_typebot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/exemple_list_typebot.png -------------------------------------------------------------------------------- /examples/typebot/prints/lists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/lists.png -------------------------------------------------------------------------------- /examples/typebot/prints/phone_number.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/phone_number.png -------------------------------------------------------------------------------- /examples/typebot/prints/publish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/publish.png -------------------------------------------------------------------------------- /examples/typebot/prints/put_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/put_token.png -------------------------------------------------------------------------------- /examples/typebot/prints/whatsapp_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/typebot/prints/whatsapp_menu.png -------------------------------------------------------------------------------- /examples/unochat/.env: -------------------------------------------------------------------------------- 1 | # ------------------ atualizar esses obrigatoriamente inicio ---------------------# 2 | CHATWOOT_DOMAIN=chatwoot.lvh.me 3 | MINIO_DOMAIN=minio.lvh.me 4 | UNOAPI_DOMAIN=unoapi.lvh.me 5 | LETSECRYPT_EMAIL=contato@lvh.me 6 | STORAGE_SECRET_ACCESS_KEY=unochat_password 7 | UNOAPI_AUTH_TOKEN=any 8 | 9 | # esse vai conseguir atualizar somente depois de criar o usuario 10 | WEBHOOK_TOKEN=kf6HyCcm74pGGWzzXq1f6TvM 11 | # ------------------ atualizar esses obrigatoriamente final ---------------------# 12 | 13 | HTTP_PROTOCOL=https 14 | 15 | # url 16 | CHATWOOT_URL=$HTTP_PROTOCOL://$CHATWOOT_DOMAIN 17 | MINIO_URL=$HTTP_PROTOCOL://$MINIO_DOMAIN 18 | 19 | # minio 20 | ACTIVE_STORAGE_SERVICE=s3_compatible 21 | STORAGE_ACCESS_KEY_ID=unochat_key 22 | STORAGE_BUCKET_NAME=unochat 23 | STORAGE_ENDPOINT=$MINIO_URL 24 | STORAGE_FORCE_PATH_STYLE=true 25 | STORAGE_REGION=us-east-1 26 | MINIO_ROOT_USER=$STORAGE_ACCESS_KEY_ID 27 | MINIO_ROOT_PASSWORD=$STORAGE_SECRET_ACCESS_KEY 28 | MINIO_SERVER_URL=$MINIO_URL 29 | 30 | #postgres 31 | POSTGRES_PASSWORD=mudar_esse_valor 32 | POSTGRES_USER=chatwoot_user 33 | POSTGRES_DB=chatwoot_db 34 | 35 | #rabbitmq 36 | RABBITMQ_DEFAULT_USER=rabbitmq-uno-user 37 | RABBITMQ_DEFAULT_PASS=rabbitmq-uno-password 38 | 39 | # chatwoot 40 | FRONTEND_URL=$CHATWOOT_URL 41 | SECRET_KEY_BASE=0s8f9qwo89r923hr8y8yiUiugieigroey8ryqh3r98gqfhig9Tgfefwiryworywoi 42 | DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres:5432/$POSTGRES_DB 43 | REDIS_URL=redis://redis:6379 44 | INSTALLATION_ENV=docker 45 | NODE_ENV=production 46 | RAILS_ENV=production 47 | 48 | #unoapi 49 | WEBHOOK_HEADER=api_access_token 50 | WEBHOOK_URL=$CHATWOOT_URL/webhooks/whatsapp 51 | AMQP_URL=amqp://$RABBITMQ_DEFAULT_USER:$RABBITMQ_DEFAULT_PASS@rabbitmq:5672 52 | BASE_URL=$HTTP_PROTOCOL://$UNOAPI_DOMAIN 53 | LOG_LEVEL=debug 54 | UNO_LOG_LEVEL=debug 55 | 56 | # compose project name 57 | COMPOSE_PROJECT_NAME=unochat -------------------------------------------------------------------------------- /examples/unochat/prints/copy_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/unochat/prints/copy_token.png -------------------------------------------------------------------------------- /examples/unochat/prints/copy_uno_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/unochat/prints/copy_uno_token.png -------------------------------------------------------------------------------- /examples/unochat/prints/create_channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/unochat/prints/create_channel.png -------------------------------------------------------------------------------- /examples/unochat/prints/create_contact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/unochat/prints/create_contact.png -------------------------------------------------------------------------------- /examples/unochat/prints/create_inbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/unochat/prints/create_inbox.png -------------------------------------------------------------------------------- /examples/unochat/prints/read_qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/unochat/prints/read_qrcode.png -------------------------------------------------------------------------------- /examples/unochat/prints/update_inbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/examples/unochat/prints/update_inbox.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /logos/BebasNeue-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/logos/BebasNeue-Regular.otf -------------------------------------------------------------------------------- /logos/unoapi.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/logos/unoapi.pdf -------------------------------------------------------------------------------- /logos/unoapi_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/logos/unoapi_logo.jpg -------------------------------------------------------------------------------- /logos/unoapi_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/logos/unoapi_logo.png -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": ".ts", 4 | "ignore": ["./coverage", "./dist", "__tests__", "jest.config.js"], 5 | "legacyWatch": true 6 | } 7 | -------------------------------------------------------------------------------- /picpay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clairton/unoapi-cloud/3e3bc82e509c5423ff06882c622a1aa791c23dc4/picpay.png -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express' 2 | import { createServer, Server as HttpServer } from 'http' 3 | import { router } from './router' 4 | import { getConfig } from './services/config' 5 | import { Incoming } from './services/incoming' 6 | import { Outgoing } from './services/outgoing' 7 | import { SessionStore } from './services/session_store' 8 | import middleware from './services/middleware' 9 | import injectRoute from './services/inject_route' 10 | import injectRouteDummy from './services/inject_route_dummy' 11 | import { OnNewLogin } from './services/socket' 12 | import { Server } from 'socket.io' 13 | import { addToBlacklist } from './services/blacklist' 14 | import cors from 'cors' 15 | import { Reload } from './services/reload' 16 | import { Logout } from './services/logout' 17 | import { ContactDummy } from './services/contact_dummy' 18 | import { Contact } from './services/contact' 19 | import { middlewareNext } from './services/middleware_next' 20 | 21 | export class App { 22 | public readonly server: HttpServer 23 | public readonly socket: Server 24 | private app 25 | 26 | constructor( 27 | incoming: Incoming, 28 | outgoing: Outgoing, 29 | baseUrl: string, 30 | getConfig: getConfig, 31 | sessionStore: SessionStore, 32 | onNewLogin: OnNewLogin, 33 | addToBlacklist: addToBlacklist, 34 | reload: Reload, 35 | logout: Logout, 36 | middleware: middleware = middlewareNext, 37 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function 38 | injectRoute: injectRoute = injectRouteDummy, 39 | contact = new ContactDummy(), 40 | ) { 41 | this.app = express() 42 | this.app.use(cors({ origin: ['*'] })) 43 | this.app.use(express.json()) 44 | this.app.use(express.urlencoded({ extended: true })) 45 | this.server = createServer(this.app) 46 | this.socket = new Server(this.server, { 47 | path: '/ws', 48 | cors: { 49 | origin: '*' 50 | } 51 | }) 52 | this.router( 53 | incoming, 54 | outgoing, 55 | baseUrl, 56 | getConfig, 57 | sessionStore, 58 | this.socket, 59 | onNewLogin, 60 | addToBlacklist, 61 | reload, 62 | logout, 63 | middleware, 64 | injectRoute, 65 | contact, 66 | ) 67 | } 68 | 69 | private router( 70 | incoming: Incoming, 71 | outgoing: Outgoing, 72 | baseUrl: string, 73 | getConfig: getConfig, 74 | sessionStore: SessionStore, 75 | socket: Server, 76 | onNewLogin: OnNewLogin, 77 | addToBlacklist: addToBlacklist, 78 | reload: Reload, 79 | logout: Logout, 80 | middleware: middleware, 81 | injectRoute: injectRoute, 82 | contact: Contact, 83 | ) { 84 | const roter = router( 85 | incoming, 86 | outgoing, 87 | baseUrl, 88 | getConfig, 89 | sessionStore, 90 | socket, 91 | onNewLogin, 92 | addToBlacklist, 93 | reload, 94 | logout, 95 | middleware, 96 | injectRoute, 97 | contact, 98 | ) 99 | this.app.use(roter) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/bridge.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | import { BindBridgeJob } from './jobs/bind_bridge' 5 | import { SessionStoreRedis } from './services/session_store_redis' 6 | import { SessionStore } from './services/session_store' 7 | import { autoConnect } from './services/auto_connect' 8 | import { 9 | UNOAPI_QUEUE_BIND, 10 | UNOAPI_QUEUE_RELOAD, 11 | UNOAPI_QUEUE_LOGOUT, 12 | UNOAPI_SERVER_NAME, 13 | UNOAPI_EXCHANGE_BRIDGE_NAME, 14 | } from './defaults' 15 | import { amqpConsume } from './amqp' 16 | import { startRedis } from './services/redis' 17 | import { getConfig } from './services/config' 18 | import { getConfigRedis } from './services/config_redis' 19 | import { getClientBaileys } from './services/client_baileys' 20 | import { onNewLoginGenerateToken } from './services/on_new_login_generate_token' 21 | import logger from './services/logger' 22 | import { Listener } from './services/listener' 23 | import { ListenerAmqp } from './services/listener_amqp' 24 | import { OutgoingAmqp } from './services/outgoing_amqp' 25 | import { Outgoing } from './services/outgoing' 26 | import { version } from '../package.json' 27 | import { ReloadBaileys } from './services/reload_baileys' 28 | import { LogoutBaileys } from './services/logout_baileys' 29 | import { ReloadJob } from './jobs/reload' 30 | import { LogoutJob } from './jobs/logout' 31 | 32 | const getConfig: getConfig = getConfigRedis 33 | const outgoingAmqp: Outgoing = new OutgoingAmqp(getConfig) 34 | const listenerAmqp: Listener = new ListenerAmqp() 35 | const onNewLogin = onNewLoginGenerateToken(outgoingAmqp) 36 | const bindJob = new BindBridgeJob() 37 | const reload = new ReloadBaileys(getClientBaileys, getConfig, listenerAmqp, onNewLogin) 38 | const reloadJob = new ReloadJob(reload) 39 | const logout = new LogoutBaileys(getClientBaileys, getConfig, listenerAmqp, onNewLogin) 40 | const logoutJob = new LogoutJob(logout) 41 | 42 | const startBrigde = async () => { 43 | await startRedis() 44 | 45 | logger.info('Unoapi Cloud version %s starting bridge...', version) 46 | 47 | logger.info('Starting bind consumer') 48 | await amqpConsume( 49 | UNOAPI_EXCHANGE_BRIDGE_NAME, 50 | `${UNOAPI_QUEUE_BIND}.${UNOAPI_SERVER_NAME}`, 51 | '', 52 | bindJob.consume.bind(bindJob), 53 | { 54 | prefetch: 1, 55 | type: 'direct' 56 | } 57 | ) 58 | 59 | logger.info('Starting reload consumer') 60 | await amqpConsume( 61 | UNOAPI_EXCHANGE_BRIDGE_NAME, 62 | `${UNOAPI_QUEUE_RELOAD}.${UNOAPI_SERVER_NAME}`, 63 | '', 64 | reloadJob.consume.bind(reloadJob), 65 | { 66 | prefetch: 1, 67 | type: 'direct' 68 | } 69 | ) 70 | 71 | logger.info('Starting logout consumer') 72 | await amqpConsume( 73 | UNOAPI_EXCHANGE_BRIDGE_NAME, 74 | `${UNOAPI_QUEUE_LOGOUT}.${UNOAPI_SERVER_NAME}`, 75 | '', 76 | logoutJob.consume.bind(logoutJob), 77 | { 78 | prefetch: 1, 79 | type: 'direct' 80 | } 81 | ) 82 | 83 | const sessionStore: SessionStore = new SessionStoreRedis() 84 | 85 | logger.info('Unoapi Cloud version %s started brige!', version) 86 | 87 | await autoConnect(sessionStore, listenerAmqp, getConfigRedis, getClientBaileys, onNewLogin) 88 | } 89 | startBrigde() 90 | 91 | process.on('unhandledRejection', (reason: any, promise) => { 92 | logger.error('unhandledRejection bridge: %s %s %s', reason, reason.stack, promise) 93 | throw reason 94 | }) 95 | -------------------------------------------------------------------------------- /src/broker.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | import { 5 | UNOAPI_QUEUE_RELOAD, 6 | UNOAPI_SERVER_NAME, 7 | UNOAPI_QUEUE_MEDIA, 8 | UNOAPI_QUEUE_OUTGOING, 9 | UNOAPI_QUEUE_NOTIFICATION, 10 | UNOAPI_QUEUE_OUTGOING_PREFETCH, 11 | UNOAPI_QUEUE_BLACKLIST_ADD, 12 | NOTIFY_FAILED_MESSAGES, 13 | UNOAPI_EXCHANGE_BROKER_NAME, 14 | } from './defaults' 15 | 16 | import { amqpConsume } from './amqp' 17 | import { startRedis } from './services/redis' 18 | import { OutgoingCloudApi } from './services/outgoing_cloud_api' 19 | import { getConfigRedis } from './services/config_redis' 20 | import logger from './services/logger' 21 | import { version } from '../package.json' 22 | import { ReloadJob } from './jobs/reload' 23 | import { MediaJob } from './jobs/media' 24 | import { Reload } from './services/reload' 25 | import { OutgoingJob } from './jobs/outgoing' 26 | import { IncomingAmqp } from './services/incoming_amqp' 27 | import { Incoming } from './services/incoming' 28 | import { Outgoing } from './services/outgoing' 29 | import { isInBlacklistInRedis } from './services/blacklist' 30 | import { NotificationJob } from './jobs/notification' 31 | import { addToBlacklist } from './jobs/add_to_blacklist' 32 | 33 | const incomingAmqp: Incoming = new IncomingAmqp(getConfigRedis) 34 | const outgoingCloudApi: Outgoing = new OutgoingCloudApi(getConfigRedis, isInBlacklistInRedis) 35 | const reload = new Reload() 36 | const reloadJob = new ReloadJob(reload) 37 | const mediaJob = new MediaJob(getConfigRedis) 38 | const notificationJob = new NotificationJob(incomingAmqp) 39 | const outgingJob = new OutgoingJob(getConfigRedis, outgoingCloudApi) 40 | 41 | const startBroker = async () => { 42 | await startRedis() 43 | 44 | const prefetch = UNOAPI_QUEUE_OUTGOING_PREFETCH 45 | 46 | logger.info('Unoapi Cloud version %s starting broker...', version) 47 | 48 | logger.info('Starting reload consumer') 49 | await amqpConsume( 50 | UNOAPI_EXCHANGE_BROKER_NAME, 51 | UNOAPI_QUEUE_RELOAD, 52 | '*', 53 | reloadJob.consume.bind(reloadJob), 54 | { type: 'topic' } 55 | ) 56 | 57 | logger.info('Starting media consumer') 58 | await amqpConsume( 59 | UNOAPI_EXCHANGE_BROKER_NAME, 60 | UNOAPI_QUEUE_MEDIA, 61 | '*', 62 | mediaJob.consume.bind(mediaJob), 63 | { type: 'topic' } 64 | ) 65 | 66 | logger.info('Binding queues consumer for server %s', UNOAPI_SERVER_NAME) 67 | 68 | const notifyFailedMessages = NOTIFY_FAILED_MESSAGES 69 | 70 | logger.info('Starting outgoing consumer %s', UNOAPI_SERVER_NAME) 71 | await amqpConsume( 72 | UNOAPI_EXCHANGE_BROKER_NAME, 73 | UNOAPI_QUEUE_OUTGOING, 74 | '*', 75 | outgingJob.consume.bind(outgingJob), 76 | { notifyFailedMessages, prefetch, type: 'topic' } 77 | ) 78 | 79 | if (notifyFailedMessages) { 80 | logger.debug('Starting notification consumer %s', UNOAPI_SERVER_NAME) 81 | await amqpConsume( 82 | UNOAPI_EXCHANGE_BROKER_NAME, 83 | UNOAPI_QUEUE_NOTIFICATION, 84 | '*', 85 | notificationJob.consume.bind(notificationJob), 86 | { notifyFailedMessages: false, type: 'topic' } 87 | ) 88 | } 89 | 90 | logger.info('Starting blacklist add consumer %s', UNOAPI_SERVER_NAME) 91 | await amqpConsume( 92 | UNOAPI_EXCHANGE_BROKER_NAME, 93 | UNOAPI_QUEUE_BLACKLIST_ADD, 94 | '*', 95 | addToBlacklist, 96 | { notifyFailedMessages, prefetch, type: 'topic' } 97 | ) 98 | 99 | logger.info('Unoapi Cloud version %s started broker!', version) 100 | } 101 | startBroker() 102 | 103 | process.on('unhandledRejection', (reason: any, promise) => { 104 | logger.error('unhandledRejection broker: %s %s %s', reason, reason.stack, promise) 105 | throw reason 106 | }) 107 | -------------------------------------------------------------------------------- /src/bulker.ts: -------------------------------------------------------------------------------- 1 | import { Outgoing } from './services/outgoing' 2 | import { CommanderJob } from './jobs/commander' 3 | import { BulkStatusJob } from './jobs/bulk_status' 4 | import { BulkWebhookJob } from './jobs/bulk_webhook' 5 | import { BulkParserJob } from './jobs/bulk_parser' 6 | import { BulkSenderJob } from './jobs/bulk_sender' 7 | import { BulkReportJob } from './jobs/bulk_report' 8 | import { OutgoingCloudApi } from './services/outgoing_cloud_api' 9 | import { 10 | UNOAPI_QUEUE_BULK_PARSER, 11 | UNOAPI_QUEUE_BULK_SENDER, 12 | UNOAPI_QUEUE_COMMANDER, 13 | UNOAPI_QUEUE_BULK_STATUS, 14 | UNOAPI_QUEUE_BULK_REPORT, 15 | UNOAPI_QUEUE_BULK_WEBHOOK, 16 | UNOAPI_EXCHANGE_BROKER_NAME, 17 | } from './defaults' 18 | import { amqpConsume } from './amqp' 19 | import { IncomingAmqp } from './services/incoming_amqp' 20 | import { getConfig } from './services/config' 21 | import { getConfigRedis } from './services/config_redis' 22 | import { Incoming } from './services/incoming' 23 | import logger from './services/logger' 24 | import { isInBlacklistInRedis } from './services/blacklist' 25 | import { version } from '../package.json' 26 | 27 | const getConfig: getConfig = getConfigRedis 28 | const incomingAmqp: Incoming = new IncomingAmqp(getConfig) 29 | 30 | 31 | const outgoingCloudApi: Outgoing = new OutgoingCloudApi(getConfig, isInBlacklistInRedis) 32 | const commanderJob = new CommanderJob(outgoingCloudApi, getConfigRedis) 33 | const bulkParserJob = new BulkParserJob(outgoingCloudApi, getConfigRedis) 34 | const bulkSenderJob = new BulkSenderJob(incomingAmqp, outgoingCloudApi) 35 | const bulkStatusJob = new BulkStatusJob() 36 | const bulkReportJob = new BulkReportJob(outgoingCloudApi, getConfigRedis) 37 | const bulkWebhookJob = new BulkWebhookJob(outgoingCloudApi) 38 | 39 | const startBulker = async () => { 40 | logger.info('Unoapi Cloud version %s starting bulker...', version) 41 | 42 | logger.info('Starting commander consumer') 43 | await amqpConsume(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_COMMANDER, '*', commanderJob.consume.bind(commanderJob), { type: 'topic' }) 44 | 45 | logger.info('Starting bulk parser consumer') 46 | await amqpConsume(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BULK_PARSER, '*', bulkParserJob.consume.bind(bulkParserJob), { type: 'topic' }) 47 | 48 | logger.info('Starting bulk sender consumer') 49 | await amqpConsume(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BULK_SENDER, '*', bulkSenderJob.consume.bind(bulkSenderJob), { type: 'topic' }) 50 | 51 | logger.info('Starting bulk status consumer') 52 | await amqpConsume(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BULK_STATUS, '*', bulkStatusJob.consume.bind(bulkStatusJob), { type: 'topic' }) 53 | 54 | logger.info('Starting bulk report consumer') 55 | await amqpConsume(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BULK_REPORT, '*', bulkReportJob.consume.bind(bulkReportJob), { type: 'topic' }) 56 | 57 | logger.info('Starting bulk webhook consumer') 58 | await amqpConsume(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BULK_WEBHOOK, '*', bulkWebhookJob.consume.bind(bulkWebhookJob), { type: 'topic' }) 59 | } 60 | startBulker() 61 | 62 | process.on('unhandledRejection', (reason: any, promise) => { 63 | logger.error('unhandledRejection bulker: %s %s %s', reason, reason.stack, promise) 64 | throw reason 65 | }) 66 | -------------------------------------------------------------------------------- /src/cloud.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | import logger from './services/logger' 5 | logger.info('Starting...') 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | import './web' 9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 10 | import './worker' 11 | -------------------------------------------------------------------------------- /src/controllers/blacklist_controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import logger from '../services/logger' 3 | import { addToBlacklist } from '../services/blacklist' 4 | 5 | export class BlacklistController { 6 | private addToBlacklist: addToBlacklist 7 | 8 | constructor(addToBlacklist: addToBlacklist) { 9 | this.addToBlacklist = addToBlacklist 10 | } 11 | 12 | async update(req: Request, res: Response) { 13 | logger.debug('blacklist method %s', req.method) 14 | logger.debug('blacklist headers %s', JSON.stringify(req.headers)) 15 | logger.debug('blacklist params %s', JSON.stringify(req.params)) 16 | logger.debug('blacklist body %s', JSON.stringify(req.body)) 17 | const to = req.body.to || req.query.to 18 | const ttl = req.body.ttl || req.query.ttl || 0 19 | if (!to) { 20 | logger.info('blacklist error: to param is required') 21 | return res.status(400).send(`{"error": "to param is required"}`) 22 | } 23 | const { webhook_id, phone } = req.params 24 | await this.addToBlacklist(phone, webhook_id, to, ttl) 25 | res.status(200).send(`{"success": true}`) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/controllers/connect_controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import logger from '../services/logger' 3 | import { Reload } from '../services/reload' 4 | import { t } from '../i18n' 5 | 6 | const connect = async (phone: string) => { 7 | return ` 8 | 9 | 29 | 41 | 42 | 43 |
${t('waiting_information')}
44 |

45 |     
46 |   `
47 | }
48 | 
49 | export class ConnectController {
50 |   private reload: Reload
51 | 
52 |   constructor(reload: Reload) {
53 |     this.reload = reload
54 |   }
55 | 
56 |   public async index(req: Request, res: Response) {
57 |     logger.debug('connect method %s', JSON.stringify(req.method))
58 |     logger.debug('connect headers %s', JSON.stringify(req.headers))
59 |     logger.debug('connect params %s', JSON.stringify(req.params))
60 |     logger.debug('connect body %s', JSON.stringify(req.body))
61 |     const { phone } = req.params
62 |     const html = await connect(phone)
63 |     this.reload.run(phone)
64 |     return res.send(html)
65 |   }
66 | }
67 | 


--------------------------------------------------------------------------------
/src/controllers/contacts_controller.ts:
--------------------------------------------------------------------------------
 1 | import { Request, Response } from 'express'
 2 | import logger from '../services/logger'
 3 | import { Contact } from '../services/contact'
 4 | 
 5 | export class ContactsController {
 6 |   private service: Contact
 7 | 
 8 |   constructor(service: Contact) {
 9 |     this.service = service
10 |   }
11 | 
12 |   public async post(req: Request, res: Response) {
13 |     logger.debug('contacts post method %s', req.method)
14 |     logger.debug('contacts post headers %s', JSON.stringify(req.headers))
15 |     logger.debug('contacts post params %s', JSON.stringify(req.params))
16 |     logger.debug('contacts post body %s', JSON.stringify(req.body))
17 |     const { phone } = req.params
18 |     const contacts = await this.service.verify(phone, req.body.contacts || [])
19 |     res.status(200).send({ contacts })
20 |   }
21 | }
22 | 


--------------------------------------------------------------------------------
/src/controllers/index_controller.ts:
--------------------------------------------------------------------------------
 1 | import { Request, Response } from 'express'
 2 | import logger from '../services/logger'
 3 | import path from 'path'
 4 | 
 5 | class IndexController {
 6 | 
 7 |   public root(req: Request, res: Response) {
 8 |     logger.debug('root method %s', JSON.stringify(req.method))
 9 |     logger.debug('root headers %s', JSON.stringify(req.headers))
10 |     logger.debug('root params %s', JSON.stringify(req.params))
11 |     logger.debug('root body %s', JSON.stringify(req.body))
12 |     res.set('Content-Type', 'text/html')
13 |     //return res.sendFile(path.join(__dirname, '..', '..', 'public', 'index.html'))
14 |     return res.sendFile(path.resolve('./public/index.html'))
15 |   }
16 | 
17 |   public socket(req: Request, res: Response) {
18 |     logger.debug('socket method %s', JSON.stringify(req.method))
19 |     logger.debug('socket headers %s', JSON.stringify(req.headers))
20 |     logger.debug('socket params %s', JSON.stringify(req.params))
21 |     logger.debug('socket body %s', JSON.stringify(req.body))
22 |     res.set('Content-Type', 'text/javascript')
23 |     return res.sendFile(path.resolve('./node_modules/socket.io-client/dist/socket.io.min.js'))
24 |   }
25 | 
26 |   public ping(req: Request, res: Response) {
27 |     logger.debug('ping method %s', JSON.stringify(req.method))
28 |     logger.debug('ping headers %s', JSON.stringify(req.headers))
29 |     logger.debug('ping params %s', JSON.stringify(req.params))
30 |     logger.debug('ping body %s', JSON.stringify(req.body))
31 |     res.set('Content-Type', 'text/plain')
32 |     return res.status(200).send('pong!')
33 |   }
34 | 
35 |   public debugToken(req: Request, res: Response) {
36 |     logger.debug('debug token method %s', JSON.stringify(req.method))
37 |     logger.debug('debug token headers %s', JSON.stringify(req.headers))
38 |     logger.debug('debug token params %s', JSON.stringify(req.params))
39 |     logger.debug('debug token query %s', JSON.stringify(req.query))
40 |     logger.debug('debug token body %s', JSON.stringify(req.body))
41 |     res.set('Content-Type', 'application/json')
42 |     return res.status(200).send({
43 |       data: {
44 |         is_valid: true,
45 |         app_id: 'unoapi',
46 |         application: 'unoapi',
47 |         expires_at: 0,
48 |         scopes: ['whatsapp_business_management', 'whatsapp_business_messaging'],
49 |       },
50 |     })
51 |   }
52 | }
53 | 
54 | export const indexController = new IndexController()
55 | 


--------------------------------------------------------------------------------
/src/controllers/marketing_messages_controller.ts:
--------------------------------------------------------------------------------
 1 | // https://developers.facebook.com/docs/whatsapp/marketing-messages-lite-api/?locale=pt_BR
 2 | 
 3 | import { Incoming } from '../services/incoming'
 4 | import { Outgoing } from '../services/outgoing'
 5 | import { MessagesController } from './messages_controller'
 6 | 
 7 | export class MarketingMessagesController extends MessagesController {
 8 | 
 9 |   constructor(incoming: Incoming, outgoing: Outgoing) {
10 |     super(incoming, outgoing)
11 |     this.endpoint = 'marketing_messages'
12 |   }
13 | }
14 | 


--------------------------------------------------------------------------------
/src/controllers/media_controller.ts:
--------------------------------------------------------------------------------
 1 | import { Request, Response } from 'express'
 2 | import { getConfig } from '../services/config'
 3 | import logger from '../services/logger'
 4 | 
 5 | export class MediaController {
 6 |   private baseUrl: string
 7 |   private getConfig: getConfig
 8 | 
 9 |   constructor(baseUrl: string, getConfig: getConfig) {
10 |     this.baseUrl = baseUrl
11 |     this.getConfig = getConfig
12 |   }
13 | 
14 |   public async index(req: Request, res: Response) {
15 |     logger.debug('media index method %s', req.method)
16 |     logger.debug('media index headers %s', JSON.stringify(req.headers))
17 |     logger.debug('media index params %s', JSON.stringify(req.params))
18 |     logger.debug('media index body %s', JSON.stringify(req.body))
19 |     const { phone, media_id: mediaId } = req.params
20 |     const config = await this.getConfig(phone)
21 |     const store = await config.getStore(phone, config)
22 |     const { mediaStore } = store
23 |     const mediaResult = await mediaStore.getMedia(this.baseUrl, mediaId)
24 |     if (mediaResult) {
25 |       logger.debug('media index response %s', JSON.stringify(mediaResult))
26 |       return res.status(200).json(mediaResult)
27 |     } else {
28 |       logger.debug('media index response 404')
29 |       return res.sendStatus(404)
30 |     }
31 |   }
32 | 
33 |   public async download(req: Request, res: Response) {
34 |     logger.debug('media download method %s', req.method)
35 |     logger.debug('media download headers %s', JSON.stringify(req.headers))
36 |     logger.debug('media download params %s', JSON.stringify(req.params))
37 |     logger.debug('media download body %s', JSON.stringify(req.body))
38 |     const { phone, file } = req.params
39 |     const config = await this.getConfig(phone)
40 |     const store = await config.getStore(phone, config)
41 |     const { mediaStore } = store
42 |     return mediaStore.downloadMedia(res, `${phone}/${file}`)
43 |   }
44 | }
45 | 


--------------------------------------------------------------------------------
/src/controllers/messages_controller.ts:
--------------------------------------------------------------------------------
 1 | /*
 2 | curl -X  POST \
 3 |  'https://graph.facebook.com/v13.0/FROM_PHONE_NUMBER_ID/messages' \
 4 |  -H 'Authorization: Bearer ACCESS_TOKEN' \
 5 |  -d '{
 6 |   "messaging_product": "whatsapp",
 7 |   "recipient_type": "individual",
 8 |   "to": "PHONE_NUMBER",
 9 |   "type": "text",
10 |   "text": { // the text object
11 |     "preview_url": false,
12 |     "body": "MESSAGE_CONTENT"
13 |   }
14 | }'
15 | 
16 | {
17 |     "messaging_product": "whatsapp",
18 |     "contacts": [
19 |         {
20 |             "input": "16505076520",
21 |             "wa_id": "16505076520"
22 |         }
23 |     ],
24 |     "messages": [
25 |         {
26 |             "id": "wamid.HBgLMTY1MDUwNzY1MjAVAgARGBI5QTNDQTVCM0Q0Q0Q2RTY3RTcA"
27 |         }
28 |     ]
29 | }
30 | */
31 | // https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#successful-response
32 | // https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#text-messages
33 | // https://developers.facebook.com/docs/whatsapp/cloud-api/guides/mark-message-as-read
34 | 
35 | import { Request, Response } from 'express'
36 | import { Response as ResponseUno } from '../services/response'
37 | import { Incoming } from '../services/incoming'
38 | import { Outgoing } from '../services/outgoing'
39 | import logger from '../services/logger'
40 | 
41 | export class MessagesController {
42 |   protected endpoint = 'messages'
43 |   private incoming: Incoming
44 |   private outgoing: Outgoing
45 | 
46 |   constructor(incoming: Incoming, outgoing: Outgoing) {
47 |     this.incoming = incoming
48 |     this.outgoing = outgoing
49 |   }
50 | 
51 |   public async index(req: Request, res: Response) {
52 |     logger.debug('%s method %s', this.endpoint, req.method)
53 |     logger.debug('%s headers %s', this.endpoint, JSON.stringify(req.headers))
54 |     logger.debug('%s params %s', this.endpoint, JSON.stringify(req.params))
55 |     logger.debug('%s body %s', this.endpoint, JSON.stringify(req.body))
56 |     const { phone } = req.params
57 |     const payload: object = req.body
58 |     try {
59 |       const response: ResponseUno = await this.incoming.send(phone, payload, { endpoint: this.endpoint })
60 |       logger.debug('%s response %s', this.endpoint, JSON.stringify(response.ok))
61 |       await res.status(200).json(response.ok)
62 |       if (response.error) {
63 |         logger.debug('%s return status %s', this.endpoint, JSON.stringify(response.error))
64 |         await this.outgoing.send(phone, response.error)
65 |       }
66 |       // eslint-disable-next-line @typescript-eslint/no-explicit-any
67 |     } catch (e: any) {
68 |       return res.status(400).json({ status: 'error', message: e.message })
69 |     }
70 |   }
71 | }
72 | 


--------------------------------------------------------------------------------
/src/controllers/pairing_code_controller.ts:
--------------------------------------------------------------------------------
 1 | import { Request, Response } from 'express'
 2 | import { getConfig } from '../services/config'
 3 | import logger from '../services/logger'
 4 | import { Incoming } from '../services/incoming'
 5 | 
 6 | export class PairingCodeController {
 7 |   private service: Incoming
 8 |   private getConfig: getConfig
 9 | 
10 |   constructor(getConfig: getConfig, service: Incoming) {
11 |     this.getConfig = getConfig
12 |     this.service = service
13 |   }
14 | 
15 |   public async request(req: Request, res: Response) {
16 |     logger.debug('pairing code post headers %s', JSON.stringify(req.headers))
17 |     logger.debug('pairing code post params %s', JSON.stringify(req.params))
18 |     logger.debug('pairing code post body %s', JSON.stringify(req.body))
19 |     const { phone } = req.params
20 |     const config = await this.getConfig(phone)
21 |     config.connectionType = 'pairing_code'
22 |     try {
23 |       const message = {
24 |         messaging_product: 'whatsapp',
25 |         to: phone,
26 |         type: 'text',
27 |         text: {
28 |           body: 'Request Pairing code'
29 |         } 
30 |       }
31 |       this.service.send(phone, message, {})
32 |       return res.status(200).json({ success: true })
33 |     } catch (e) {
34 |       return res.status(500).json({ status: 'error', message: e.message })
35 |     }
36 |   }
37 | }
38 | 


--------------------------------------------------------------------------------
/src/controllers/phone_number_controller.ts:
--------------------------------------------------------------------------------
 1 | import { Request, Response } from 'express'
 2 | import { Config, getConfig } from '../services/config'
 3 | import { SessionStore } from '../services/session_store'
 4 | import logger from '../services/logger'
 5 | 
 6 | export class PhoneNumberController {
 7 |   private getConfig: getConfig
 8 |   private sessionStore: SessionStore
 9 | 
10 |   constructor(getConfig: getConfig, sessionStore: SessionStore) {
11 |     this.getConfig = getConfig
12 |     this.sessionStore = sessionStore
13 |   }
14 | 
15 |   public async get(req: Request, res: Response) {
16 |     logger.debug('phone number get method %s', req.method)
17 |     logger.debug('phone number get headers %s', JSON.stringify(req.headers))
18 |     logger.debug('phone number get params %s', JSON.stringify(req.params))
19 |     logger.debug('phone number get body %s', JSON.stringify(req.body))
20 |     logger.debug('phone number get query', JSON.stringify(req.query))
21 |     try {
22 |       const { phone } = req.params
23 |       const config = await this.getConfig(phone)
24 |       const store = await config.getStore(phone, config)
25 |       logger.debug('Session store retrieved!')
26 |       const { sessionStore } = store
27 |       const templates = await store.dataStore.loadTemplates()
28 |       logger.debug('Templates retrieved!')
29 |       return res.status(200).json({
30 |         display_phone_number: phone,
31 |         status: await sessionStore.getStatus(phone),
32 |         message_templates: { data: templates },
33 |         ...config,
34 |       })
35 |     } catch (e) {
36 |       return res.status(500).json({ status: 'error', message: e.message })
37 |     }
38 |   }
39 | 
40 |   public async list(req: Request, res: Response) {
41 |     logger.debug('phone number list method %s', req.method)
42 |     logger.debug('phone number list headers %s', JSON.stringify(req.headers))
43 |     logger.debug('phone number list params %s', JSON.stringify(req.params))
44 |     logger.debug('phone number list body %s', JSON.stringify(req.body))
45 |     logger.debug('phone number list query', JSON.stringify(req.query))
46 |     try {
47 |       const phones = await this.sessionStore.getPhones()
48 |       // eslint-disable-next-line @typescript-eslint/no-explicit-any
49 |       const configs: any[] = []
50 |       for (let i = 0, j = phones.length; i < j; i++) {
51 |         const phone = phones[i]
52 |         const config = await this.getConfig(phone)
53 |         const store = await config.getStore(phone, config)
54 |         const { sessionStore } = store
55 |         const status = config.provider == 'forwarder' ? 'forwarder' : await sessionStore.getStatus(phone)
56 |         configs.push({ ...config, display_phone_number: phone, status })
57 |       }
58 |       logger.debug('Configs retrieved!')
59 |       return res.status(200).json({ data: configs })
60 |     } catch (e) {
61 |       return res.status(500).json({ status: 'error', message: e.message })
62 |     }
63 |   }
64 | }
65 | 


--------------------------------------------------------------------------------
/src/controllers/registration_controller.ts:
--------------------------------------------------------------------------------
 1 | import { Request, Response } from 'express'
 2 | import { getConfig } from '../services/config'
 3 | import { setConfig } from '../services/redis'
 4 | import logger from '../services/logger'
 5 | import { Logout } from '../services/logout'
 6 | import { Reload } from '../services/reload'
 7 | 
 8 | export class RegistrationController {
 9 |   private getConfig: getConfig
10 |   private logout: Logout
11 |   private reload: Reload
12 | 
13 |   constructor(getConfig: getConfig, reload: Reload, logout: Logout) {
14 |     this.getConfig = getConfig
15 |     this.reload = reload
16 |     this.logout = logout
17 |   }
18 | 
19 |   public async register(req: Request, res: Response) {
20 |     logger.debug('register method %s', req.method)
21 |     logger.debug('register headers %s', JSON.stringify(req.headers))
22 |     logger.debug('register params %s', JSON.stringify(req.params))
23 |     logger.debug('register body %s', JSON.stringify(req.body))
24 |     logger.debug('register query %s', JSON.stringify(req.query))
25 |     const { phone } = req.params
26 |     try {
27 |       await setConfig(phone, req.body)
28 |       this.reload.run(phone)
29 |       const config = await this.getConfig(phone)
30 |       return res.status(200).json(config)
31 |     } catch (e) {
32 |       return res.status(400).json({ status: 'error', message: `${phone} could not create, error: ${e.message}` })
33 |     }
34 |   }
35 | 
36 |   public async deregister(req: Request, res: Response) {
37 |     logger.debug('deregister method %s', req.method)
38 |     logger.debug('deregister headers %s', JSON.stringify(req.headers))
39 |     logger.debug('deregister params %s', JSON.stringify(req.params))
40 |     logger.debug('deregister body %s', JSON.stringify(req.body))
41 |     logger.debug('deregister query %s', JSON.stringify(req.query))
42 |     const { phone } = req.params
43 |     await this.logout.run(phone)
44 |     return res.status(204).send()
45 |   }
46 | }


--------------------------------------------------------------------------------
/src/controllers/templates_controller.ts:
--------------------------------------------------------------------------------
 1 | // https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/
 2 | 
 3 | import { Request, Response } from 'express'
 4 | import { getConfig } from '../services/config'
 5 | import logger from '../services/logger'
 6 | import fetch, { Response as FetchResponse, RequestInit } from 'node-fetch'
 7 | 
 8 | export class TemplatesController {
 9 |   private getConfig: getConfig
10 | 
11 |   constructor(getConfig: getConfig) {
12 |     this.getConfig = getConfig
13 |   }
14 | 
15 |   public async index(req: Request, res: Response) {
16 |     logger.debug('templates method %s', JSON.stringify(req.method))
17 |     logger.debug('templates headers %s', JSON.stringify(req.headers))
18 |     logger.debug('templates params %s', JSON.stringify(req.params))
19 |     logger.debug('templates body %s', JSON.stringify(req.body))
20 |     logger.debug('templates query %s', JSON.stringify(req.query))
21 | 
22 |     return this.loadTemplates(req, res)
23 |   }
24 | 
25 |   public async templates(req: Request, res: Response) {
26 |     logger.debug('message_templates method %s', JSON.stringify(req.method))
27 |     logger.debug('message_templates headers %s', JSON.stringify(req.headers))
28 |     logger.debug('message_templates params %s', JSON.stringify(req.params))
29 |     logger.debug('message_templates body %s', JSON.stringify(req.body))
30 |     logger.debug('message_templates query %s', JSON.stringify(req.query))
31 | 
32 |     return this.loadTemplates(req, res)
33 |   }
34 | 
35 |   private async loadTemplates(req: Request, res: Response) {
36 |     const { phone } = req.params
37 |     try {
38 |       const config = await this.getConfig(phone)
39 |       if (config.connectionType == 'forward') {
40 |         const url = `${config.webhookForward.url}/${config.webhookForward.version}/${config.webhookForward.businessAccountId}/message_templates?access_token=${config.webhookForward.token}`
41 |         logger.debug('message_templates forward get templates in url %s', url)
42 |         const options: RequestInit = { method: 'GET' }
43 |         if (config.webhookForward?.timeoutMs) {
44 |           options.signal = AbortSignal.timeout(config.webhookForward?.timeoutMs)
45 |         }
46 |         let response: FetchResponse
47 |         try {
48 |           response = await fetch(url, options)
49 |         } catch (error) {
50 |           logger.error(`Error on get templantes to url ${url}`)
51 |           logger.error(error)
52 |           throw error
53 |         }
54 |         res.setHeader('content-type', 'application/json; charset=UTF-8')
55 |         return response.body.pipe(res)
56 |       } else {
57 |         const store = await config.getStore(phone, config)
58 |         const templates = await store.dataStore.loadTemplates()
59 |         return res.status(200).json({ data: templates })
60 |       }
61 | 
62 |     } catch (e) {
63 |       return res.status(400).json({ status: 'error', message: `${phone} could not create template, error: ${e.message}` })
64 |     }
65 |   }
66 | }
67 | 


--------------------------------------------------------------------------------
/src/controllers/webhook_controller.ts:
--------------------------------------------------------------------------------
 1 | import { Request, Response } from 'express'
 2 | import { Outgoing } from '../services/outgoing'
 3 | import logger from '../services/logger'
 4 | import { UNOAPI_AUTH_TOKEN } from '../defaults'
 5 | import { getConfig } from '../services/config'
 6 | 
 7 | export class WebhookController {
 8 |   private service: Outgoing
 9 |   private getConfig: getConfig
10 | 
11 |   constructor(service: Outgoing, getConfig: getConfig) {
12 |     this.service = service
13 |     this.getConfig = getConfig
14 |   }
15 | 
16 |   public async whatsapp(req: Request, res: Response) {
17 |     logger.debug('webhook whatsapp method %s', req.method)
18 |     logger.debug('webhook whatsapp headers %s', JSON.stringify(req.headers))
19 |     logger.debug('webhook whatsapp params %s', JSON.stringify(req.params))
20 |     logger.debug('webhook whatsapp body %s', JSON.stringify(req.body))
21 |     const { phone } = req.params
22 |     await this.service.send(phone, req.body)
23 |     res.status(200).send(`{"success": true}`)
24 |   }
25 | 
26 |   public async whatsappVerify(req: Request, res: Response) {
27 |     logger.debug('webhook whatsapp verify method %s', req.method)
28 |     logger.debug('webhook whatsapp verify headers %s', JSON.stringify(req.headers))
29 |     logger.debug('webhook whatsapp verify params %s', JSON.stringify(req.params))
30 |     logger.debug('webhook whatsapp verify body %s', JSON.stringify(req.body))
31 |     logger.debug('webhook whatsapp verify query %s', JSON.stringify(req.query))
32 |     const { phone } = req.params
33 | 
34 |     const mode = req.query['hub.mode']
35 |     const token = req.query['hub.verify_token']
36 |     const challenge = req.query['hub.challenge']
37 |     const config = (await this.getConfig(phone.replace('+', ''))) || { authToken: UNOAPI_AUTH_TOKEN }
38 |   
39 |     if (mode === 'subscribe' && token === config.authToken) {
40 |       res.status(200).send(challenge)
41 |     } else {
42 |       res.sendStatus(403)
43 |     }
44 |   }
45 | }
46 | 


--------------------------------------------------------------------------------
/src/controllers/webhook_fake_controller.ts:
--------------------------------------------------------------------------------
 1 | import { Request, Response } from 'express'
 2 | import logger from '../services/logger'
 3 | 
 4 | export class WebhookFakeController {
 5 | 
 6 |   public async fake(req: Request, res: Response) {
 7 |     logger.debug('webhook fake method %s', req.method)
 8 |     logger.debug('webhook fake headers %s', JSON.stringify(req.headers))
 9 |     logger.debug('webhook fake params %s', JSON.stringify(req.params))
10 |     logger.debug('webhook fake body %s', JSON.stringify(req.body))
11 |     logger.debug('webhook fake quert %s', JSON.stringify(req.query))
12 |     res.status(200).send(`{"success": true}`)
13 |   }
14 | }
15 | 


--------------------------------------------------------------------------------
/src/i18n.ts:
--------------------------------------------------------------------------------
 1 | import { I18n, TranslateOptions } from 'i18n'
 2 | import path from 'path'
 3 | import { DEFAULT_LOCALE } from './defaults'
 4 | import { AVAILABLE_LOCALES } from './defaults'
 5 | 
 6 | const i18n = new I18n({
 7 |   locales: AVAILABLE_LOCALES,
 8 |   defaultLocale: DEFAULT_LOCALE,
 9 |   directory: path.join(__dirname, 'locales'),
10 |   updateFiles: false,
11 | })
12 | 
13 | i18n.setLocale(DEFAULT_LOCALE)
14 | 
15 | export const t =  (phraseOrOptions: string | TranslateOptions, ...replace: any[]) => {
16 |   const string = i18n.__(phraseOrOptions, ...replace)
17 |   return string
18 | }


--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
 1 | import dotenv from 'dotenv'
 2 | dotenv.config({ path: process.env.DOTENV_CONFIG_PATH || '.env' })
 3 | 
 4 | import { App } from './app'
 5 | import { IncomingBaileys } from './services/incoming_baileys'
 6 | import { Incoming } from './services/incoming'
 7 | import { Outgoing } from './services/outgoing'
 8 | import { OutgoingCloudApi } from './services/outgoing_cloud_api'
 9 | import { SessionStoreFile } from './services/session_store_file'
10 | import { SessionStore } from './services/session_store'
11 | import { autoConnect } from './services/auto_connect'
12 | import { getConfigByEnv } from './services/config_by_env'
13 | import { getClientBaileys } from './services/client_baileys'
14 | import { onNewLoginAlert } from './services/on_new_login_alert'
15 | import { Broadcast } from './services/broadcast'
16 | import { isInBlacklistInMemory, addToBlacklistInMemory } from './services/blacklist'
17 | import { version } from '../package.json'
18 | 
19 | import logger from './services/logger'
20 | import { Listener } from './services/listener'
21 | import { ListenerBaileys } from './services/listener_baileys'
22 | 
23 | import { BASE_URL, PORT } from './defaults'
24 | import { ReloadBaileys } from './services/reload_baileys'
25 | import { LogoutBaileys } from './services/logout_baileys'
26 | 
27 | const outgoingCloudApi: Outgoing = new OutgoingCloudApi(getConfigByEnv, isInBlacklistInMemory)
28 | 
29 | const broadcast: Broadcast = new Broadcast()
30 | const listenerBaileys: Listener = new ListenerBaileys(outgoingCloudApi, broadcast, getConfigByEnv)
31 | const onNewLoginn = onNewLoginAlert(listenerBaileys)
32 | const incomingBaileys: Incoming = new IncomingBaileys(listenerBaileys, getConfigByEnv, getClientBaileys, onNewLoginn)
33 | const sessionStore: SessionStore = new SessionStoreFile()
34 | 
35 | const reload = new ReloadBaileys(getClientBaileys, getConfigByEnv, listenerBaileys, onNewLoginn)
36 | const logout = new LogoutBaileys(getClientBaileys, getConfigByEnv, listenerBaileys, onNewLoginn)
37 | 
38 | const app: App = new App(incomingBaileys, outgoingCloudApi, BASE_URL, getConfigByEnv, sessionStore, onNewLoginn, addToBlacklistInMemory, reload, logout)
39 | broadcast.setSever(app.socket)
40 | 
41 | app.server.listen(PORT, '0.0.0.0', async () => {
42 |   logger.info('Unoapi Cloud version: %s, listening on port: %s', version, PORT)
43 |   autoConnect(sessionStore, listenerBaileys, getConfigByEnv, getClientBaileys, onNewLoginn)
44 | })
45 | 
46 | export default app
47 | 
48 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
49 | process.on('unhandledRejection', (reason: any, promise) => {
50 |   logger.error('unhandledRejection: %s', reason.stack)
51 |   logger.error('promise: %s', promise)
52 |   throw reason
53 | })
54 | 


--------------------------------------------------------------------------------
/src/jobs/add_to_blacklist.ts:
--------------------------------------------------------------------------------
1 | import { addToBlacklistRedis } from '../services/blacklist'
2 | import logger from '../services/logger'
3 | 
4 | export const addToBlacklist = async (_phone: string, data: object) => {
5 |   const { from, webhookId, to, ttl } = data as any
6 |   logger.debug('Add blacklist from: %s, webhook: %s, to: %s, ttl: %s', from, webhookId, to, ttl)
7 |   await addToBlacklistRedis(from, webhookId, to, ttl)
8 | }
9 | export default addToBlacklist


--------------------------------------------------------------------------------
/src/jobs/bind_bridge.ts:
--------------------------------------------------------------------------------
 1 | import { IncomingJob } from './incoming'
 2 | import { ListenerJob } from './listener'
 3 | import { Broadcast } from '../services/broadcast'
 4 | import {
 5 |   UNOAPI_QUEUE_INCOMING,
 6 |   UNOAPI_QUEUE_COMMANDER,
 7 |   UNOAPI_QUEUE_LISTENER,
 8 |   UNOAPI_SERVER_NAME,
 9 |   UNOAPI_EXCHANGE_BRIDGE_NAME,
10 | } from '../defaults'
11 | import { amqpConsume } from '../amqp'
12 | import { getConfig } from '../services/config'
13 | import { getConfigRedis } from '../services/config_redis'
14 | import { getClientBaileys } from '../services/client_baileys'
15 | import { onNewLoginGenerateToken } from '../services/on_new_login_generate_token'
16 | import { Outgoing } from '../services/outgoing'
17 | import logger from '../services/logger'
18 | import { Listener } from '../services/listener'
19 | import { ListenerBaileys } from '../services/listener_baileys'
20 | import { OutgoingAmqp } from '../services/outgoing_amqp'
21 | import { BroadcastAmqp } from '../services/broadcast_amqp'
22 | import { isInBlacklistInRedis } from '../services/blacklist'
23 | import { ListenerAmqp } from '../services/listener_amqp'
24 | import { OutgoingCloudApi } from '../services/outgoing_cloud_api'
25 | import { IncomingBaileys } from '../services/incoming_baileys'
26 | 
27 | const getConfig: getConfig = getConfigRedis
28 | const outgoingAmqp: Outgoing = new OutgoingAmqp(getConfig)
29 | const listenerAmqp: Listener = new ListenerAmqp()
30 | const broadcastAmqp: Broadcast = new BroadcastAmqp()
31 | const listenerBaileys: Listener = new ListenerBaileys(outgoingAmqp, broadcastAmqp, getConfig)
32 | const outgoingCloudApi: Outgoing = new OutgoingCloudApi(getConfig, isInBlacklistInRedis)
33 | const onNewLogin = onNewLoginGenerateToken(outgoingCloudApi)
34 | const incomingBaileys = new IncomingBaileys(listenerAmqp, getConfig, getClientBaileys, onNewLogin)
35 | const incomingJob = new IncomingJob(incomingBaileys, outgoingAmqp, getConfig, UNOAPI_QUEUE_COMMANDER)
36 | const listenerJob = new ListenerJob(listenerBaileys, outgoingCloudApi, getConfig)
37 | 
38 | const processeds = new Map()
39 | 
40 | export class BindBridgeJob {
41 |   async consume(server: string, { routingKey }: { routingKey: string }) {
42 |     const config = await getConfig(routingKey)
43 |     if (config.provider && !['forwarder', 'baileys'].includes(config.provider!)) {
44 |       logger.info(`Ignore connecting routingKey ${routingKey} provider ${config.provider}...`)
45 |       return
46 |     }
47 |     if (config.server !== UNOAPI_SERVER_NAME) {
48 |       logger.info(`Ignore bing brigde ${routingKey} server ${config.server} is not server current server ${UNOAPI_SERVER_NAME}...`)
49 |       return
50 |     }
51 |     const store = await config.getStore(routingKey, config)
52 |     const { sessionStore } = store
53 |     if (!(await sessionStore.isStatusOnline(routingKey)) && processeds.get(routingKey)) {
54 |       return
55 |     }
56 |     processeds.set(routingKey, true)
57 |     logger.info('Binding queues consumer bridge server %s routingKey %s', server, routingKey)
58 | 
59 |     const notifyFailedMessages = config.notifyFailedMessages
60 | 
61 |     logger.info('Starting listener baileys consumer %s', routingKey)
62 |     await amqpConsume(
63 |       UNOAPI_EXCHANGE_BRIDGE_NAME,
64 |       `${UNOAPI_QUEUE_LISTENER}.${UNOAPI_SERVER_NAME}`, 
65 |       routingKey,
66 |       listenerJob.consume.bind(listenerJob),
67 |       {
68 |         notifyFailedMessages,
69 |         priority: 5,
70 |         prefetch: 1,
71 |         type: 'direct'
72 |       }
73 |     )
74 | 
75 |     logger.info('Starting incoming consumer %s', routingKey)
76 |     await amqpConsume(
77 |       UNOAPI_EXCHANGE_BRIDGE_NAME,
78 |       `${UNOAPI_QUEUE_INCOMING}.${UNOAPI_SERVER_NAME}`, 
79 |       routingKey, 
80 |       incomingJob.consume.bind(incomingJob), 
81 |       {
82 |         notifyFailedMessages,
83 |         priority: 5,
84 |         prefetch: 1 /* allways 1 */,
85 |         type: 'direct'
86 |       }
87 |     )
88 |   }
89 | }
90 | 


--------------------------------------------------------------------------------
/src/jobs/broadcast.ts:
--------------------------------------------------------------------------------
 1 | import { Broadcast } from '../services/broadcast'
 2 | 
 3 | export class BroacastJob {
 4 |   private broadcast: Broadcast
 5 | 
 6 |   constructor(broadcast: Broadcast) {
 7 |     this.broadcast = broadcast
 8 |   }
 9 | 
10 |   async consume(_: string, { phone, type, content }) {
11 |     return this.broadcast.send(phone, type, content)
12 |   }
13 | }
14 | 


--------------------------------------------------------------------------------
/src/jobs/bulk_report.ts:
--------------------------------------------------------------------------------
 1 | import { UNOAPI_QUEUE_BULK_REPORT, UNOAPI_BULK_DELAY, UNOAPI_EXCHANGE_BROKER_NAME } from '../defaults'
 2 | import { Outgoing } from '../services/outgoing'
 3 | import { getBulkReport } from '../services/redis'
 4 | import { amqpPublish } from '../amqp'
 5 | import { v1 as uuid } from 'uuid'
 6 | import { getConfig } from '../services/config'
 7 | 
 8 | export class BulkReportJob {
 9 |   private outgoing: Outgoing
10 |   private getConfig: getConfig
11 | 
12 |   constructor(outgoing: Outgoing, getConfig: getConfig) {
13 |     this.outgoing = outgoing
14 |     this.getConfig = getConfig
15 |   }
16 | 
17 |   async consume(phone: string, data: object) {
18 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
19 |     const { payload } = data as any
20 |     const { id, length } = payload
21 |     const count = payload.count ? payload.count + 1 : 1
22 |     const bulk = await getBulkReport(phone, id)
23 |     if (bulk) {
24 |       const { report, status } = bulk
25 |       let typeMessage
26 |       let message
27 |       if (!payload.unverified && status.scheduled) {
28 |         typeMessage = 'text'
29 |         if (count >= 10) {
30 |           message = { body: `Bulk ${id} phone ${phone} with ${length}, has retried generate ${count} and not retried more` }
31 |         } else {
32 |           message = { body: `Bulk ${id} phone ${phone} with ${length}, some messages is already scheduled status, try again later, this is ${count} try...` }
33 |           await amqpPublish(
34 |             UNOAPI_EXCHANGE_BROKER_NAME,
35 |             UNOAPI_QUEUE_BULK_REPORT,
36 |             phone,
37 |             { payload: { id, length, count } },
38 |             { delay: UNOAPI_BULK_DELAY * 1000, type: 'topic' }
39 |           )
40 |         }
41 |       } else {
42 |         const caption = `Bulk ${id} phone ${phone} with ${length} message(s) status -> ${JSON.stringify(status)}`
43 |         const lines: string[] = Object.keys(report).map((key) => `${key};${report[key]}`)
44 |         const file = lines.join('\n')
45 |         const buffer = Buffer.from(file)
46 |         const base64 = `data:text/csv;base64,${buffer.toString('base64')}`
47 |         typeMessage = 'document'
48 |         const mediaId = uuid().replaceAll('-', '')
49 |         const mediaKey = `${phone}/${mediaId}`
50 |         const filename = `${mediaKey}.csv`
51 |         const config = await this.getConfig(phone)
52 |         const store = await config.getStore(phone, config)
53 |         await store.mediaStore.saveMediaBuffer(filename, buffer)
54 |         message = {
55 |           url: base64,
56 |           mime_type: 'text/csv',
57 |           filename, 
58 |           caption,
59 |           id: mediaKey,
60 |         }
61 |       }
62 |       const update = {
63 |         type: typeMessage,
64 |         from: phone,
65 |         [typeMessage]: message,
66 |       }
67 |       this.outgoing.formatAndSend(phone, phone, update)
68 |     } else {
69 |       const message = {
70 |         type: 'text',
71 |         text: {
72 |           body: `Bulk id ${id} not found`,
73 |         },
74 |       }
75 |       this.outgoing.formatAndSend(phone, phone, message)
76 |     }
77 |   }
78 | }
79 | 


--------------------------------------------------------------------------------
/src/jobs/bulk_sender.ts:
--------------------------------------------------------------------------------
 1 | import { amqpPublish } from '../amqp'
 2 | import { UNOAPI_BULK_BATCH, UNOAPI_BULK_DELAY, UNOAPI_QUEUE_BULK_SENDER, UNOAPI_QUEUE_BULK_REPORT, UNOAPI_BULK_MESSAGE_DELAY, UNOAPI_EXCHANGE_BROKER_NAME } from '../defaults'
 3 | import { Incoming } from '../services/incoming'
 4 | import { Outgoing } from '../services/outgoing'
 5 | import { setMessageStatus, setbulkMessage } from '../services/redis'
 6 | import logger from '../services/logger'
 7 | 
 8 | export class BulkSenderJob {
 9 |   private outgoing: Outgoing
10 |   private incoming: Incoming
11 | 
12 |   constructor(incoming: Incoming, outgoing: Outgoing) {
13 |     this.incoming = incoming
14 |     this.outgoing = outgoing
15 |   }
16 | 
17 |   async consume(phone: string, data: object) {
18 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
19 |     const { payload } = data as any
20 |     const { id, messages, length } = payload
21 |     try {
22 |       const batch = Math.floor(Math.random() * UNOAPI_BULK_BATCH) + 1
23 |       const messagesToSend = messages.slice(0, batch)
24 |       // eslint-disable-next-line @typescript-eslint/no-explicit-any
25 |       let statusMessage: any = `Bulk ${id} enqueuing phone ${phone} with ${messagesToSend.length} message(s)...`
26 |       logger.debug(statusMessage)
27 |       let count = 0
28 |       const message = {
29 |         from: phone,
30 |         type: 'text',
31 |         text: {
32 |           body: statusMessage,
33 |         },
34 |       }
35 |       this.outgoing.formatAndSend(phone, phone, message)
36 |       let delay = 0
37 |       let totalDelay = 0
38 |       // eslint-disable-next-line @typescript-eslint/no-explicit-any
39 |       const promises = messagesToSend.map(async (m: any) => {
40 |         delay = Math.floor(Math.random() * count++) * (UNOAPI_BULK_MESSAGE_DELAY * 1000)
41 |         totalDelay = totalDelay + delay
42 |         const options = { delay, composing: true, priority: 1, sendMessageToWebhooks: true } // low priority, send where not has agent message is queue
43 |         const response = await this.incoming.send(phone, m.payload, options)
44 |         const messageId = response.ok.messages[0].id
45 |         await setbulkMessage(phone, id, messageId, m.payload.to)
46 |         await setMessageStatus(phone, messageId, 'scheduled')
47 |         return response
48 |       })
49 |       await Promise.all(promises)
50 |       const delayToResend = totalDelay + UNOAPI_BULK_DELAY * 1000
51 |       if (messages.length > batch) {
52 |         const messagesToRenqueue = messages.slice(batch)
53 |         await amqpPublish(
54 |           UNOAPI_EXCHANGE_BROKER_NAME,
55 |           UNOAPI_QUEUE_BULK_SENDER,
56 |           phone,
57 |           {
58 |             payload: { phone, messages: messagesToRenqueue, id, length },
59 |           },
60 |           { delay: delayToResend, type: 'topic' },
61 |         )
62 |         statusMessage = `Bulk ${id} phone ${phone} reenqueuing ${messagesToRenqueue.length} message(s) with delay ${delayToResend}...`
63 |       } else {
64 |         statusMessage = `Bulk ${id} phone ${phone} is finished with ${messagesToSend.length} message(s)!`
65 |         await amqpPublish(
66 |           UNOAPI_EXCHANGE_BROKER_NAME,
67 |           UNOAPI_QUEUE_BULK_REPORT, phone, 
68 |           { payload: { id, length } }, 
69 |           { delay: UNOAPI_BULK_DELAY * 1000, type: 'topic' }
70 |         )
71 |       }
72 |       const messageUpdate = {
73 |         type: 'text',
74 |         from: phone,
75 |         text: {
76 |           body: statusMessage,
77 |         },
78 |       }
79 |       await this.outgoing.formatAndSend(phone, phone, messageUpdate)
80 |     } catch (error) {
81 |       const text = `Error on send bulk ${phone}: ${JSON.stringify(error)}`
82 |       logger.error(error, 'Error on send bulk')
83 |       const messageError = {
84 |         type: 'text',
85 |         from: phone,
86 |         text: {
87 |           body: text,
88 |         },
89 |       }
90 |       await this.outgoing.formatAndSend(phone, phone, messageError)
91 |       throw error
92 |     }
93 |   }
94 | }
95 | 


--------------------------------------------------------------------------------
/src/jobs/bulk_status.ts:
--------------------------------------------------------------------------------
 1 | import { setMessageStatus } from '../services/redis'
 2 | import logger from '../services/logger'
 3 | 
 4 | export class BulkStatusJob {
 5 |   async consume(phone: string, data: object) {
 6 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 7 |     const { payload } = data as any
 8 |     const state = payload.entry[0].changes[0].value.statuses[0]
 9 |     logger.debug(`State: ${JSON.stringify(state)}`)
10 |     const messageId = state.id
11 |     const status = state.status
12 |     if (status == 'failed') {
13 |       const error = state.errors[0]
14 |       if (['2', 2].includes(error.code)) {
15 |         return setMessageStatus(phone, messageId, 'without-whatsapp')
16 |       } else if (['7', 7].includes(error.code)) {
17 |         return setMessageStatus(phone, messageId, 'invalid-phone-number')
18 |       }
19 |     }
20 |   }
21 | }
22 | 


--------------------------------------------------------------------------------
/src/jobs/bulk_webhook.ts:
--------------------------------------------------------------------------------
 1 | import { Outgoing } from '../services/outgoing'
 2 | import { getKey } from '../services/redis'
 3 | 
 4 | export class BulkWebhookJob {
 5 |   private outgoing: Outgoing
 6 | 
 7 |   constructor(outgoing: Outgoing) {
 8 |     this.outgoing = outgoing
 9 |   }
10 | 
11 |   async consume(phone: string, data: object) {
12 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
13 |     const { payload } = data as any
14 |     const messageId = payload?.entry[0]?.changes[0]?.value?.messages[0]?.id
15 |     const key = await getKey(phone, messageId)
16 |     if (key) {
17 |       return this.outgoing.send(phone, payload)
18 |     }
19 |     throw `key id not found ${messageId}`
20 |   }
21 | }
22 | 


--------------------------------------------------------------------------------
/src/jobs/listener.ts:
--------------------------------------------------------------------------------
 1 | import { amqpPublish } from '../amqp'
 2 | import { UNOAPI_EXCHANGE_BRIDGE_NAME, UNOAPI_QUEUE_LISTENER, UNOAPI_SERVER_NAME } from '../defaults'
 3 | import { Listener } from '../services/listener'
 4 | import logger from '../services/logger'
 5 | import { Outgoing } from '../services/outgoing'
 6 | import { DecryptError } from '../services/transformer'
 7 | import { getConfig } from '../services/config'
 8 | 
 9 | export class ListenerJob {
10 |   private listener: Listener
11 |   private outgoing: Outgoing
12 |   private getConfig: getConfig
13 | 
14 |   constructor(listener: Listener, outgoing: Outgoing, getConfig: getConfig) {
15 |     this.listener = listener
16 |     this.outgoing = outgoing
17 |     this.getConfig = getConfig
18 |   }
19 | 
20 |   async consume(phone: string, data: object, options?: { countRetries: number; maxRetries: number, priority: 0 }) {
21 |     const config = await this.getConfig(phone)
22 |     if (config.server !== UNOAPI_SERVER_NAME) {
23 |       logger.info(`Ignore listener routing key ${phone} server ${config.server} is not server current server ${UNOAPI_SERVER_NAME}...`)
24 |       return;
25 |     }
26 |     if (config.provider !== 'baileys') {
27 |       logger.info(`Ignore listener routing key ${phone} is not provider baileys...`)
28 |       return;
29 |     }
30 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
31 |     const a = data as any
32 |     const { messages, type } = a
33 |     if (a.splited) {
34 |       try {
35 |         await this.listener.process(phone, messages, type)
36 |       } catch (error) {
37 |         if (error instanceof DecryptError && options && options?.countRetries >= options?.maxRetries) {
38 |           // send message asking to open whatsapp to see
39 |           await this.outgoing.send(phone, error.getContent())
40 |         } else {
41 |           throw error
42 |         }
43 |       }
44 |     } else {
45 |       if (type == 'delete' && messages.keys) {
46 |         await Promise.all(
47 |           messages.keys.map(async (m: object) => {
48 |             return amqpPublish(
49 |               UNOAPI_EXCHANGE_BRIDGE_NAME,
50 |               `${UNOAPI_QUEUE_LISTENER}.${UNOAPI_SERVER_NAME}`,
51 |               phone,
52 |               { messages: { keys: [m] }, type, splited: true },
53 |               { type: 'direct' }
54 |             )
55 |          })
56 |         )
57 |       } else {
58 |         await Promise.all(messages.
59 |           map(async (m: object) => {
60 |             return amqpPublish(
61 |               UNOAPI_EXCHANGE_BRIDGE_NAME,
62 |               `${UNOAPI_QUEUE_LISTENER}.${UNOAPI_SERVER_NAME}`,
63 |               phone,
64 |               { messages: [m], type, splited: true },
65 |               { type: 'direct' }
66 |             )
67 |           })
68 |         )
69 |       }
70 |     }
71 |   }
72 | }
73 | 


--------------------------------------------------------------------------------
/src/jobs/logout.ts:
--------------------------------------------------------------------------------
 1 | import logger from '../services/logger'
 2 | import { Logout } from '../services/logout'
 3 | 
 4 | export class LogoutJob {
 5 |   private logout: Logout
 6 | 
 7 |   constructor(logout: Logout) {
 8 |     this.logout = logout
 9 |   }
10 | 
11 |   async consume(_: string, { phone }: { phone: string }) {
12 |     logger.debug('Logout service for phone %s', phone)
13 |     this.logout.run(phone)
14 |   }
15 | }
16 | 


--------------------------------------------------------------------------------
/src/jobs/media.ts:
--------------------------------------------------------------------------------
 1 | import { getConfig } from '../services/config'
 2 | import logger from '../services/logger'
 3 | 
 4 | export class MediaJob {
 5 |   private getConfig: getConfig
 6 | 
 7 |   constructor(getConfig: getConfig) {
 8 |     this.getConfig = getConfig
 9 |   }
10 | 
11 |   async consume(phone: string, data: object) {
12 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
13 |     const a = data as any
14 |     const fileName: string = a.fileName
15 |     const config = await this.getConfig(phone)
16 |     const { mediaStore } = await config.getStore(phone, config)
17 |     logger.debug('Removing file %s...', fileName)
18 |     await mediaStore.removeMedia(fileName)
19 |     logger.debug('Remove file %s!', fileName)
20 |   }
21 | }
22 | 


--------------------------------------------------------------------------------
/src/jobs/notification.ts:
--------------------------------------------------------------------------------
 1 | import { Incoming } from '../services/incoming'
 2 | 
 3 | export class NotificationJob {
 4 |   private incoming: Incoming
 5 | 
 6 |   constructor(incoming: Incoming) {
 7 |     this.incoming = incoming
 8 |   }
 9 | 
10 |   async consume(phone: string, data: object) {
11 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
12 |     const a = data as any
13 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
14 |     const payload: any = a.payload
15 |     const options: object = a.options
16 |     this.incoming.send(phone, payload, options)
17 |   }
18 | }
19 | 


--------------------------------------------------------------------------------
/src/jobs/reload.ts:
--------------------------------------------------------------------------------
 1 | import logger from '../services/logger';
 2 | import { Reload } from '../services/reload'
 3 | 
 4 | export class ReloadJob {
 5 |   private reload: Reload
 6 | 
 7 |   constructor(reload: Reload) {
 8 |     this.reload = reload
 9 |   }
10 | 
11 |   async consume(_: string, { phone }: { phone: string }) {
12 |     logger.debug('Reload job run for phone %s', phone)
13 |     await this.reload.run(phone)
14 |   }
15 | }
16 | 


--------------------------------------------------------------------------------
/src/locales/en.json:
--------------------------------------------------------------------------------
 1 | {
 2 | 	"without_whatsapp": "The phone number %s does not have Whatsapp account!",
 3 | 	"invalid_phone_number": "The phone number %s is invalid!",
 4 | 	"offline_session": "offline session, connecting....",
 5 | 	"disconnected_session": "disconnect number, please send a message do try reconnect and read qr code if necessary",
 6 |   "reloaded_session": "Session reloaded, send a message to connect again",
 7 | 	"connecting_session": "Wait a moment, connecting process",
 8 |   "invalid_link": "Error on retrieve media, http status %s in link %s",
 9 | 	"attempts_exceeded": "The %s times of generate qrcode is exceeded!",
10 | 	"received_pending_notifications": "Received pending notifications",
11 | 	"online_session": "Online session",
12 | 	"connection_timed_out": "Connecting %s timed out %s ms, change to disconnect",
13 | 	"connecting": "Connecting...",
14 | 	"connected": "Connected with %s using Whatsapp Version v%s, latest Baileys version is v%s at %s",
15 | 	"removed": "The session is removed in Whatsapp App, send a message here to reconnect!",
16 | 	"unique": "The session must be unique, close connection, send a message here to reconnect if him was offline!",
17 | 	"closed": "The connection is closed with status: %s, detail: %s!",
18 | 	"connecting_attemps": "Try connnecting time %s of %s...",
19 | 	"qrcode_attemps": "Please, read the QR Code to connect on Whatsapp Web, attempt %s of %s",
20 | 	"auto_restart": "Config to auto restart in %s milliseconds.",
21 | 	"failed_decrypt": "🕒 The message could not be read. Please ask to send it again or open WhatsApp on your phone.",
22 | 	"error": "Error -> %s.",
23 | 	"on_read_qrcode": "Awesome, read the qrcode if you not yet. For now you need to update config to use this auth token %s",
24 | 	"pairing_code": "Open your WhatsApp and go to: Connected Devices > Connect a new Device > Connect using phone number > And put your connection code > %s",
25 |   "restart": "Restarting session",
26 |   "standby": "Standby session, waiting for time configured to try connect again, %s error in %s seconds",
27 |   "proxy_error": "Error on connect to proxy: %s",
28 |   "session_conflict": "The session number is %s but the configured number %s",
29 |   "waiting_information": "Waiting for qrcode/pairing code",
30 |   "reload": "Reload"
31 | }


--------------------------------------------------------------------------------
/src/locales/pt.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "without_whatsapp": "O número de telefone %s não tem whatsapp!",
 3 |   "invalid_phone_number": "O número de telefone %s está inválido!",
 4 |   "offline_session": "Sessão desligada, a conectar...",
 5 |   "disconnected_session": "A sessão foi desligada, por favor envia uma mensagem e leia o QR Code se for necessário.",
 6 |   "reloaded_session": "Sessão terminada, envie uma mensagem para conectar novamente",
 7 |   "connecting_session": "Aguarde um momento, a estabelecer a sessão...",
 8 |   "invalid_link": "Houve um erro ao carregar o conteúdo, estado %s, link %s",
 9 | 	"attempts_exceeded": "O limite de %s vezes de geração de QR Code foi excedido!",
10 | 	"received_pending_notifications": "A sincronizar mensagens enviadas enquanto estava offline",
11 | 	"online_session": "Sessão estabelecida",
12 | 	"connection_timed_out": "A sessão %s excedeu o tempo de %s ms, sessão alterada para estado Offline",
13 | 	"connecting": "A estabelecer...",
14 | 	"connected": "Sessão do número %s estabelecida, versão do WhatsApp v%s, a última versão do WhatsApp v%s, às %s",
15 | 	"removed": "A sessão foi removida via aplicação WhatsApp, envie uma nova mensagem para gerar novo QR Code e voltar a estabelecer a ligação!",
16 | 	"unique": "A sessão só pode ser estabelecida uma vez, a atual sessão está a ser desligada, envie uma mensagem para voltar a estabelecer a ligação!",
17 | 	"closed": "A sessão foi encerrada com o estado: %s, detalhe: %s!",
18 | 	"connecting_attemps": "Tentativa de estabelecimento de sessão %s de %s...",
19 | 	"qrcode_attemps": "Por favor, leia QR Code para estabelecer a ligação, tentativa %s de %s",
20 | 	"auto_restart": "Configurado para reiniciar a sessão a cada %s milliseconds.",
21 | 	"failed_decrypt": "🕒 Não foi possível apresentar a mensagem. Peça para enviar novamente a mensagem ou abra a APP do Whatsapp para ver o conteúdo.",
22 | 	"error": "Erro não previsto: %s.",
23 | 	"on_read_qrcode": "Fantástico, se ainda não o fez, leia o QR Code. O token de authenticação para esta sessão é %s",
24 | 	"pairing_code": "Informe o código para conectar no whatsapp > %s",
25 |   "restart": "Reiniciando sessão",
26 |   "standby": "Sessão colocada em standby, esperando pelo tempo configurado para tentar conectar novamente: %s",
27 |   "proxy_error": "Erro ao conectar no proxy: %s",
28 |   "session_conflict": "O número the sessão usado é o %s mas o número da configuração é %s",
29 |   "waiting_information": "Esperando por qrcode/pairing code",
30 |   "reload": "Recarregar"
31 | }


--------------------------------------------------------------------------------
/src/locales/pt_BR.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "without_whatsapp": "O número de telefone %s não tem whatsapp!",
 3 |   "invalid_phone_number": "O número telefone %s esta inválido!",
 4 |   "offline_session": "Sessão desligada, tentando conectar...",
 5 |   "disconnected_session": "Sessão desconectada, por favor envie um mensagem e leia o qrcode se for necessário",
 6 |   "reloaded_session": "Sessão terminada, envie uma mensagem para conectar novamente",
 7 |   "connecting_session": "Espere um momento, conectando a sessão...",
 8 |   "invalid_link": "Houve um erro ao recuperar a midia, status %s para o link %s",
 9 | 	"attempts_exceeded": "O limite de %s vezes de geração de qrcode foi excedida!",
10 | 	"received_pending_notifications": "Recebendo mensagens enviadas enquanto estava offline",
11 | 	"online_session": "Sessão está online",
12 | 	"connection_timed_out": "A sessão %s excedeu o tempo de %s ms para conectar, sessão alterada para estado Offline",
13 | 	"connecting": "Conectando...",
14 | 	"connected": "Conectado com o número %s utilizando a versao do Whatsapp v%s, e a útima versão seria v%s, hora atual %s",
15 | 	"removed": "A sessão foi removida no aplicativo do Whatsapp, envie uma mesagem para gerar o qrcode e conectar novamente!",
16 | 	"unique": "A sessão só pode ser conectado uma vez, saindo da atual, envia uma mensagem para conectar novamente!",
17 | 	"closed": "A sessão for encerrada com status: %s, detalhe: %s!",
18 | 	"connecting_attemps": "Tentativa de conexão %s de %s...",
19 | 	"qrcode_attemps": "Por favor, leia QR Code para conectar, tentativa %s de %s",
20 | 	"auto_restart": "Configura para reiniciar a sessão a cada %s milliseconds.",
21 | 	"failed_decrypt": "🕒 Não foi possível ler a mensagem. Peça para enviar novamente ou abra o Whatsapp no celular.",
22 | 	"error": "Erro não tratado: %s.",
23 | 	"on_read_qrcode": "Maravilha, leia o qrcode, se ainda não leu. O token de authenticação para essa sessão é %s",
24 | 	"pairing_code": "Informe o código para conectar no whatsapp: %s",
25 |   "restart": "Reiniciando sessão",
26 |   "standby": "Sessão colocada em standby, esperando pelo tempo configurado para tentar conectar novamente: %s",
27 |   "proxy_error": "Erro ao conectar no proxy: %s",
28 |   "session_conflict": "O número the sessão usado é o %s mas o número da configuração é %s",
29 |   "waiting_information": "Esperando por qrcode/pairing code",
30 |   "reload": "Recarregar"
31 | }


--------------------------------------------------------------------------------
/src/qrcode.d.ts:
--------------------------------------------------------------------------------
1 | type HTMLCanvasElement = never
2 | 


--------------------------------------------------------------------------------
/src/services/auth_state.ts:
--------------------------------------------------------------------------------
 1 | import { initAuthCreds, proto, AuthenticationState, AuthenticationCreds, makeCacheableSignalKeyStore } from 'baileys'
 2 | import { session } from './session'
 3 | import logger from './logger'
 4 | 
 5 | export const authState = async (session: session, phone: string) => {
 6 |   const { readData, writeData, removeData, getKey } = await session(phone)
 7 | 
 8 |   const creds: AuthenticationCreds = ((await readData('')) || initAuthCreds()) as AuthenticationCreds
 9 | 
10 |   const keys = {
11 |     get: async (type: string, ids: string[]) => {
12 |       // eslint-disable-next-line @typescript-eslint/no-explicit-any
13 |       const data: any = {}
14 |       await Promise.all(
15 |         ids.map(async (id) => {
16 |           const key = getKey(type, id)
17 |           const value = await readData(key)
18 |           if (type === 'app-state-sync-key' && value) {
19 |             data[id] = proto.Message.AppStateSyncKeyData.fromObject(value)
20 |           } else {
21 |             data[id] = value
22 |           }
23 |         }),
24 |       )
25 | 
26 |       return data
27 |     },
28 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
29 |     set: async (data: any) => {
30 |       const tasks: Promise[] = []
31 |       for (const category in data) {
32 |         for (const id in data[category]) {
33 |           const value = data[category][id]
34 |           const key = getKey(category, id)
35 |           tasks.push(value ? writeData(key, value) : removeData(key))
36 |         }
37 |       }
38 |       await Promise.all(tasks)
39 |     },
40 |   }
41 | 
42 |   const state: AuthenticationState = {
43 |     creds,
44 |     keys: makeCacheableSignalKeyStore(keys, logger),
45 |   }
46 | 
47 |   const saveCreds: () => Promise = async () => {
48 |     logger.debug('save creds %s', phone)
49 |     return writeData('', creds)
50 |   }
51 | 
52 |   return {
53 |     state,
54 |     saveCreds,
55 |   }
56 | }
57 | 


--------------------------------------------------------------------------------
/src/services/auto_connect.ts:
--------------------------------------------------------------------------------
 1 | import { getClient, ConnectionInProgress } from './client'
 2 | import { getConfig } from './config'
 3 | import { SessionStore } from './session_store'
 4 | import { Listener } from './listener'
 5 | import { OnNewLogin } from './socket'
 6 | import logger from './logger'
 7 | import { UNOAPI_SERVER_NAME } from '../defaults'
 8 | 
 9 | export const autoConnect = async (
10 |   sessionStore: SessionStore,
11 |   listener: Listener,
12 |   getConfig: getConfig,
13 |   getClient: getClient,
14 |   onNewLogin: OnNewLogin,
15 | ) => {
16 |   try {
17 |     const phones = await sessionStore.getPhones()
18 |     logger.info(`${phones.length} phones to verify if is auto connect`)
19 |     for (let i = 0, j = phones.length; i < j; i++) {
20 |       const phone = phones[i]
21 |       try {
22 |         const config = await getConfig(phone)
23 |         if (config.provider && !['forwarder', 'baileys'].includes(config.provider)) {
24 |           logger.info(`Ignore connecting phone ${phone} provider ${config.provider}...`)
25 |           continue;
26 |         }
27 |         if (config.server !== UNOAPI_SERVER_NAME) {
28 |           logger.info(`Ignore connecting phone ${phone} server ${config.server} is not server current server ${UNOAPI_SERVER_NAME}...`)
29 |           continue;
30 |         }
31 |         await sessionStore.syncConnection(phone)
32 |         if (await sessionStore.isStatusStandBy(phone)) {
33 |           logger.info(`Session standby ${phone}...`)
34 |           continue;
35 |         }
36 |         logger.info(`Auto connecting phone ${phone}...`)
37 |         try {
38 |           const store = await config.getStore(phone, config)
39 |           const { sessionStore } = store
40 |           if (await sessionStore.isStatusConnecting(phone) || await sessionStore.isStatusOnline(phone)) {
41 |             logger.info(`Update session status to auto connect ${phone}...`)
42 |             await sessionStore.setStatus(phone, 'offline')
43 |           }
44 |           getClient({ phone, listener, getConfig, onNewLogin })
45 |           // eslint-disable-next-line @typescript-eslint/no-explicit-any
46 |         } catch (e: any) {
47 |           if (e instanceof ConnectionInProgress) {
48 |             logger.info(`Connection already in progress ${phone}...`)
49 |           } else {
50 |             throw e
51 |           }
52 |         }
53 |         logger.info(`Auto connected phone ${phone}!`)
54 |       } catch (error) {
55 |         logger.error(error, `Error on connect phone ${phone}`)
56 |       }
57 |     }
58 |   } catch (error) {
59 |     logger.error(error, 'Erro on auto connect')
60 |     throw error
61 |   }
62 | }
63 | 


--------------------------------------------------------------------------------
/src/services/blacklist.ts:
--------------------------------------------------------------------------------
 1 | import NodeCache from 'node-cache'
 2 | import { amqpPublish } from '../amqp'
 3 | import { UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BLACKLIST_ADD } from '../defaults'
 4 | import { blacklist, redisTtl, redisKeys, setBlacklist } from './redis'
 5 | import logger from './logger'
 6 | import { extractDestinyPhone } from './transformer'
 7 | 
 8 | const DATA = new NodeCache()
 9 | let searchData = true
10 | 
11 | export interface addToBlacklist {
12 |   (from: string, webhookId: string, to: string, ttl: number): Promise
13 | }
14 | 
15 | export interface isInBlacklist {
16 |   (from: string, webhookId: string, payload: object): Promise
17 | }
18 | 
19 | export const blacklistInMemory = (from: string, webhookId: string, to: string) => {
20 |   return `${from}:${webhookId}:${to}`
21 | }
22 | 
23 | export const isInBlacklistInMemory: isInBlacklist = async (from: string, webhookId: string, payload: object) => {
24 |   const to = extractDestinyPhone(payload)
25 |   const key = blacklistInMemory(from, webhookId, to)
26 |   const cache: string | undefined = DATA.get(key)
27 |   logger.debug('Retrieve destiny phone %s and verify key %s is %s in cache', to, key, cache ? 'present' : 'not present')
28 |   return cache || ''
29 | }
30 | 
31 | export const addToBlacklistInMemory: addToBlacklist = async (from: string, webhookId: string, to: string, ttl: number) => {
32 |   const key = blacklistInMemory(from, webhookId, to)
33 |   logger.debug('Add %s to blacklist with ttl %s', key, ttl)
34 |   if (ttl > 0) {
35 |     return DATA.set(key, to, ttl)
36 |   } else if (ttl == 0) {
37 |     DATA.del(key)
38 |     return true
39 |   } else {
40 |     return DATA.set(key, to)
41 |   }
42 | }
43 | 
44 | export const cleanBlackList = async () => {
45 |   DATA.flushAll()
46 |   searchData = true
47 | }
48 | 
49 | export const isInBlacklistInRedis: isInBlacklist = async (from: string, webhookId: string, payload: object) => {
50 |   if (DATA.getStats().keys <= 0 && searchData) {
51 |     searchData = false
52 |     const pattern = `${blacklist('', '', '').replaceAll('::', '')}*`
53 |     const keys = await redisKeys(pattern)
54 |     logger.info(`Load ${keys.length} items in blacklist`)
55 |     const promises = keys.map(async key => {
56 |       const ttl = await redisTtl(key)
57 |       const [ _k, from, webhookId, to ] = key.split(':')
58 |       return addToBlacklistInMemory(from, webhookId, to, ttl)
59 |     })
60 |     await Promise.all(promises)
61 |   }
62 |   return isInBlacklistInMemory(from, webhookId, payload)
63 | }
64 | 
65 | export const addToBlacklistRedis: addToBlacklist = async (from: string, webhookId: string, to: string, ttl: number) => {
66 |   await setBlacklist(from, webhookId, to, ttl)
67 |   await addToBlacklistInMemory(from, webhookId, to, ttl)
68 |   return true
69 | }
70 | 
71 | export const addToBlacklistJob: addToBlacklist = async (from: string, webhookId: string, to: string, ttl: number) => {
72 |   await amqpPublish(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BLACKLIST_ADD, from, { from, webhookId, to, ttl }, { type: 'topic' })
73 |   return true
74 | }


--------------------------------------------------------------------------------
/src/services/broadcast.ts:
--------------------------------------------------------------------------------
 1 | import { Server } from 'socket.io'
 2 | 
 3 | export class Broadcast {
 4 |   private server: Server
 5 | 
 6 |   public setSever(server: Server) {
 7 |     this.server = server
 8 |   }
 9 | 
10 |   public async send(phone: string, type: string, content: string) {
11 |     if (!this.server) {
12 |       throw 'Set the socket server'
13 |     }
14 |     await this.server.emit('broadcast', { phone, type, content })
15 |   }
16 | }
17 |   


--------------------------------------------------------------------------------
/src/services/broadcast_amqp.ts:
--------------------------------------------------------------------------------
 1 | import { amqpPublish } from '../amqp'
 2 | import { UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BROADCAST } from '../defaults'
 3 | import { Broadcast } from './broadcast'
 4 | 
 5 | export class BroadcastAmqp extends Broadcast {
 6 |   public async send(phone: string, type: string, content: string) {
 7 |     const payload = { phone, type, content }
 8 |     await amqpPublish(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BROADCAST, phone, payload, { type: 'topic' })
 9 |   }
10 | }
11 | 


--------------------------------------------------------------------------------
/src/services/client.ts:
--------------------------------------------------------------------------------
 1 | import { Response } from './response'
 2 | import { OnNewLogin } from './socket'
 3 | import { getConfig } from './config'
 4 | import { Listener } from './listener'
 5 | 
 6 | export const clients: Map = new Map()
 7 | 
 8 | export type ContactStatus = 'valid' | 'processing' | 'invalid'| 'failed'
 9 | 
10 | export interface Contact {
11 |   wa_id: String | undefined
12 |   input: String
13 |   status: ContactStatus
14 | }
15 | 
16 | export interface getClient {
17 |   ({
18 |     phone,
19 |     listener,
20 |     getConfig,
21 |     onNewLogin,
22 |   }: {
23 |     phone: string
24 |     listener: Listener
25 |     getConfig: getConfig
26 |     onNewLogin: OnNewLogin
27 |   }): Promise
28 | }
29 | 
30 | export class ConnectionInProgress extends Error {
31 |   constructor(message: string) {
32 |     super(message)
33 |   }
34 | }
35 | 
36 | export interface Client {
37 |   connect(time: number): Promise
38 | 
39 |   disconnect(): Promise
40 |   
41 |   logout(): Promise
42 | 
43 |   // eslint-disable-next-line @typescript-eslint/no-explicit-any
44 |   send(payload: any, options: any): Promise
45 | 
46 |   getMessageMetadata(message: T): Promise
47 | 
48 |   contacts(numbers: string[]): Promise
49 | }
50 | 


--------------------------------------------------------------------------------
/src/services/client_forward.ts:
--------------------------------------------------------------------------------
 1 | import { Client, Contact } from './client';
 2 | import { getConfig } from './config';
 3 | import { Listener } from './listener';
 4 | import logger from './logger';
 5 | 
 6 | export class ClientForward implements Client {
 7 |   private phone: string
 8 |   private listener: Listener
 9 |   private getConfig: getConfig
10 | 
11 |   constructor(phone: string, getConfig: getConfig, listener: Listener) {
12 |     this.phone = phone
13 |     this.getConfig = getConfig
14 |     this.listener = listener
15 |   }
16 | 
17 |   public async send(payload: any, options: any) {
18 |     const config = await this.getConfig(this.phone)
19 |     const body = JSON.stringify(payload)
20 |     const headers = {
21 |       'Content-Type': 'application/json; charset=utf-8',
22 |       'Authorization': `Bearer ${config.webhookForward.token}`
23 |     }
24 |     const endpoint = options.endpoint && payload.type ? options.endpoint : 'messages'
25 |     const url = `${config.webhookForward.url}/${config.webhookForward.version}/${config.webhookForward.phoneNumberId}/${endpoint}`
26 |     logger.debug(`Send url ${url} with headers %s and body %s`, JSON.stringify(headers), body)
27 |     let response: Response
28 |     try {
29 |       const options: RequestInit = { method: 'POST', body, headers }
30 |       if (config.webhookForward?.timeoutMs) {
31 |         options.signal = AbortSignal.timeout(config.webhookForward?.timeoutMs)
32 |       }
33 |       response = await fetch(url, options)
34 |     } catch (error) {
35 |       logger.error(error, `Error on send to url ${url} with headers %s and body %s`, JSON.stringify(headers), body)
36 |       throw error
37 |     }
38 |     logger.debug('Response status: %s', response?.status)
39 |     if (!response?.ok) {
40 |       return { error: await response.json(), ok: undefined }
41 |     } else {
42 |       return { ok: await response.json(), error: undefined }
43 |     }
44 |   }
45 | 
46 |   public async connect(_time: number) {
47 |     const message = {
48 |       message: {
49 |         conversation: 'Starting unoapi forwarder......'
50 |       }
51 |     }
52 |     return this.listener.process(this.phone, [message] , 'status')
53 |   }
54 | 
55 |   public getMessageMetadata(_message: T): Promise {
56 |     throw new Error('ClientCloudApi not getMessageMetadata')
57 |   }
58 | 
59 |   public contacts(_numbers: string[]): Promise {
60 |     throw new Error('ClientCloudApi not contacts')
61 |   }
62 | 
63 |   public async disconnect() {
64 |     throw 'ClientCloudApi not disconnect'
65 |   }
66 |   
67 |   public async logout() {
68 |     throw 'ClientCloudApi not logout'
69 |   }
70 | }
71 | 


--------------------------------------------------------------------------------
/src/services/config_redis.ts:
--------------------------------------------------------------------------------
 1 | import { getConfig, Config, configs } from './config'
 2 | import { getConfig as getConfigCache } from './redis'
 3 | import { getStoreRedis } from './store_redis'
 4 | import { getStoreFile } from './store_file'
 5 | import logger from './logger'
 6 | import { getConfigByEnv } from './config_by_env'
 7 | import { MessageFilter } from './message_filter'
 8 | 
 9 | export const getConfigRedis: getConfig = async (phone: string): Promise => {
10 |   if (!configs.has(phone)) {
11 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
12 |     const configRedis: any = { ...((await getConfigCache(phone)) || {}) }
13 |     logger.info('Retrieve config default for %s', phone)
14 |     const config: Config = { ...(await getConfigByEnv(phone)) }
15 | 
16 |     if (configRedis) {
17 |       Object.keys(configRedis).forEach((key) => {
18 |         if (key in configRedis) {
19 |           if (key === 'webhooks') {
20 |             const webhooks: any[] = []
21 |             configRedis[key].forEach((webhook) => {
22 |               Object.keys(config.webhooks[0]).forEach((keyWebhook) => {
23 |                 if (!(keyWebhook in webhook)) {
24 |                   // override by env, if not present in redis
25 |                   webhook[keyWebhook] = config.webhooks[0][keyWebhook]
26 |                 }
27 |               });
28 |               webhooks.push(webhook)
29 |             })
30 |             configRedis[key] = webhooks
31 |           } else if (key === 'webhookForward'){
32 |             const webhookForward = configRedis[key]
33 |             Object.keys(configRedis[key]).forEach((k) => {
34 |               if (!webhookForward[k]) {
35 |                 webhookForward[k] = config[key][k]
36 |               }
37 |             })
38 |             configRedis[key] = webhookForward
39 |           }
40 |           logger.debug('Override env config by redis config in %s: %s => %s', phone, key, JSON.stringify(configRedis[key]));
41 |           config[key] = configRedis[key];
42 |         }
43 |       });
44 |     }
45 | 
46 |     config.server = config.server || 'server_1'
47 |     config.provider = config.provider || 'baileys'
48 |     
49 |     const filter: MessageFilter = new MessageFilter(phone, config)
50 |     config.shouldIgnoreJid = filter.isIgnoreJid.bind(filter)
51 |     config.shouldIgnoreKey = filter.isIgnoreKey.bind(filter)
52 |     if (config.useRedis) {
53 |       config.getStore = getStoreRedis
54 |     } else {
55 |       config.getStore = getStoreFile
56 |     }
57 |     logger.info('Config redis: %s -> %s', phone, JSON.stringify(config))
58 |     configs.set(phone, config)
59 |   }
60 |   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
61 |   return configs.get(phone)!
62 | }
63 | 


--------------------------------------------------------------------------------
/src/services/contact.ts:
--------------------------------------------------------------------------------
 1 | import { Contact as ContactRow } from './client'
 2 | 
 3 | export interface ContactResponse {
 4 |   contacts: ContactRow[]
 5 | }
 6 | 
 7 | export interface Contact {
 8 |   verify(phone: string, numbers: String[]): Promise
 9 | }
10 | 


--------------------------------------------------------------------------------
/src/services/contact_baileys.ts:
--------------------------------------------------------------------------------
 1 | import { Contact } from './contact'
 2 | import { Client, getClient } from './client'
 3 | import { getConfig } from './config'
 4 | import { OnNewLogin } from './socket'
 5 | import { Listener } from './listener'
 6 | 
 7 | export default class ContactBaileys implements Contact {
 8 |   private service: Listener
 9 |   private getClient: getClient
10 |   private getConfig: getConfig
11 |   private onNewLogin: OnNewLogin
12 | 
13 |   constructor(service: Listener, getConfig: getConfig, getClient: getClient, onNewLogin: OnNewLogin) {
14 |     this.service = service
15 |     this.getConfig = getConfig
16 |     this.getClient = getClient
17 |     this.onNewLogin = onNewLogin
18 |   }
19 | 
20 |   public async verify(phone: string, numbers: string[]) {
21 |     const client: Client = await this.getClient({
22 |       phone,
23 |       listener: this.service,
24 |       getConfig: this.getConfig,
25 |       onNewLogin: this.onNewLogin,
26 |     })
27 |     const contacts = await client.contacts(numbers)
28 |     return { contacts }
29 |   }
30 | }


--------------------------------------------------------------------------------
/src/services/contact_dummy.ts:
--------------------------------------------------------------------------------
1 | import { Contact, ContactResponse } from './contact';
2 | 
3 | export class ContactDummy implements Contact {
4 |   public async verify(_phone: String, _numbers: String[]) {
5 |     return { contacts: [] } as ContactResponse
6 |   }
7 | }


--------------------------------------------------------------------------------
/src/services/data_store.ts:
--------------------------------------------------------------------------------
 1 | import { GroupMetadata, WAMessage, WAMessageKey, WASocket } from 'baileys'
 2 | import { Config } from './config'
 3 | import makeInMemoryStore from '../store/make-in-memory-store'
 4 | 
 5 | export const dataStores: Map = new Map()
 6 | 
 7 | export interface getDataStore {
 8 |   (phone: string, config: Config): Promise
 9 | }
10 | 
11 | export type MessageStatus = 'scheduled'
12 |       | 'pending'
13 |       | 'without-whatsapp'
14 |       | 'invalid-phone-number'
15 |       | 'error'
16 |       | 'failed'
17 |       | 'sent'
18 |       | 'delivered'
19 |       | 'read'
20 |       | 'played'
21 |       | 'accepted'
22 |       | 'deleted'
23 | 
24 | export type DataStore = ReturnType & {
25 |   type: string
26 |   loadKey: (id: string) => Promise
27 |   setKey: (id: string, key: WAMessageKey) => Promise
28 |   setUnoId: (id: string, unoId: string) => Promise
29 |   setMediaPayload: (id: string, payload: any) => Promise
30 |   loadMediaPayload: (id: string) => Promise
31 |   setImageUrl: (jid: string, url: string) => Promise
32 |   getImageUrl: (jid: string) => Promise
33 |   loadImageUrl: (jid: string, sock: Partial) => Promise
34 |   setGroupMetada: (jid: string, data: GroupMetadata) => Promise
35 |   getGroupMetada: (jid: string) => Promise
36 |   loadGroupMetada: (jid: string, sock: Partial) => Promise
37 |   loadUnoId: (id: string) => Promise
38 |   setStatus: (id: string, status: MessageStatus) => Promise
39 |   loadStatus: (id: string) => Promise
40 |   getJid: (phone: string) => Promise
41 |   loadJid: (phone: string, sock: WASocket) => Promise
42 |   setJid: (phone: string, jid: string) => Promise
43 |   setMessage: (jid: string, message: WAMessage) => Promise
44 |   cleanSession: (removeConfig: boolean) => Promise
45 |   loadTemplates(): Promise
46 |   setTemplates(templates: string): Promise
47 | }
48 | 


--------------------------------------------------------------------------------
/src/services/incoming.ts:
--------------------------------------------------------------------------------
1 | import { Response } from './response'
2 | 
3 | export interface Incoming {
4 |   send(phone: string, payload: object, options: object): Promise
5 | }
6 | 


--------------------------------------------------------------------------------
/src/services/incoming_amqp.ts:
--------------------------------------------------------------------------------
 1 | import { Incoming } from './incoming'
 2 | import { amqpPublish } from '../amqp'
 3 | import { UNOAPI_EXCHANGE_BRIDGE_NAME, UNOAPI_QUEUE_INCOMING } from '../defaults'
 4 | import { v1 as uuid } from 'uuid'
 5 | import { jidToPhoneNumber } from './transformer'
 6 | import { getConfig } from './config'
 7 | 
 8 | export class IncomingAmqp implements Incoming {
 9 |   private getConfig: getConfig
10 | 
11 |   constructor(getConfig: getConfig) {
12 |     this.getConfig = getConfig
13 |   }
14 | 
15 |   public async send(phone: string, payload: object, options: object = {}) {
16 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
17 |     const { status, type, to } = payload as any
18 |     const config = await this.getConfig(phone);
19 |     if (status) {
20 |       options['type'] = 'direct'
21 |       options['priority'] = 3 // update status is always middle important
22 |       await amqpPublish(
23 |         UNOAPI_EXCHANGE_BRIDGE_NAME,
24 |         `${UNOAPI_QUEUE_INCOMING}.${config.server!}`, 
25 |         phone,
26 |         { payload, options },
27 |         options
28 |       )
29 |       return { ok: { success: true } }
30 |     } else if (type) {
31 |       const id = uuid()
32 |       if (!options['priority']) {
33 |         options['priority'] = 5 // send message without bulk is very important
34 |       }
35 |       options['type'] = 'direct'
36 |       await amqpPublish(
37 |         UNOAPI_EXCHANGE_BRIDGE_NAME,
38 |         `${UNOAPI_QUEUE_INCOMING}.${config.server!}`,
39 |         phone,
40 |         { payload, id, options }, 
41 |         options
42 |       )
43 |       const ok = {
44 |         messaging_product: 'whatsapp',
45 |         contacts: [
46 |           {
47 |             wa_id: jidToPhoneNumber(to, ''),
48 |           },
49 |         ],
50 |         messages: [
51 |           {
52 |             id,
53 |           },
54 |         ],
55 |       }
56 |       return { ok }
57 |     } else {
58 |       throw `Unknown incoming message ${JSON.stringify(payload)}`
59 |     }
60 |   }
61 | }
62 | 


--------------------------------------------------------------------------------
/src/services/incoming_baileys.ts:
--------------------------------------------------------------------------------
 1 | import { Incoming } from './incoming'
 2 | import { Client, getClient } from './client'
 3 | import { getConfig } from './config'
 4 | import { OnNewLogin } from './socket'
 5 | import logger from './logger'
 6 | import { Listener } from './listener'
 7 | 
 8 | export class IncomingBaileys implements Incoming {
 9 |   private service: Listener
10 |   private getClient: getClient
11 |   private getConfig: getConfig
12 |   private onNewLogin: OnNewLogin
13 | 
14 |   constructor(service: Listener, getConfig: getConfig, getClient: getClient, onNewLogin: OnNewLogin) {
15 |     this.service = service
16 |     this.getConfig = getConfig
17 |     this.getClient = getClient
18 |     this.onNewLogin = onNewLogin
19 |   }
20 | 
21 |   public async send(phone: string, payload: object, options: object) {
22 |     const client: Client = await this.getClient({
23 |       phone,
24 |       listener: this.service,
25 |       getConfig: this.getConfig,
26 |       onNewLogin: this.onNewLogin,
27 |     })
28 |     logger.debug('Retrieved client for %s', phone)
29 |     return client.send(payload, options)
30 |   }
31 | }
32 | 


--------------------------------------------------------------------------------
/src/services/inject_route.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | 
3 | export default interface router {
4 |   (router: Router): Promise
5 | }
6 | 


--------------------------------------------------------------------------------
/src/services/inject_route_dummy.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | import injectRoute from './inject_route'
3 | 
4 | const injectRouteDummy: injectRoute = async (_router: Router) => {}
5 | export default injectRouteDummy
6 | 


--------------------------------------------------------------------------------
/src/services/listener.ts:
--------------------------------------------------------------------------------
1 | export type eventType = 'qrcode' | 'status' | 'history' | 'append' | 'notify' | 'update' | 'delete' | 'contacts.upsert' | 'contacts.update'
2 | 
3 | export interface Listener {
4 |   process(phone: string, messages: object[], type: eventType): Promise
5 | }
6 | 


--------------------------------------------------------------------------------
/src/services/listener_amqp.ts:
--------------------------------------------------------------------------------
 1 | import { eventType, Listener } from './listener'
 2 | import { PublishOption, amqpPublish } from '../amqp'
 3 | import { UNOAPI_EXCHANGE_BRIDGE_NAME, UNOAPI_QUEUE_LISTENER, UNOAPI_SERVER_NAME } from '../defaults'
 4 | 
 5 | const priorities = {
 6 |   'qrcode': 5,
 7 |   'status': 3,
 8 |   'history': 0,
 9 |   'append': 5,
10 |   'notify': 5,
11 |   'message': 5,
12 |   'update': 3,
13 |   'delete': 3,
14 | }
15 | 
16 | const delay = new Map()
17 | 
18 | const delays = {
19 |   'qrcode': _ => 0,
20 |   'status': _ => 0,
21 |   'history': (phone: string) => {
22 |     const current = delay.get(phone)
23 |     if (current) {
24 |       delay.set(phone, current + 1000)
25 |       return current
26 |     } else {
27 |       delay.set(phone, 1000)
28 |       return 0
29 |     }
30 |   },
31 |   'append': _ => 0,
32 |   'notify': _ => 0,
33 |   'message': _ => 0,
34 |   'update': _ => 0,
35 |   'delete': _ => 0,
36 | }
37 | 
38 | 
39 | export class ListenerAmqp implements Listener {
40 |   public async process(phone: string, messages: object[], type: eventType) {
41 |     const options: Partial = {}
42 |     options.priority = options.priority || priorities[type] || 5
43 |     options.delay = options.delay || delays[type](phone) || 0
44 |     options.type = 'direct'
45 |     await amqpPublish(
46 |       UNOAPI_EXCHANGE_BRIDGE_NAME,
47 |       `${UNOAPI_QUEUE_LISTENER}.${UNOAPI_SERVER_NAME}`,
48 |       phone,
49 |       { messages, type }, 
50 |       options
51 |     )
52 |   }
53 | }
54 | 


--------------------------------------------------------------------------------
/src/services/logger.ts:
--------------------------------------------------------------------------------
1 | import P, { Level } from 'pino'
2 | 
3 | import { UNO_LOG_LEVEL } from '../defaults'
4 | 
5 | const logger = P({ timestamp: () => `,"time":"${new Date().toJSON()}"` })
6 | logger.level = UNO_LOG_LEVEL as Level
7 | 
8 | export default logger
9 | 


--------------------------------------------------------------------------------
/src/services/logout.ts:
--------------------------------------------------------------------------------
1 | export interface Logout {
2 |   run(phone: string): Promise
3 | }
4 | 


--------------------------------------------------------------------------------
/src/services/logout_amqp.ts:
--------------------------------------------------------------------------------
 1 | import { amqpPublish } from '../amqp'
 2 | import { UNOAPI_EXCHANGE_BRIDGE_NAME, UNOAPI_QUEUE_LOGOUT } from '../defaults'
 3 | import { getConfig } from './config'
 4 | import { Logout } from './logout'
 5 | 
 6 | export class LogoutAmqp implements Logout {
 7 |   private getConfig: getConfig
 8 | 
 9 |   constructor(getConfig: getConfig) {
10 |     this.getConfig = getConfig
11 |   }
12 | 
13 |     public async run(phone: string) {
14 |     const config = await this.getConfig(phone)
15 |     await amqpPublish(
16 |       UNOAPI_EXCHANGE_BRIDGE_NAME,
17 |       `${UNOAPI_QUEUE_LOGOUT}.${config.server!}`,
18 |       '',
19 |       { phone },
20 |       { type: 'direct' }
21 |     )
22 |   }
23 | }
24 | 


--------------------------------------------------------------------------------
/src/services/logout_baileys.ts:
--------------------------------------------------------------------------------
 1 | import { Listener } from '../services/listener'
 2 | import { configs, getConfig } from '../services/config'
 3 | import { clients, getClient } from '../services/client'
 4 | import { OnNewLogin } from '../services/socket'
 5 | import { Logout } from './logout'
 6 | import logger from './logger'
 7 | import { stores } from './store'
 8 | import { dataStores } from './data_store'
 9 | import { mediaStores } from './media_store'
10 | 
11 | export class LogoutBaileys implements Logout {
12 |   private getClient: getClient
13 |   private getConfig: getConfig
14 |   private listener: Listener
15 |   private onNewLogin: OnNewLogin
16 | 
17 |   constructor(getClient: getClient, getConfig: getConfig, listener: Listener, onNewLogin: OnNewLogin) {
18 |     this.getClient = getClient
19 |     this.getConfig = getConfig
20 |     this.listener = listener
21 |     this.onNewLogin = onNewLogin
22 |   }
23 | 
24 |   async run(phone: string) {
25 |     logger.debug('Logout baileys for phone %s', phone)
26 |     const config = await this.getConfig(phone)
27 |     const store = await config.getStore(phone, config)
28 |     const { sessionStore, dataStore } = store
29 |     if (await sessionStore.isStatusOnline(phone)) {
30 |       const client = await this.getClient({
31 |         phone,
32 |         listener: this.listener,
33 |         getConfig: this.getConfig,
34 |         onNewLogin: this.onNewLogin,
35 |       })
36 |       await client.logout()
37 |     }
38 |     await dataStore.cleanSession(true)
39 |     clients.delete(phone)
40 |     stores.delete(phone)
41 |     dataStores.delete(phone)
42 |     mediaStores.delete(phone)
43 |     configs.delete(phone)
44 |     sessionStore.setStatus(phone, 'disconnected')
45 |   }
46 | }
47 | 


--------------------------------------------------------------------------------
/src/services/media_store.ts:
--------------------------------------------------------------------------------
 1 | import { Contact, proto, WAMessage } from 'baileys'
 2 | import { Response } from 'express'
 3 | import { getDataStore } from './data_store'
 4 | import { Config } from './config'
 5 | import { Readable } from 'stream'
 6 | 
 7 | export const mediaStores: Map = new Map()
 8 | 
 9 | export interface getMediaStore {
10 |   (phone: string, config: Config, getDataStore: getDataStore): MediaStore
11 | }
12 | 
13 | export type MediaStore = {
14 |   type: string
15 |   getMedia: (baseUrl: string, mediaId: string) => Promise
16 |   saveMedia: (waMessage: WAMessage) => Promise
17 |   saveMediaForwarder: (message: T) => Promise
18 |   saveMediaBuffer: (fileName: string, buffer: Buffer) => Promise
19 |   removeMedia: (fileName: string) => Promise
20 |   downloadMedia: (resp: Response, fileName: string) => Promise
21 |   downloadMediaStream: (fileName: string) => Promise
22 |   getFilePath: (phone: string, mediaId: string, mimeType: string) => string
23 |   getFileUrl: (filePath: string, expiresIn: number) => Promise
24 |   getDownloadUrl: (baseUrl: string, fileName: string) => Promise
25 |   getProfilePictureUrl: (baseUrl: string, jid: string) => Promise
26 |   saveProfilePicture: (contact: Partial) => Promise
27 | }
28 | 


--------------------------------------------------------------------------------
/src/services/middleware.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express'
2 | 
3 | export default interface middleware {
4 |   (req: Request, res: Response, next: NextFunction): Promise
5 | }
6 | 


--------------------------------------------------------------------------------
/src/services/middleware_next.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express'
2 | import middleware from './middleware'
3 | 
4 | export const middlewareNext: middleware = async (_req: Request, _res: Response, next: NextFunction) => next()
5 | 


--------------------------------------------------------------------------------
/src/services/on_new_login_alert.ts:
--------------------------------------------------------------------------------
 1 | import { v1 as uuid } from 'uuid'
 2 | import { Listener } from './listener'
 3 | import { OnNewLogin } from './socket'
 4 | import { phoneNumberToJid } from './transformer'
 5 | 
 6 | export const onNewLoginAlert = (listener: Listener): OnNewLogin => {
 7 |   return async (phone: string) => {
 8 |     const message = `Please be careful, the http endpoint is unprotected and if it is exposed in the network, someone else can send message as you!`
 9 |     const payload = {
10 |       key: {
11 |         remoteJid: phoneNumberToJid(phone),
12 |         id: uuid(),
13 |       },
14 |       message: {
15 |         conversation: message,
16 |       },
17 |     }
18 |     return listener.process(phone, [payload], 'notify')
19 |   }
20 | }
21 | 


--------------------------------------------------------------------------------
/src/services/on_new_login_generate_token.ts:
--------------------------------------------------------------------------------
 1 | import { Outgoing } from './outgoing'
 2 | import { v1 as uuid } from 'uuid'
 3 | import { getConfigRedis } from './config_redis'
 4 | import { getConfig, setConfig } from './redis'
 5 | import { OnNewLogin } from './socket'
 6 | import { t } from '../i18n'
 7 | 
 8 | export const onNewLoginGenerateToken = (outgoing: Outgoing): OnNewLogin => {
 9 |   return async (phone: string) => {
10 |     let authToken = `${uuid()}${uuid()}`.replaceAll('-', '')
11 |     const config = await getConfig(phone)
12 |     if (!config) {
13 |       const defaultConfig = { ...(await getConfigRedis(phone)), authToken }
14 |       await setConfig(phone, defaultConfig)
15 |     } else {
16 |       if (!config.authToken) {
17 |         config.authToken = authToken
18 |       } else {
19 |         authToken = config.authToken
20 |       }
21 |       await setConfig(phone, { ...config })
22 |     }
23 |     const message = t('on_read_qrcode', authToken)
24 |     const payload = {
25 |       from: phone,
26 |       type: 'text',
27 |       text: {
28 |         body: message,
29 |       },
30 |     }
31 |     return outgoing.formatAndSend(phone, phone, payload)
32 |   }
33 | }
34 | 


--------------------------------------------------------------------------------
/src/services/outgoing.ts:
--------------------------------------------------------------------------------
 1 | import { PublishOption } from '../amqp'
 2 | import { Webhook } from './config'
 3 | 
 4 | export class FailedSend extends Error {
 5 |   // eslint-disable-next-line @typescript-eslint/no-explicit-any
 6 |   private errors: any[]
 7 | 
 8 |   // eslint-disable-next-line @typescript-eslint/no-explicit-any
 9 |   constructor(errors: any[]) {
10 |     super('')
11 |     this.errors = errors
12 |   }
13 | 
14 |   getErrors() {
15 |     return this.errors
16 |   }
17 | }
18 | 
19 | export interface Outgoing {
20 |   formatAndSend(phone: string, to: string, message: object): Promise
21 |   send(phone: string, message: object): Promise
22 |   sendHttp(phone: string, webhook: Webhook, message: object, options: Partial): Promise
23 | }
24 | 


--------------------------------------------------------------------------------
/src/services/outgoing_amqp.ts:
--------------------------------------------------------------------------------
 1 | import { Webhook, getConfig } from './config'
 2 | import { Outgoing } from './outgoing'
 3 | import { PublishOption, amqpPublish } from '../amqp'
 4 | import { UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_OUTGOING } from '../defaults'
 5 | import { completeCloudApiWebHook } from './transformer'
 6 | 
 7 | export class OutgoingAmqp implements Outgoing {
 8 |   private getConfig: getConfig
 9 | 
10 |   constructor(getConfig: getConfig) {
11 |     this.getConfig = getConfig
12 |   }
13 | 
14 |   public async formatAndSend(phone: string, to: string, message: object) {
15 |     const data = completeCloudApiWebHook(phone, to, message)
16 |     return this.send(phone, data)
17 |   }
18 | 
19 |   public async send(phone: string, payload: object) {
20 |     const config = await this.getConfig(phone)
21 |     await amqpPublish(
22 |       UNOAPI_EXCHANGE_BROKER_NAME,
23 |       UNOAPI_QUEUE_OUTGOING, phone,
24 |       { webhooks: config.webhooks, payload, split: true },
25 |       { type: 'topic' }
26 |     )
27 |   }
28 | 
29 |   public async sendHttp(phone: string, webhook: Webhook, payload: object, options: Partial = {}) {
30 |     options.type = 'topic'
31 |     await amqpPublish(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_OUTGOING, phone, { webhook, payload, split: false }, options)
32 |   }
33 | }
34 | 


--------------------------------------------------------------------------------
/src/services/outgoing_cloud_api.ts:
--------------------------------------------------------------------------------
 1 | import { Outgoing } from './outgoing'
 2 | import fetch, { Response, RequestInit } from 'node-fetch'
 3 | import { Webhook, getConfig } from './config'
 4 | import logger from './logger'
 5 | import { completeCloudApiWebHook, isGroupMessage, isOutgoingMessage, isNewsletterMessage, isUpdateMessage } from './transformer'
 6 | import { isInBlacklist } from './blacklist'
 7 | import { PublishOption } from '../amqp'
 8 | 
 9 | export class OutgoingCloudApi implements Outgoing {
10 |   private getConfig: getConfig
11 |   private isInBlacklist: isInBlacklist
12 | 
13 |   constructor(getConfig: getConfig, isInBlacklist: isInBlacklist) {
14 |     this.getConfig = getConfig
15 |     this.isInBlacklist = isInBlacklist
16 |   }
17 | 
18 |   public async formatAndSend(phone: string, to: string, message: object) {
19 |     const data = completeCloudApiWebHook(phone, to, message)
20 |     return this.send(phone, data)
21 |   }
22 | 
23 |   public async send(phone: string, message: object) {
24 |     const config = await this.getConfig(phone)
25 |     const promises = config.webhooks.map(async (w) => this.sendHttp(phone, w, message))
26 |     await Promise.all(promises)
27 |   }
28 | 
29 |   public async sendHttp(phone: string, webhook: Webhook, message: object, _options: Partial = {}) {
30 |     const destinyPhone = await this.isInBlacklist(phone, webhook.id, message)
31 |     if (destinyPhone) {
32 |       logger.info(`Session phone %s webhook %s and destiny phone %s are in blacklist`, phone, webhook.id, destinyPhone)
33 |       return
34 |     }
35 |     if (!webhook.sendGroupMessages && isGroupMessage(message)) {
36 |       logger.info(`Session phone %s webhook %s configured to not send group message for this webhook`, phone, webhook.id)
37 |       return
38 |     }
39 |     if (!webhook.sendNewsletterMessages && isNewsletterMessage(message)) {
40 |       logger.info(`Session phone %s webhook %s configured to not send newsletter message for this webhook`, phone, webhook.id)
41 |       return
42 |     }
43 |     if (!webhook.sendOutgoingMessages && isOutgoingMessage(message)) {
44 |       logger.info(`Session phone %s webhook %s configured to not send outgoing message for this webhook`, phone, webhook.id)
45 |       return
46 |     }
47 |     if (!webhook.sendUpdateMessages && isUpdateMessage(message)) {
48 |       logger.info(`Session phone %s webhook %s configured to not send update message for this webhook`, phone, webhook.id)
49 |       return
50 |     }
51 |     const body = JSON.stringify(message)
52 |     const headers = {
53 |       'Content-Type': 'application/json; charset=utf-8'
54 |     }
55 |     if (webhook.header && webhook.token) {
56 |       headers[webhook.header] = webhook.token
57 |     }
58 |     const url = webhook.urlAbsolute || `${webhook.url}/${phone}`
59 |     logger.debug(`Send url ${url} with headers %s and body %s`, JSON.stringify(headers), body)
60 |     let response: Response
61 |     try {
62 |       const options: RequestInit = { method: 'POST', body, headers }
63 |       if (webhook.timeoutMs) {
64 |         options.signal = AbortSignal.timeout(webhook.timeoutMs)
65 |       }
66 |       response = await fetch(url, options)
67 |     } catch (error) {
68 |       logger.error('Error on send to url %s with headers %s and body %s', url, JSON.stringify(headers), body)
69 |       logger.error(error)
70 |       throw error
71 |     }
72 |     logger.debug('Response: %s', response?.status)
73 |     if (!response?.ok) {
74 |       throw await response?.text()
75 |     }
76 |   }
77 | }
78 | 


--------------------------------------------------------------------------------
/src/services/reload.ts:
--------------------------------------------------------------------------------
 1 | import { clients } from '../services/client'
 2 | import { configs } from '../services/config'
 3 | import { dataStores } from './data_store'
 4 | import logger from './logger'
 5 | import { mediaStores } from './media_store'
 6 | import { stores } from './store'
 7 | 
 8 | export class Reload {
 9 |   async run(phone: string) {
10 |     logger.debug('Reload memory run for phone %s', phone)
11 |     clients.delete(phone)
12 |     stores.delete(phone)
13 |     dataStores.delete(phone)
14 |     mediaStores.delete(phone)
15 |     configs.delete(phone)
16 |   }
17 | }
18 | 


--------------------------------------------------------------------------------
/src/services/reload_amqp.ts:
--------------------------------------------------------------------------------
 1 | import { amqpPublish } from '../amqp'
 2 | import { UNOAPI_EXCHANGE_BRIDGE_NAME, UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_RELOAD } from '../defaults'
 3 | import { getConfig } from './config'
 4 | import { Reload } from './reload'
 5 | 
 6 | export class ReloadAmqp extends Reload {
 7 |   private getConfig: getConfig
 8 | 
 9 |   constructor(getConfig: getConfig) {
10 |     super()
11 |     this.getConfig = getConfig
12 |   }
13 | 
14 |   public async run(phone: string) {
15 |     const config = await this.getConfig(phone)
16 |     await amqpPublish(
17 |       UNOAPI_EXCHANGE_BROKER_NAME,
18 |       UNOAPI_QUEUE_RELOAD,
19 |       phone,
20 |       { phone },
21 |       { type: 'topic' }
22 |     )
23 |     await amqpPublish(
24 |       UNOAPI_EXCHANGE_BRIDGE_NAME,
25 |       `${UNOAPI_QUEUE_RELOAD}.${config.server!}`,
26 |       '',
27 |       { phone },
28 |       { type: 'direct' }
29 |     )
30 |   }
31 | }
32 | 


--------------------------------------------------------------------------------
/src/services/reload_baileys.ts:
--------------------------------------------------------------------------------
 1 | import { UNOAPI_SERVER_NAME } from '../defaults'
 2 | import { getClient } from '../services/client'
 3 | import { getConfig } from '../services/config'
 4 | import { Listener } from '../services/listener'
 5 | import { OnNewLogin } from '../services/socket'
 6 | import logger from './logger'
 7 | import { Reload } from './reload'
 8 | 
 9 | export class ReloadBaileys extends Reload {
10 |   private getClient: getClient
11 |   private getConfig: getConfig
12 |   private listener: Listener
13 |   private onNewLogin: OnNewLogin
14 | 
15 |   constructor(getClient: getClient, getConfig: getConfig, listener: Listener, onNewLogin: OnNewLogin) {
16 |     super()
17 |     this.getClient = getClient
18 |     this.getConfig = getConfig
19 |     this.listener = listener
20 |     this.onNewLogin = onNewLogin
21 |   }
22 | 
23 |   async run(phone: string) {
24 |     logger.debug('Reload baileys run for phone %s', phone)
25 |     const config = await this.getConfig(phone)
26 |     if (config.server != UNOAPI_SERVER_NAME) {
27 |       logger.debug('Reload broker for phone %s', phone)
28 |       return super.run(phone)
29 |     }
30 |     const currentClient = await this.getClient({
31 |       phone,
32 |       listener: this.listener,
33 |       getConfig: this.getConfig,
34 |       onNewLogin: this.onNewLogin,
35 |     })
36 |     const store = await config.getStore(phone, config)
37 |     const { sessionStore } = store
38 |     if (await sessionStore.isStatusOnline(phone) || await sessionStore.isStatusStandBy(phone) || await sessionStore.isStatusConnecting(phone)) {
39 |       logger.warn('Reload disconnect session %s!', phone)
40 |       await currentClient.disconnect()
41 |     }
42 |     await super.run(phone)
43 |     await sessionStore.setStatus(phone, 'online') // to clear standby
44 |     await sessionStore.setStatus(phone, 'disconnected')
45 |     await this.getClient({
46 |       phone,
47 |       listener: this.listener,
48 |       getConfig: this.getConfig,
49 |       onNewLogin: this.onNewLogin,
50 |     })
51 |     logger.info('Reloaded session %s!', phone)
52 |   }
53 | }
54 | 


--------------------------------------------------------------------------------
/src/services/response.ts:
--------------------------------------------------------------------------------
1 | export type Response = {
2 |   // eslint-disable-next-line @typescript-eslint/no-explicit-any
3 |   ok: any
4 |   // eslint-disable-next-line @typescript-eslint/no-explicit-any
5 |   error?: any
6 | }
7 | 


--------------------------------------------------------------------------------
/src/services/security.ts:
--------------------------------------------------------------------------------
 1 | import { UNOAPI_HEADER_NAME, UNOAPI_AUTH_TOKEN } from '../defaults'
 2 | import { getConfig } from './redis'
 3 | import middleware from './middleware'
 4 | import { Request, Response, NextFunction } from 'express'
 5 | import logger from './logger'
 6 | 
 7 | const security = async (req: Request, res: Response, next: NextFunction) => {
 8 |   const { phone } = req.params
 9 |   logger.debug('Verifing client authentication...')
10 |   if (UNOAPI_AUTH_TOKEN) {
11 |     const httpAuthToken = getAuthHeaderToken(req)
12 |     if (!httpAuthToken) {
13 |       const message = `Please set Header ${UNOAPI_HEADER_NAME} query params or Authorization header`
14 |       logger.warn(message)
15 |       logger.debug('method %s', req.method)
16 |       logger.debug('headers %s', JSON.stringify(req.headers))
17 |       logger.debug('params %s', JSON.stringify(req.params))
18 |       logger.debug('body %s', JSON.stringify(req.body))
19 |       logger.debug('query %s', JSON.stringify(req.query))
20 |       res.status(401).json({
21 |         error: {
22 |           code: 0,
23 |           title: message,
24 |         },
25 |       })
26 |     } else {
27 |       logger.debug(`Retrieved http token ${httpAuthToken}`)
28 |       const config = (await getConfig(phone)) || { authToken: UNOAPI_AUTH_TOKEN }
29 |       logger.debug(`Retrieved auth token ${httpAuthToken}`)
30 |       if (httpAuthToken.trim() != config?.authToken?.trim() && httpAuthToken.trim() != UNOAPI_AUTH_TOKEN) {
31 |         const message = `Invalid token value ${httpAuthToken}`
32 |         logger.debug('method %s', req.method)
33 |         logger.debug('headers %s', JSON.stringify(req.headers))
34 |         logger.debug('params %s', JSON.stringify(req.params))
35 |         logger.debug('body %s', JSON.stringify(req.body))
36 |         logger.debug('query %s', JSON.stringify(req.query))
37 |         logger.warn(message)
38 |         res.status(403).json({
39 |           error: {
40 |             code: 10,
41 |             title: message,
42 |           },
43 |         })
44 |       } else {
45 |         logger.debug('Authenticated!')
46 |         next()
47 |       }
48 |     }
49 |   } else {
50 |     next()
51 |   }
52 | }
53 | 
54 | const getAuthHeaderToken = (req: Request) => {
55 |   const headerName = UNOAPI_HEADER_NAME
56 |   return (
57 |     req.headers[headerName] ||
58 |     req.headers['authorization'] ||
59 |     req.query['access_token'] ||
60 |     req.query['hub.verify_token'] ||
61 |     req.headers['Authorization'] ||
62 |     req.body['auth_token'] ||
63 |     req.body['authToken'] ||
64 |     ''
65 |   ).replace('Bearer ', '')
66 | }
67 | 
68 | export default security as middleware
69 | 


--------------------------------------------------------------------------------
/src/services/send_error.ts:
--------------------------------------------------------------------------------
1 | export class SendError extends Error {
2 |   readonly code: number
3 |   readonly title: string
4 |   constructor(code: number, title: string) {
5 |     super(`${code}: ${title}`)
6 |     this.code = code
7 |     this.title = title
8 |   }
9 | }


--------------------------------------------------------------------------------
/src/services/session.ts:
--------------------------------------------------------------------------------
 1 | export interface writeData {
 2 |   (key: string, data: object): Promise
 3 | }
 4 | 
 5 | export interface readData {
 6 |   (key: string): Promise
 7 | }
 8 | 
 9 | export interface removeData {
10 |   (key: string): Promise
11 | }
12 | 
13 | export interface getKey {
14 |   (type: string, id): string
15 | }
16 | 
17 | export interface session {
18 |   (phone: string): Promise<{ writeData: writeData; readData: readData; removeData: removeData; getKey: getKey }>
19 | }
20 | 


--------------------------------------------------------------------------------
/src/services/session_file.ts:
--------------------------------------------------------------------------------
 1 | import { BufferJSON } from 'baileys'
 2 | import { rmSync, writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'
 3 | import { session, writeData, readData, removeData, getKey } from './session'
 4 | import logger from './logger'
 5 | 
 6 | export const sessionFile: session = async (phone: string) => {
 7 |   const getKey: getKey = (type: string, id: string) => `/${type}-${id}.json`
 8 |   const getFile = (key: string) => `${phone}${key ? key : '/creds.json'}`.replace('/.', '')
 9 | 
10 |   if (!existsSync(phone)) {
11 |     mkdirSync(phone, { recursive: true })
12 |   }
13 | 
14 |   const fileGet = (key: string) => {
15 |     if (existsSync(key)) {
16 |       return readFileSync(key, { encoding: 'utf-8' })
17 |     }
18 |     return ''
19 |   }
20 | 
21 |   const fileSet = (key: string, value: string) => {
22 |     if (existsSync(key)) {
23 |       fileDel(key)
24 |     }
25 |     writeFileSync(key, value, { encoding: 'utf-8' })
26 |   }
27 | 
28 |   const fileDel = (key: string) => {
29 |     rmSync(key)
30 |   }
31 | 
32 |   const setAuth = (key: string, value: object, stringify: (p: object) => string) => {
33 |     const authValue = stringify(value)
34 |     return fileSet(key, authValue)
35 |   }
36 |   const getAuth = async (key: string, parse: (p: string) => object | undefined) => {
37 |     const authString = await fileGet(key)
38 |     if (authString) {
39 |       const authJson = parse(authString)
40 |       return authJson
41 |     }
42 |   }
43 |   const delAuth = async (key: string) => {
44 |     if (existsSync(key)) {
45 |       return fileDel(key)
46 |     }
47 |   }
48 | 
49 |   const writeData: writeData = async (key: string, data: object) => {
50 |     const file = getFile(key)
51 |     logger.debug('write data', file)
52 |     try {
53 |       // eslint-disable-next-line @typescript-eslint/no-explicit-any
54 |       return setAuth(file, data, (value: any) => JSON.stringify(value, BufferJSON.replacer))
55 |     } catch (error) {
56 |       logger.error(error, 'Error on write auth')
57 |       throw error
58 |     }
59 |   }
60 | 
61 |   const readData: readData = async (key: string) => {
62 |     const file = getFile(key)
63 |     logger.debug('read data %s', file)
64 |     try {
65 |       return getAuth(file, (value: string) => {
66 |         try {
67 |           return value ? JSON.parse(value, BufferJSON.reviver) : undefined
68 |         } catch (error) {
69 |           logger.error(error, `Error on parsing auth: ${value}`)
70 |           throw error
71 |         }
72 |       })
73 |     } catch (error) {
74 |       logger.error(error, 'Error on read auth %s')
75 |       throw error
76 |     }
77 |   }
78 | 
79 |   const removeData: removeData = async (key: string) => {
80 |     const file = getFile(key)
81 |     logger.debug('read data', file)
82 |     logger.debug('remove data', file)
83 |     try {
84 |       await delAuth(file)
85 |     } catch (error) {
86 |       logger.error(error, 'Error on remove auth')
87 |       throw error
88 |     }
89 |   }
90 | 
91 |   return { writeData, readData, removeData, getKey }
92 | }
93 | 


--------------------------------------------------------------------------------
/src/services/session_redis.ts:
--------------------------------------------------------------------------------
 1 | import { BufferJSON } from 'baileys'
 2 | import { setAuth, getAuth, delAuth } from './redis'
 3 | import { session, writeData, readData, removeData, getKey } from './session'
 4 | import logger from './logger'
 5 | 
 6 | export const sessionRedis: session = async (phone: string) => {
 7 |   const getKey: getKey = (type: string, id: string) => `:${type}-${id}`
 8 |   const getBase = (key: string) => `${phone}${key ? key : ':creds'}`
 9 | 
10 |   const writeData: writeData = async (key: string, data: object) => {
11 |     try {
12 |       // eslint-disable-next-line @typescript-eslint/no-explicit-any
13 |       setAuth(getBase(key), data, (value: any) => JSON.stringify(value, BufferJSON.replacer))
14 |     } catch (error) {
15 |       logger.error(error, 'Error on write auth')
16 |       throw error
17 |     }
18 |   }
19 | 
20 |   const readData: readData = async (key: string) => {
21 |     try {
22 |       return getAuth(getBase(key), (value: string) => {
23 |         try {
24 |           return value ? JSON.parse(value, BufferJSON.reviver) : null
25 |         } catch (error) {
26 |           logger.error(`Error on parsing auth: ${value}`)
27 |           throw error
28 |         }
29 |       })
30 |     } catch (error) {
31 |       logger.error(error, 'Error on read auth')
32 |       throw error
33 |     }
34 |   }
35 | 
36 |   const removeData: removeData = async (key: string) => {
37 |     try {
38 |       await delAuth(getBase(key))
39 |     } catch (error) {
40 |       logger.error(error, 'Error on remove auth %s')
41 |       throw error
42 |     }
43 |   }
44 | 
45 |   return { writeData, getKey, removeData, readData }
46 | }
47 | 


--------------------------------------------------------------------------------
/src/services/session_store.ts:
--------------------------------------------------------------------------------
 1 | import { MAX_CONNECT_RETRY } from '../defaults'
 2 | import logger from './logger'
 3 | 
 4 | export type sessionStatus = 'offline' | 'online' | 'disconnected' | 'connecting' | 'standby' | 'restart_required'
 5 | 
 6 | const statuses: Map = new Map()
 7 | const retries: Map = new Map()
 8 | 
 9 | 
10 | export abstract class SessionStore {
11 |   abstract getPhones(): Promise
12 | 
13 |   async getStatus(phone: string) {
14 |     return statuses.get(phone) || 'disconnected'
15 |   }
16 | 
17 |   async setStatus(phone: string, status: sessionStatus) {
18 |     logger.info(`Session status ${phone} change from ${await this.getStatus(phone)} to ${status}`)
19 |     statuses.set(phone, status) 
20 |   }
21 | 
22 |   async isStatusOnline(phone: string) {
23 |     return await this.getStatus(phone) == 'online'
24 |   }
25 | 
26 |   async isStatusConnecting(phone: string) {
27 |     return await this.getStatus(phone) == 'connecting'
28 |   }
29 | 
30 |   async isStatusOffline(phone: string) {
31 |     return await this.getStatus(phone) == 'offline'
32 |   }
33 | 
34 |   async isStatusDisconnect(phone: string) {
35 |     return await this.getStatus(phone) == 'disconnected'
36 |   }
37 | 
38 |   async isStatusRestartRequired(phone: string) {
39 |     return await this.getStatus(phone) == 'restart_required'
40 |   }
41 | 
42 |   async getConnectCount(phone: string) {
43 |     return retries.get(phone) || 0
44 |   }
45 | 
46 |   async setConnectCount(phone: string, count) {
47 |     retries.set(phone, count)
48 |   }
49 | 
50 |   async isStatusStandBy(phone: string) {
51 |     return await this.getStatus(phone) == 'standby'
52 |   }
53 | 
54 |   async verifyStatusStandBy(phone: string) {
55 |     const count = await this.getConnectCount(phone)
56 |     if (await this.getStatus(phone) == 'standby') {
57 |       if (count < MAX_CONNECT_RETRY) {
58 |         logger.warn('Standby removed %s', phone)
59 |         await this.setStatus(phone, 'offline')
60 |         return false
61 |       }
62 |       logger.warn('Standby %s', phone)
63 |       return true
64 |     } else if (count > MAX_CONNECT_RETRY && !await this.isStatusRestartRequired(phone)) {
65 |       this.setStatus(phone, 'standby')
66 |       return true
67 |     }
68 |     await this.setConnectCount(phone, count + 1)
69 |     return false
70 |   }
71 | 
72 |   async syncConnections() {}
73 | 
74 |   async syncConnection(_phone: string) {}
75 | }
76 | 


--------------------------------------------------------------------------------
/src/services/session_store_file.ts:
--------------------------------------------------------------------------------
 1 | import { SessionStore } from './session_store'
 2 | import { Dirent, existsSync, readdirSync } from 'fs'
 3 | 
 4 | export const SESSION_DIR = './data/sessions'
 5 | 
 6 | export class SessionStoreFile extends SessionStore {
 7 |   private sessionDir: string
 8 | 
 9 |   constructor(sessionDir: string = SESSION_DIR) {
10 |     super()
11 |     this.sessionDir = sessionDir
12 |   }
13 | 
14 |   async getPhones(): Promise {
15 |     if (existsSync(this.sessionDir)) {
16 |       const dirents: Dirent[] = readdirSync(this.sessionDir, { withFileTypes: true })
17 |       const directories = dirents.filter((dirent) => dirent.isDirectory())
18 |       const phones = directories.map((d) => d.name)
19 |       return phones
20 |     } else {
21 |       return []
22 |     }
23 |   }
24 | }
25 | 


--------------------------------------------------------------------------------
/src/services/session_store_redis.ts:
--------------------------------------------------------------------------------
 1 | import { SessionStore, sessionStatus } from './session_store'
 2 | import { configKey, authKey, redisKeys, getSessionStatus, setSessionStatus, sessionStatusKey, redisGet, getConnectCount, setConnectCount, delAuth, clearConnectCount } from './redis'
 3 | import logger from './logger'
 4 | import { MAX_CONNECT_RETRY, MAX_CONNECT_TIME } from '../defaults'
 5 | 
 6 | const toReplaceConfig = configKey('')
 7 | const toReplaceStatus = sessionStatusKey('')
 8 | 
 9 | export class SessionStoreRedis extends SessionStore {
10 |   async getPhones(): Promise {
11 |     try {
12 |       const pattern = configKey('*')
13 |       const keys = await redisKeys(pattern)
14 |       return keys.map((key: string) => key.replace(toReplaceConfig, ''))
15 |     } catch (error) {
16 |       logger.error(error, 'Erro on get configs')
17 |       throw error
18 |     }
19 |   }
20 | 
21 |   async getStatus(phone: string) {
22 |     return await getSessionStatus(phone) || 'disconnected'
23 |   }
24 | 
25 |   async setStatus(phone: string, status: sessionStatus) {
26 |     logger.info(`Session status ${phone} change from ${await this.getStatus(phone)} to ${status}`)
27 |     if (['online', 'restart_required'].includes(status)) {
28 |       await this.clearConnectCount(phone)
29 |     }
30 |     return setSessionStatus(phone, status)
31 |   }
32 | 
33 |   async getConnectCount(phone: string) {
34 |     return getConnectCount(phone)
35 |   }
36 | 
37 |   async setConnectCount(phone: string, count) {
38 |     await setConnectCount(phone, count, MAX_CONNECT_TIME)
39 |   }
40 | 
41 |   async clearConnectCount(phone: string) {
42 |     logger.info('Cleaning count connect for %s..', phone)
43 |     await clearConnectCount(phone)
44 |     logger.info('Cleaned count connect for %s!', phone)
45 |   }
46 | 
47 |   async syncConnections() {
48 |     logger.info(`Syncing lost and standby connections...`)
49 |     try {
50 |       const pattern = sessionStatusKey('*')
51 |       const keys = await redisKeys(pattern)
52 |       for (let i = 0; i < keys.length; i++) {
53 |         const key = keys[i];
54 |         const phone = key.replace(toReplaceStatus, '')
55 |         await this.syncConnection(phone)
56 |       }
57 |       logger.info(`Synced lost and standby connections!`)
58 |     } catch (error) {
59 |       logger.error(error, 'Error on sync lost connecting')
60 |       throw error
61 |     }
62 |   }
63 | 
64 |   async syncConnection(phone: string) {
65 |     logger.info(`Syncing ${phone} lost connection`)
66 |     if(await this.isStatusRestartRequired(phone)) {
67 |       logger.info(`Is not lost connection, is restart required ${phone}`)
68 |       return
69 |     }
70 |     const aKey = authKey(`${phone}*`)
71 |     const keys = await redisKeys(aKey)
72 |     logger.info(`Found auth ${keys.length} keys for session ${phone}`)
73 |     if (keys.length == 1 && keys[0] == authKey(`${phone}:creds`)) {
74 |       await delAuth(phone)
75 |       await this.setStatus(phone, 'disconnected')
76 |     }
77 |     const key = sessionStatusKey(phone)
78 |     if (await redisGet(key) == 'standby' && await this.getConnectCount(phone) < MAX_CONNECT_RETRY) {
79 |       logger.info(`Sync ${phone} standby!`)
80 |       await this.setStatus(phone, 'offline')
81 |     }
82 |   }
83 | }


--------------------------------------------------------------------------------
/src/services/store.ts:
--------------------------------------------------------------------------------
 1 | import { AuthenticationState } from 'baileys'
 2 | import { DataStore } from './data_store'
 3 | import { MediaStore } from './media_store'
 4 | import { Config } from './config'
 5 | import { SessionStore } from './session_store'
 6 | 
 7 | export const stores: Map = new Map()
 8 | 
 9 | export interface getStore {
10 |   (phone: string, config: Config): Promise
11 | }
12 | 
13 | export type Store = {
14 |   dataStore: DataStore,
15 |   sessionStore: SessionStore,
16 |   state: AuthenticationState
17 |   saveCreds: () => Promise
18 |   mediaStore: MediaStore
19 | }
20 | 
21 | export interface store {
22 |   (phone: string, config: Config): Promise
23 | }
24 | 


--------------------------------------------------------------------------------
/src/services/store_file.ts:
--------------------------------------------------------------------------------
 1 | import { AuthenticationState } from 'baileys'
 2 | import { store, Store } from './store'
 3 | import { DataStore } from './data_store'
 4 | import { getDataStoreFile } from './data_store_file'
 5 | import { authState } from './auth_state'
 6 | import { sessionFile } from './session_file'
 7 | import { MEDIA_DIR } from './data_store_file'
 8 | import { existsSync, readFileSync, rmSync, mkdirSync, renameSync } from 'fs'
 9 | import { SESSION_DIR, SessionStoreFile } from './session_store_file'
10 | import { getStore, stores } from './store'
11 | import { MediaStore } from './media_store'
12 | import { getMediaStoreFile } from './media_store_file'
13 | import { Config } from './config'
14 | import logger from './logger'
15 | import { getMediaStoreS3 } from './media_store_s3'
16 | 
17 | const STORE_DIR = `./data/stores`
18 | 
19 | export const getStoreFile: getStore = async (phone: string, config: Config): Promise => {
20 |   if (!stores.has(phone)) {
21 |     logger.debug('Creating file store %s', phone)
22 |     const fstore: Store = await storeFile(phone, config)
23 |     stores.set(phone, fstore)
24 |   } else {
25 |     logger.debug('Retrieving file store %s', phone)
26 |   }
27 |   return stores.get(phone) as Store
28 | }
29 | 
30 | const storeFile: store = async (phone: string, config: Config): Promise => {
31 |   const dirs = [SESSION_DIR, MEDIA_DIR, STORE_DIR]
32 |   dirs.forEach((dir) => {
33 |     if (!existsSync(dir)) {
34 |       logger.info(`Creating dir: ${dir}`)
35 |       mkdirSync(dir, { recursive: true })
36 |     } else {
37 |       logger.info(`Using dir: ${dir}`)
38 |     }
39 |   })
40 |   const sessionDir = `${SESSION_DIR}/${phone}`
41 |   const mediaDir = `${MEDIA_DIR}/${phone}`
42 |   logger.info(`Store session in directory: ${sessionDir}`)
43 |   logger.info(`Store medias in directory: ${mediaDir}`)
44 |   const { state, saveCreds }: { state: AuthenticationState; saveCreds: () => Promise } = await authState(sessionFile, sessionDir)
45 |   const dataStore: DataStore = await getDataStoreFile(phone, config)
46 |   let mediaStore: MediaStore
47 |   if (config.useS3) {
48 |     mediaStore = getMediaStoreS3(phone, config, getDataStoreFile) as MediaStore
49 |     logger.info(`Store media in s3`)
50 |   } else {
51 |     mediaStore = getMediaStoreFile(phone, config, getDataStoreFile) as MediaStore
52 |     logger.info(`Store media in system file`)
53 |   }
54 |   if (!config.ignoreDataStore) {
55 |     const dataFile = `${STORE_DIR}/${phone}.json`
56 |     logger.info(`Store data in file: ${dataFile}`)
57 |     if (existsSync(dataFile)) {
58 |       logger.debug(`Store data in file already exist: ${dataFile}`)
59 |       const content = readFileSync(dataFile)
60 |       if (content.toString()) {
61 |         try {
62 |           JSON.parse(content.toString())
63 |         } catch (e) {
64 |           const dest = `${dataFile}.old${new Date().getTime()}`
65 |           logger.warn(`Store data in file: ${dataFile}, content is corrupted and was moved to ${dest}`)
66 |           renameSync(dataFile, dest)
67 |         }
68 |       } else {
69 |         logger.debug(`Store data in file content is empty and was removed: ${dataFile}`)
70 |         rmSync(dataFile)
71 |       }
72 |     }
73 |     try {
74 |       dataStore.readFromFile(dataFile)
75 |     } catch (error) {
76 |       logger.debug(`Try read ${dataFile} again....`)
77 |       try {
78 |         dataStore.readFromFile(dataFile)
79 |       } catch (error) {
80 |         logger.error('Error on read data store %s', error)
81 |       }
82 |     }
83 |     setInterval(() => {
84 |       dataStore.writeToFile(dataFile), 10_0000
85 |     })
86 |   } else {
87 |     logger.info('Store data not save')
88 |   }
89 |   const sessionStore = new SessionStoreFile()
90 |   return { state, saveCreds, dataStore, mediaStore, sessionStore }
91 | }
92 | 


--------------------------------------------------------------------------------
/src/services/store_redis.ts:
--------------------------------------------------------------------------------
 1 | import { AuthenticationState } from 'baileys'
 2 | import { sessionRedis } from './session_redis'
 3 | import { authState } from './auth_state'
 4 | import { store, Store } from './store'
 5 | import { DataStore } from './data_store'
 6 | import { getDataStoreRedis } from './data_store_redis'
 7 | import { getStore, stores } from './store'
 8 | import { getMediaStoreS3 } from './media_store_s3'
 9 | import { MediaStore } from './media_store'
10 | import { Config } from './config'
11 | import logger from './logger'
12 | import { SessionStoreRedis } from './session_store_redis'
13 | import { getMediaStoreFile } from './media_store_file'
14 | 
15 | export const getStoreRedis: getStore = async (phone: string, config: Config): Promise => {
16 |   if (!stores.has(phone)) {
17 |     logger.debug('Creating redis store %s', phone)
18 |     const fstore: Store = await storeRedis(phone, config)
19 |     stores.set(phone, fstore)
20 |   } else {
21 |     logger.debug('Retrieving redis store %s', phone)
22 |   }
23 |   return stores.get(phone) as Store
24 | }
25 | 
26 | const storeRedis: store = async (phone: string, config: Config): Promise => {
27 |   logger.info(`Store session: ${phone}`)
28 |   const { state, saveCreds }: { state: AuthenticationState; saveCreds: () => Promise } = await authState(sessionRedis, phone)
29 |   const dataStore: DataStore = await getDataStoreRedis(phone, config)
30 |   let mediaStore: MediaStore
31 |   if (config.useS3) {
32 |     mediaStore = getMediaStoreS3(phone, config, getDataStoreRedis) as MediaStore
33 |     logger.info(`Store media in s3`)
34 |   } else {
35 |     mediaStore = getMediaStoreFile(phone, config, getDataStoreRedis) as MediaStore
36 |     logger.info(`Store media in system file`)
37 |   }
38 |   logger.info(`Store data in redis`)
39 |   const sessionStore = new SessionStoreRedis()
40 |   return { state, saveCreds, dataStore, mediaStore, sessionStore }
41 | }
42 | 


--------------------------------------------------------------------------------
/src/services/template.ts:
--------------------------------------------------------------------------------
 1 | import { getConfig } from './config'
 2 | 
 3 | export class Template {
 4 |   private getConfig: getConfig
 5 | 
 6 |   constructor(getConfig: getConfig) {
 7 |     this.getConfig = getConfig
 8 |   }
 9 | 
10 |   // eslint-disable-next-line @typescript-eslint/no-explicit-any
11 |   async bind(phone: string, name: string, parametersValues: any) {
12 |     const config = await this.getConfig(phone)
13 |     const store = await config.getStore(phone, config)
14 |     // eslint-disable-next-line @typescript-eslint/no-explicit-any
15 |     const template: any = (await store.dataStore.loadTemplates()).find((t: any) => t.name == name)
16 |     if (template) {
17 |       const types = ['header', 'body', 'footer']
18 |       let text = ''
19 |       types.forEach((type) => {
20 |         // eslint-disable-next-line @typescript-eslint/no-explicit-any
21 |         const component = template.components && template.components.find((c: any) => c.type.toLowerCase() == type)
22 |         if (component) {
23 |           // eslint-disable-next-line @typescript-eslint/no-explicit-any
24 |           const value = parametersValues.find((c: any) => c.type.toLowerCase() == type)
25 |           if (value) {
26 |             text = `${text}${component.text}`
27 |             // eslint-disable-next-line @typescript-eslint/no-explicit-any
28 |             value.parameters.forEach((parameter: any) => {
29 |               text = text.replace(/\{\{.*?\}\}/, parameter.text)
30 |             })
31 |           }
32 |         }
33 |       })
34 |       return { text }
35 |     } else {
36 |       throw `Template name ${name} not found`
37 |     }
38 |   }
39 | }
40 | 


--------------------------------------------------------------------------------
/src/store/make-ordered-dictionary.ts:
--------------------------------------------------------------------------------
 1 | function makeOrderedDictionary(idGetter: (item: T) => string) {
 2 | 	const array: T[] = []
 3 | 	const dict: { [_: string]: T } = { }
 4 | 
 5 | 	const get = (id: string): T | undefined => dict[id]
 6 | 
 7 | 	const update = (item: T) => {
 8 | 		const id = idGetter(item)
 9 | 		const idx = array.findIndex(i => idGetter(i) === id)
10 | 		if(idx >= 0) {
11 | 			array[idx] = item
12 | 			dict[id] = item
13 | 		}
14 | 
15 | 		return false
16 | 	}
17 | 
18 | 	const upsert = (item: T, mode: 'append' | 'prepend') => {
19 | 		const id = idGetter(item)
20 | 		if(get(id)) {
21 | 			update(item)
22 | 		} else {
23 | 			if(mode === 'append') {
24 | 				array.push(item)
25 | 			} else {
26 | 				array.splice(0, 0, item)
27 | 			}
28 | 
29 | 			dict[id] = item
30 | 		}
31 | 	}
32 | 
33 | 	const remove = (item: T) => {
34 | 		const id = idGetter(item)
35 | 		const idx = array.findIndex(i => idGetter(i) === id)
36 | 		if(idx >= 0) {
37 | 			array.splice(idx, 1)
38 | 			delete dict[id]
39 | 			return true
40 | 		}
41 | 
42 | 		return false
43 | 	}
44 | 
45 | 	return {
46 | 		array,
47 | 		get,
48 | 		upsert,
49 | 		update,
50 | 		remove,
51 | 		updateAssign: (id: string, update: Partial) => {
52 | 			const item = get(id)
53 | 			if(item) {
54 | 				Object.assign(item, update)
55 | 				delete dict[id]
56 | 				dict[idGetter(item)] = item
57 | 				return true
58 | 			}
59 | 
60 | 			return false
61 | 		},
62 | 		clear: () => {
63 | 			array.splice(0, array.length)
64 | 			for(const key of Object.keys(dict)) {
65 | 				delete dict[key]
66 | 			}
67 | 		},
68 | 		filter: (contain: (item: T) => boolean) => {
69 | 			let i = 0
70 | 			while(i < array.length) {
71 | 				if(!contain(array[i])) {
72 | 					delete dict[idGetter(array[i])]
73 | 					array.splice(i, 1)
74 | 				} else {
75 | 					i += 1
76 | 				}
77 | 			}
78 | 		},
79 | 		toJSON: () => array,
80 | 		fromJSON: (newItems: T[]) => {
81 | 			array.splice(0, array.length, ...newItems)
82 | 		}
83 | 	}
84 | }
85 | 
86 | export default makeOrderedDictionary


--------------------------------------------------------------------------------
/src/store/object-repository.ts:
--------------------------------------------------------------------------------
 1 | export class ObjectRepository {
 2 | 	readonly entityMap: Map
 3 | 
 4 | 	constructor(entities: Record = {}) {
 5 | 		this.entityMap = new Map(Object.entries(entities))
 6 | 	}
 7 | 
 8 | 	findById(id: string) {
 9 | 		return this.entityMap.get(id)
10 | 	}
11 | 
12 | 	findAll() {
13 | 		return Array.from(this.entityMap.values())
14 | 	}
15 | 
16 | 	upsertById(id: string, entity: T) {
17 | 		return this.entityMap.set(id, { ...entity })
18 | 	}
19 | 
20 | 	deleteById(id: string) {
21 | 		return this.entityMap.delete(id)
22 | 	}
23 | 
24 | 	count() {
25 | 		return this.entityMap.size
26 | 	}
27 | 
28 | 	toJSON() {
29 | 		return this.findAll()
30 | 	}
31 | 
32 | }


--------------------------------------------------------------------------------
/src/waker.ts:
--------------------------------------------------------------------------------
 1 | import dotenv from 'dotenv'
 2 | dotenv.config({ path: process.env.DOTENV_CONFIG_PATH || '.env' })
 3 | 
 4 | import {
 5 |   UNOAPI_EXCHANGE_BRIDGE_NAME,
 6 |   UNOAPI_EXCHANGE_BROKER_NAME,
 7 |   UNOAPI_QUEUE_INCOMING,
 8 |   UNOAPI_QUEUE_LISTENER,
 9 |   UNOAPI_QUEUE_OUTGOING,
10 | } from './defaults'
11 | import { Channel, ConsumeMessage } from 'amqplib'
12 | 
13 | import logger from './services/logger'
14 | import { queueDeadName, amqpConnect, amqpPublish, extractRoutingKeyFromBindingKey, ExchagenType } from './amqp'
15 | 
16 | logger.info('Starting with waker...')
17 | 
18 | const brokerQueues = [UNOAPI_QUEUE_OUTGOING]
19 | const bridgeQueues = [UNOAPI_QUEUE_LISTENER, UNOAPI_QUEUE_INCOMING]
20 | 
21 | const queues = bridgeQueues.concat(brokerQueues)
22 | 
23 | const getExchangeName = queue => {
24 |   if (bridgeQueues.includes(queue)) {
25 |     return UNOAPI_EXCHANGE_BRIDGE_NAME
26 |   } else if (brokerQueues.includes(queue)) {
27 |     return UNOAPI_EXCHANGE_BROKER_NAME
28 |   } else {
29 |     throw `Unknow queue ${queue}`
30 |   }
31 | }
32 | 
33 | (async () => {
34 |   return Promise.all(
35 |     queues.map(async queue => {
36 |       const connection =  await amqpConnect()
37 |       const queueName = queueDeadName(queue)
38 |       const exchangeName = queueDeadName(getExchangeName(queue))
39 |       const exchangeType = 'topic'
40 |       logger.info('Waker exchange %s queue %s type %s', exchangeName, queueName, exchangeType)
41 |       const channel: Channel = await connection.createChannel()
42 |       await channel.assertExchange(exchangeName, exchangeType, { durable: true })
43 |       await channel.assertQueue(queueName, { durable: true })
44 |       await channel.bindQueue(queueName, exchangeName)
45 |       channel.consume(queueName, async (payload: ConsumeMessage | null) => {
46 |         if (!payload) {
47 |           throw 'payload not be null'
48 |         }
49 |         await amqpPublish(
50 |           exchangeName, 
51 |           queue, 
52 |           extractRoutingKeyFromBindingKey(payload.fields.routingKey),
53 |           JSON.parse(payload.content.toString()),
54 |           { type: exchangeType }
55 |         )
56 |         return channel.ack(payload)
57 |       })
58 |       await channel.unbindQueue(queueName, exchangeName)
59 |     })
60 |   )
61 |   // process.exit(1)
62 | })()
63 |   
64 | process.on('unhandledRejection', (reason: any, promise) => {
65 |   logger.error('unhandledRejection: %s', reason.stack)
66 |   logger.error('promise: %s', promise)
67 |   throw reason
68 | })
69 | 


--------------------------------------------------------------------------------
/src/web.ts:
--------------------------------------------------------------------------------
 1 | import * as dotenv from 'dotenv'
 2 | dotenv.config()
 3 | 
 4 | import { App } from './app'
 5 | import { Incoming } from './services/incoming'
 6 | import { IncomingAmqp } from './services/incoming_amqp'
 7 | import { Outgoing } from './services/outgoing'
 8 | import { OutgoingAmqp } from './services/outgoing_amqp'
 9 | import { SessionStore } from './services/session_store'
10 | import { SessionStoreRedis } from './services/session_store_redis'
11 | import { 
12 |   BASE_URL, 
13 |   PORT,
14 |   CONFIG_SESSION_PHONE_CLIENT,
15 |   CONFIG_SESSION_PHONE_NAME,
16 |   UNOAPI_QUEUE_BROADCAST,
17 |   UNOAPI_EXCHANGE_BROKER_NAME,
18 |   UNOAPI_QUEUE_RELOAD,
19 | } from './defaults'
20 | import { getConfigRedis } from './services/config_redis'
21 | import security from './services/security'
22 | import { amqpConsume } from './amqp'
23 | import logger from './services/logger'
24 | import { version } from '../package.json'
25 | import { onNewLoginGenerateToken } from './services/on_new_login_generate_token'
26 | import { addToBlacklistJob } from './services/blacklist'
27 | import { Broadcast } from './services/broadcast'
28 | import { BroacastJob } from './jobs/broadcast'
29 | import { ReloadAmqp } from './services/reload_amqp'
30 | import { LogoutAmqp } from './services/logout_amqp'
31 | import { Reload } from './services/reload'
32 | 
33 | const reload = new Reload()
34 | const incoming: Incoming = new IncomingAmqp(getConfigRedis)
35 | const outgoing: Outgoing = new OutgoingAmqp(getConfigRedis)
36 | const sessionStore: SessionStore = new SessionStoreRedis()
37 | const onNewLogin = onNewLoginGenerateToken(outgoing)
38 | const broadcast: Broadcast = new Broadcast()
39 | const reloadAmqp = new ReloadAmqp(getConfigRedis)
40 | const logout = new LogoutAmqp(getConfigRedis)
41 | import { ReloadJob } from './jobs/reload'
42 | const reloadJob = new ReloadJob(reloadAmqp)
43 | 
44 | const app: App = new App(incoming, outgoing, BASE_URL, getConfigRedis, sessionStore, onNewLogin, addToBlacklistJob, reloadAmqp, logout, security)
45 | broadcast.setSever(app.socket)
46 | 
47 | const broadcastJob = new BroacastJob(broadcast)
48 | 
49 | app.server.listen(PORT, '0.0.0.0', async () => {
50 |   logger.info('Starting broadcast consumer')
51 |   await amqpConsume(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_BROADCAST, '*', broadcastJob.consume.bind(broadcastJob), { type: 'topic' })
52 |   await amqpConsume(UNOAPI_EXCHANGE_BROKER_NAME, UNOAPI_QUEUE_RELOAD, '*', reload.run.bind(reloadJob), { type: 'topic' })
53 |   logger.info('Unoapi Cloud version: %s, listening on port: %s | Linked Device: %s(%s)', version, PORT, CONFIG_SESSION_PHONE_CLIENT, CONFIG_SESSION_PHONE_NAME)
54 | })
55 | 


--------------------------------------------------------------------------------
/src/worker.ts:
--------------------------------------------------------------------------------
1 | import './bridge'
2 | import './broker'
3 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "exclude": ["./coverage", "./dist", "__tests__", "jest.config.js"],
 3 |   "compilerOptions": {
 4 |     "target": "esnext",
 5 |     "module": "NodeNext",
 6 |     "moduleResolution": "nodenext",
 7 |     "experimentalDecorators": true,
 8 |     "allowJs": false,
 9 |     "checkJs": false,
10 |     "outDir": "dist",
11 |     "strict": false,
12 |     "strictNullChecks": true,
13 |     "skipLibCheck": true,
14 |     "noImplicitThis": true,
15 |     "esModuleInterop": true,
16 |     "declaration": true,
17 |     "resolveJsonModule": true,
18 |     "lib": ["es2020", "esnext.array", "DOM", "ES2021.String"]
19 |   },
20 |   "include": ["src/**/*.ts", "src/**/*.json"],
21 |   "ts-node": {
22 |     "compilerOptions": {
23 |       "esModuleInterop": true,
24 |     }
25 |   }
26 | }
27 | 


--------------------------------------------------------------------------------