Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fdfce3944 | ||
|
|
1e12a83cdf | ||
|
|
1c41b8be65 | ||
|
|
f72ee7c1f8 | ||
|
|
e75912435b | ||
|
|
da294a58a6 | ||
|
|
7689199e11 | ||
|
|
324ab11f09 | ||
|
|
b1f2e17e3d | ||
|
|
90822cd582 | ||
|
|
9eec72b641 | ||
|
|
050c652480 | ||
|
|
c44680c987 | ||
|
|
9394509a07 | ||
|
|
63890dc499 | ||
|
|
e2330901e4 | ||
|
|
58672fa1e1 | ||
|
|
3890071f77 | ||
|
|
d60dfba90a | ||
|
|
4e58713e7e | ||
|
|
6f0af3c7cc | ||
|
|
23bac2e7b1 | ||
|
|
e839203dbd | ||
|
|
304b0037ec | ||
|
|
482dbbeb0e | ||
|
|
8f4c79baf4 | ||
|
|
31038a9318 | ||
|
|
86fde1f670 | ||
|
|
984ef0c6a1 | ||
|
|
744ee1abb1 | ||
|
|
47f4f2670d | ||
|
|
f16eaed935 | ||
|
|
dfe9454a72 | ||
|
|
4aef38ff04 | ||
|
|
ab8de51022 | ||
|
|
c001d7fed6 | ||
|
|
cc056dca9c | ||
|
|
47f60a1626 | ||
|
|
98721051da | ||
|
|
5d7a6f4ff7 | ||
|
|
dcb7bf033f | ||
|
|
7d29be2d69 | ||
|
|
41b669a35c | ||
|
|
7b5ca3bc57 | ||
|
|
bb0c7fd22b | ||
|
|
c79958541e | ||
|
|
94ee8863d6 | ||
|
|
011477262d | ||
|
|
2e8e4cefb0 | ||
|
|
5e2802c098 | ||
|
|
9a6250a055 | ||
|
|
f8cb56525a | ||
|
|
b5b491cb7b | ||
|
|
7eb6be70f9 | ||
|
|
0de8a5fabc | ||
|
|
9fbc18fb70 | ||
|
|
e7ce3368b0 | ||
|
|
729600e9c6 | ||
|
|
54886f385b | ||
|
|
81854ad7ec | ||
|
|
5261d4f281 | ||
|
|
72d9d94342 | ||
|
|
b782ad27a2 | ||
|
|
d7dad5e6c2 | ||
|
|
8765bb4412 | ||
|
|
984be25d51 | ||
|
|
70620207dd | ||
|
|
a96200f981 | ||
|
|
4978abf3d8 | ||
|
|
a67d84f21e | ||
|
|
91f59b38fd | ||
|
|
d0ec351910 | ||
|
|
a7b12874fb | ||
|
|
cd2c7bdd86 | ||
|
|
045077143f | ||
|
|
bcd2199687 | ||
|
|
af108eede4 | ||
|
|
ee1cf838b8 | ||
|
|
0fc74895da | ||
|
|
394937c154 | ||
|
|
71f9adeff4 | ||
|
|
e13ccdab9c | ||
|
|
2074748a7a | ||
|
|
283d63394e | ||
|
|
5cfb53c367 | ||
|
|
9466407b8a | ||
|
|
4b65f0c462 | ||
|
|
23504b35c8 | ||
|
|
dd6ec4ca3a | ||
|
|
8235e5a111 | ||
|
|
55499a6144 | ||
|
|
9dbc4a6085 | ||
|
|
f7c6ce847c | ||
|
|
84eed00415 | ||
|
|
51aa0e3154 | ||
|
|
77893a0eb0 | ||
|
|
4a866d8a08 | ||
|
|
f912353a5c | ||
|
|
a6a1d8dc28 | ||
|
|
150e73aa74 | ||
|
|
a9300b3134 | ||
|
|
bbe3859085 | ||
|
|
b420548dd9 | ||
|
|
741483eec7 | ||
|
|
2acf7e4e17 | ||
|
|
9bca6d904d | ||
|
|
857c586df6 | ||
|
|
df8cb7cecb | ||
|
|
9d0050adb8 | ||
|
|
df927ab04a | ||
|
|
02e5934e8f | ||
|
|
825a4152db | ||
|
|
81b10099af | ||
|
|
3be5f328b0 | ||
|
|
5caa3b8b81 | ||
|
|
8231bf7d8e | ||
|
|
22d94808fb | ||
|
|
57daefa680 | ||
|
|
5cf8704973 | ||
|
|
28da9c34bb | ||
|
|
51ba5e3266 | ||
|
|
f696794ee1 | ||
|
|
c6eca9510b | ||
|
|
9a2fd2fccd | ||
|
|
c0a2d02f0d | ||
|
|
6493d16fd2 | ||
|
|
9f96c678dc | ||
|
|
a81f2ab553 | ||
|
|
8fbf7fa0e5 | ||
|
|
af0bba313a | ||
|
|
76307dec12 | ||
|
|
0b441054ae | ||
|
|
2e8b78aebc | ||
|
|
d01b221a24 | ||
|
|
61bb111768 | ||
|
|
de47d6e445 | ||
|
|
59fe4b30f0 | ||
|
|
166dae8e27 | ||
|
|
5ce64fd30d | ||
|
|
1a4513f437 | ||
|
|
59be4a780f | ||
|
|
74001c2b49 | ||
|
|
a9ce0fb2d9 | ||
|
|
4c3fd1c214 | ||
|
|
a9459fe746 | ||
|
|
5a3228f05b | ||
|
|
befc238db9 | ||
|
|
ae75a1f8d4 | ||
|
|
d1c50bfd73 | ||
|
|
6f540366f4 | ||
|
|
151fc760fe | ||
|
|
be4391a4b6 | ||
|
|
76ae3e0f6b | ||
|
|
f37b6edaa6 | ||
|
|
7095f77f93 | ||
|
|
5853b778f2 | ||
|
|
6405725ce6 | ||
|
|
111d6141aa | ||
|
|
5560b3c8e6 | ||
|
|
d528d541ea | ||
|
|
ab6b51cc1f | ||
|
|
35aa4f2658 | ||
|
|
36db3c37e8 | ||
|
|
b0c9232153 | ||
|
|
0070fb9e5c | ||
|
|
aa33ac4b1d | ||
|
|
1c2bc5b395 | ||
|
|
f2fe18a4c5 | ||
|
|
87fdcbebf6 | ||
|
|
68a4f651ea | ||
|
|
243caa9626 | ||
|
|
863684b916 | ||
|
|
2d543f3728 | ||
|
|
47088cfbee | ||
|
|
66c75cbf79 | ||
|
|
b2c68d88ae | ||
|
|
675e998cba | ||
|
|
db1d20ae36 | ||
|
|
59b40dda4e | ||
|
|
317fab4dfe | ||
|
|
6c83ab68b4 | ||
|
|
2194465757 | ||
|
|
a0e70f88ad | ||
|
|
9102a3d852 | ||
|
|
b05cba896e | ||
|
|
6716a31b6c | ||
|
|
2e42d0e7eb | ||
|
|
dee9864157 | ||
|
|
0293fc8c6f | ||
|
|
725fdc03a6 | ||
|
|
4557cb695f | ||
|
|
172403a061 | ||
|
|
d03c3c185e | ||
|
|
50a111343c | ||
|
|
f01693adc3 | ||
|
|
d2bbbb4448 | ||
|
|
38c0aadff2 | ||
|
|
79d593b155 | ||
|
|
ddc84cfecc | ||
|
|
b620383d94 | ||
|
|
2319dba68d | ||
|
|
0c388ee3a7 | ||
|
|
fb5b3e2964 | ||
|
|
d44eaa5b41 | ||
|
|
2eaf05df27 | ||
|
|
9e5431e205 | ||
|
|
26c3102af8 | ||
|
|
104f06ef2b | ||
|
|
93c8654e84 | ||
|
|
a7261790bd | ||
|
|
d8d6e521f6 | ||
|
|
02497bcd40 | ||
|
|
b498e15620 | ||
|
|
1288d312c0 | ||
|
|
84cd350c42 | ||
|
|
100bf89074 | ||
|
|
d66c80f95a | ||
|
|
26f04bbd94 | ||
|
|
6582c03823 | ||
|
|
abacaf2e02 | ||
|
|
c65d8b9856 | ||
|
|
8f94cf06de | ||
|
|
94c1c16de8 | ||
|
|
e69d992725 | ||
|
|
e6c5dd6e22 | ||
|
|
5486d4874a | ||
|
|
9d9d8e03fe | ||
|
|
6ee50243a2 | ||
|
|
3ec078f9c1 | ||
|
|
4c3209be6d | ||
|
|
7fe8f9a8dc | ||
|
|
7fa2b29418 | ||
|
|
dd4edd5a85 | ||
|
|
b85df5a0d3 | ||
|
|
1a7bec3d4e | ||
|
|
727c4b454f | ||
|
|
4ba86b34a9 | ||
|
|
277a35aed9 | ||
|
|
28c1ffa09f | ||
|
|
10595680a9 | ||
|
|
577298dbc5 | ||
|
|
49ca4dfbf7 | ||
|
|
89ec47deec | ||
|
|
a043a46457 | ||
|
|
eb8e3a0fd1 | ||
|
|
fc25df8104 | ||
|
|
d8ffd3e543 | ||
|
|
ea4ec9310d | ||
|
|
479ec85e0d | ||
|
|
3e4b450260 | ||
|
|
f6a81949d0 | ||
|
|
6896962606 | ||
|
|
9b6d6ab4b0 |
No files matched your search
@@ -10,6 +10,8 @@ docs
|
||||
examples
|
||||
.github
|
||||
.git
|
||||
.envs/*
|
||||
.env
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
|
||||
+17
-13
@@ -3,31 +3,35 @@
|
||||
# https://waha.devlike.pro/docs/how-to/config/
|
||||
#
|
||||
|
||||
# ====================
|
||||
# ===== SECURITY =====
|
||||
# ====================
|
||||
WAHA_API_KEY=00000000000000000000000000000000
|
||||
WAHA_DASHBOARD_USERNAME=admin
|
||||
WAHA_DASHBOARD_PASSWORD=11111111111111111111111111111111
|
||||
WHATSAPP_SWAGGER_USERNAME=admin
|
||||
WHATSAPP_SWAGGER_PASSWORD=11111111111111111111111111111111
|
||||
|
||||
# Disable Dashboard or Swagger
|
||||
WAHA_DASHBOARD_ENABLED=True
|
||||
WHATSAPP_SWAGGER_ENABLED=True
|
||||
|
||||
# ==================
|
||||
# ===== COMMON =====
|
||||
# ==================
|
||||
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
|
||||
WHATSAPP_DEFAULT_ENGINE=WEBJS
|
||||
|
||||
# Base URL for the API (used for webhooks, file URLs, etc.)
|
||||
WAHA_BASE_URL=http://localhost:3000
|
||||
# WAHA_PUBLIC_URL=http://localhost:3000
|
||||
|
||||
# Server configuration (if you need to customize hostname/port)
|
||||
# WHATSAPP_API_SCHEMA=http
|
||||
# WHATSAPP_API_PORT=3000
|
||||
# WHATSAPP_API_HOSTNAME=localhost
|
||||
|
||||
# ====================
|
||||
# ===== SECURITY =====
|
||||
# ====================
|
||||
# "sha512:{SHA}" format - below is "admin" api key
|
||||
WAHA_API_KEY=sha512:c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec
|
||||
WAHA_DASHBOARD_ENABLED=True
|
||||
WAHA_DASHBOARD_USERNAME=admin
|
||||
WAHA_DASHBOARD_PASSWORD=admin
|
||||
WHATSAPP_SWAGGER_ENABLED=True
|
||||
WHATSAPP_SWAGGER_USERNAME=admin
|
||||
WHATSAPP_SWAGGER_PASSWORD=admin
|
||||
|
||||
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
|
||||
WHATSAPP_DEFAULT_ENGINE=WEBJS
|
||||
|
||||
# ===================
|
||||
# ===== LOGGING =====
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
src/core/engines/gows/grpc/*
|
||||
src/core/engines/gows/proto/*
|
||||
src/core/engines/webjs/_lodash.js
|
||||
@@ -1,36 +0,0 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'simple-import-sort'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
'prettier/@typescript-eslint',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
rules: {
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/ban-ts-ignore': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'lines-between-class-members': [
|
||||
'error',
|
||||
'always',
|
||||
{ exceptAfterSingleLine: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -129,6 +129,7 @@ jobs:
|
||||
docker run -d \
|
||||
--name smoke \
|
||||
--rm -p3000:3000 \
|
||||
-e WAHA_API_KEY=test \
|
||||
${{ vars.DOCKER_IMAGE }}:${{ matrix.tag }}
|
||||
|
||||
sleep 3
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Dev
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
if: github.repository == 'devlikeapro/waha-plus'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
name:
|
||||
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
||||
${{ matrix.tag }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
||||
tag: 'dev'
|
||||
platform: 'amd64'
|
||||
browser: 'chromium'
|
||||
engine: 'WEBJS'
|
||||
goss: 'goss-linux-amd64'
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
|
||||
tag: 'dev-arm'
|
||||
platform: 'linux/arm64'
|
||||
browser: 'chromium'
|
||||
engine: 'WEBJS'
|
||||
goss: 'goss-linux-arm'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check recent changes
|
||||
id: recent
|
||||
run: |
|
||||
count=$(git rev-list --after="24 hours ago" --count HEAD || true)
|
||||
if [ "$count" -gt 0 ]; then
|
||||
echo "has_recent=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_recent=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: No recent changes summary
|
||||
if: ${{ steps.recent.outputs.has_recent != 'true' }}
|
||||
run:
|
||||
echo "No commits in the last 24 hours; skipping build." >>
|
||||
"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Docker meta
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ vars.DOCKER_IMAGE }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.tag }}
|
||||
|
||||
- name: Login to image repository
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Install goss
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
env:
|
||||
GOSS_DOWNLOAD: 'https://github.com/goss-org/goss/releases/download'
|
||||
GOSS_VERSION: 'v0.4.8'
|
||||
run: |
|
||||
curl -L https://github.com/goss-org/goss/releases/download/${{ env.GOSS_VERSION }}/${{ matrix.goss }} -o /usr/local/bin/goss
|
||||
chmod +rx /usr/local/bin/goss
|
||||
|
||||
- name: Run smoke tests
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
working-directory: tests/smoke
|
||||
timeout-minutes: 2
|
||||
run: |
|
||||
docker run -d \
|
||||
--name smoke \
|
||||
--rm -p3000:3000 \
|
||||
-e WAHA_API_KEY=test \
|
||||
${{ vars.DOCKER_IMAGE }}:${{ matrix.tag }}
|
||||
|
||||
sleep 3
|
||||
docker logs smoke
|
||||
goss validate --retry-timeout 30s --sleep 1s
|
||||
docker rm --force smoke
|
||||
|
||||
- name: Push
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,51 @@
|
||||
name: 'github-translate'
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
discussion:
|
||||
types: [created]
|
||||
discussion_comment:
|
||||
types: [created]
|
||||
# pull_request_target:
|
||||
# types: [opened, edited]
|
||||
# pull_request_review_comment:
|
||||
# types: [created, edited]
|
||||
|
||||
jobs:
|
||||
en:
|
||||
if: github.repository == 'devlikeapro/waha'
|
||||
permissions:
|
||||
issues: write
|
||||
discussions: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: devlikeapro/github-translate-action@main
|
||||
env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
IS_MODIFY_TITLE: true
|
||||
CUSTOM_BOT_NOTE: 'English'
|
||||
TARGET_LANGUAGE: 'eng'
|
||||
TRANSLATED_LANGUAGE: 'en'
|
||||
# pt:
|
||||
# permissions:
|
||||
# issues: write
|
||||
# discussions: write
|
||||
# pull-requests: write
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: devlikeapro/github-translate-action@main
|
||||
# env:
|
||||
# # GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# with:
|
||||
# IS_MODIFY_TITLE: false
|
||||
# CUSTOM_BOT_NOTE: 'Portuguese'
|
||||
# TARGET_LANGUAGE: 'por'
|
||||
# TRANSLATED_LANGUAGE: 'pt'
|
||||
@@ -1,5 +1,6 @@
|
||||
src/core/engines/gows/proto
|
||||
.env
|
||||
.envs/*
|
||||
*.heapsnapshot
|
||||
.yarn
|
||||
example.ts
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript"],
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-inferrable-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-unused-vars": "off"
|
||||
}
|
||||
}
|
||||
+7
-11
@@ -17,18 +17,12 @@ repos:
|
||||
language: python
|
||||
stages: [commit-msg]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: lint
|
||||
stages: [pre-commit]
|
||||
name: lint
|
||||
- id: oxlint
|
||||
name: oxlint
|
||||
entry: npx oxlint --fix --deny-warnings
|
||||
language: system
|
||||
entry: bash -c '$HOME/.nvm/nvm-exec npm run lint-cache'
|
||||
pass_filenames: false
|
||||
files: |
|
||||
(?x)^(
|
||||
src/.*|
|
||||
)$
|
||||
pass_filenames: true
|
||||
files: \.(ts|tsx|js|jsx)$
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: 'v3.1.0'
|
||||
@@ -73,9 +67,11 @@ repos:
|
||||
exclude: |
|
||||
(?x)^(
|
||||
entrypoint.sh|
|
||||
AGENTS.md|
|
||||
.pre-commit-config.yaml|
|
||||
^src/core/engines/webjs/session.webjs.core.ts|
|
||||
^src/core/engines/webjs/_lodash.js|
|
||||
^tests/perf/.*|
|
||||
^scripts/.*|
|
||||
^scripts/init-waha.js|
|
||||
)$
|
||||
@@ -0,0 +1,124 @@
|
||||
# WAHA Agent Playbook
|
||||
|
||||
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
|
||||
(WAHA) codebase when assisting as an automation or coding agent.
|
||||
|
||||
## Product & Variants
|
||||
|
||||
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
|
||||
only supports the default session plus minimal media features. Plus extends
|
||||
core via `src/plus` to add multi-session orchestration, richer media handling,
|
||||
and external storage integrations.
|
||||
- Core code must remain free from Plus-only references. A pre-commit hook
|
||||
rejects the word `plus` inside core files; reuse abstractions exposed through
|
||||
`@waha/core/**` instead of importing Plus modules from Core.
|
||||
- Commit subjects are validated: changes that touch `src/plus` require a
|
||||
`[PLUS] …` prefix and must not include non-Plus files; everything else must
|
||||
use `[core] …`. Verify against `./.precommit/validate_commit_message.py` if
|
||||
unsure.
|
||||
|
||||
## Tech Stack Snapshot
|
||||
|
||||
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry). Always install packages and run
|
||||
scripts with Yarn.
|
||||
- **Framework**: NestJS v11 with dependency injection, modular controllers in
|
||||
`src/api`, and Pino-based logging via `nestjs-pino`.
|
||||
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`). Core
|
||||
uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus` with extra
|
||||
storage backends (Mongo/Postgres/SQLite).
|
||||
- **ESM Bridge**: ESM-only dependencies (Baileys) load through
|
||||
`src/vendor/esm.ts`. Add new ESM modules there to ensure they load exactly
|
||||
once.
|
||||
- **Utilities**: RxJS streams (`SwitchObservable`, `DefaultMap`) drive webhook
|
||||
event fan-out. Prefer existing helpers in `src/utils` and `src/core/utils`
|
||||
before adding bespoke logic.
|
||||
|
||||
## Repository Landmarks
|
||||
|
||||
- `src/main.ts`: runtime entry point; dynamically loads the correct AppModule
|
||||
(Core vs Plus) and configures global interceptors/filters.
|
||||
- `src/api/**`: REST controllers and WebSocket gateway. Keep handlers thin;
|
||||
delegate to managers/services. HTTP routes follow `/api/{sessionName}/…`;
|
||||
always thread the session name through request DTOs and guards instead of
|
||||
hardcoding `default`.
|
||||
- `src/core/**`: shared abstractions (config services, engine bootstrap,
|
||||
storage, session management). Core only allows the `default` session and
|
||||
cleans up storage on boot.
|
||||
- `src/plus/**`: multi-session orchestration, advanced media services, and
|
||||
external persistence layers (Mongo, Postgres). Reuse this layer when adding
|
||||
Plus-only capabilities.
|
||||
- `src/apps/**`: integrations (e.g., ChatWoot) and application-specific
|
||||
services.
|
||||
- `src/structures/**` and `src/utils/**`: DTOs, enums (event names follow
|
||||
`domain.action`), helper utilities. Maintain naming consistency when
|
||||
introducing new events.
|
||||
- `tests/**`: Jest-based suites; do not add new tests unless explicitly asked,
|
||||
but keep existing tests working.
|
||||
|
||||
## Coding Expectations
|
||||
|
||||
- Favor composable, long-lived solutions. If a helper already exists (e.g.,
|
||||
`parseBool`, `DefaultMap`, media factories), extend it instead of reinventing
|
||||
logic. Lodash ships with the project—prefer its utilities over hand-rolled
|
||||
helpers. Import lodash as a namespace (`import * as lodash from 'lodash';`) so
|
||||
helpers like `lodash.camelCase` stay consistent across files. When a new
|
||||
third-party library seems necessary, confirm with the user, especially if the
|
||||
package looks stale.
|
||||
- Stick to NestJS patterns: inject dependencies through constructors, expose
|
||||
provider tokens from modules, and keep controllers free from business logic.
|
||||
- Logging goes through injected `PinoLogger` or helpers in
|
||||
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
|
||||
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
|
||||
consistent (use absolute aliases, not relative `../../../`).
|
||||
- Avoid naming unused variables with a leading underscore; if a parameter is
|
||||
required by a signature, explicitly `void` it instead.
|
||||
- For configs, prefer runtime configurability over constants. Environment keys
|
||||
follow `WAHA_*` for global values and `WAHA_SESSION_CONFIG_*` /
|
||||
`session.config.*` for per-session overrides. If both env and config are
|
||||
supported, honor both (`WAHA_WEBJS_CONFIG_*` vs. `session.webjs.config.*`).
|
||||
|
||||
## Language & Localization
|
||||
|
||||
- Keep identifiers (classes, methods, variables) and code comments in English so
|
||||
the codebase stays consistent for the global team.
|
||||
- Route user-visible strings through the existing i18n structure rather than
|
||||
hardcoding text. ChatWoot copy belongs in `src/apps/chatwoot/i18n` with
|
||||
English as the source locale before adding translations.
|
||||
|
||||
## ChatWoot Integration Notes
|
||||
|
||||
- Avoid introducing synthetic events; map onto existing webhook or engine events
|
||||
whenever plausible. Always use the official ChatWoot API client located in
|
||||
`src/apps/chatwoot/client`.
|
||||
- When adding events, align consumer names with webhook/event identifiers (e.g.,
|
||||
`message.any`).
|
||||
|
||||
## Workflow Checklist
|
||||
|
||||
1. Identify whether work targets Core, Plus, or shared layers. If Plus-only,
|
||||
isolate changes to `src/plus` and ensure the commit prefix matches.
|
||||
2. Lean on existing services/managers; extend the appropriate session manager
|
||||
rather than branching logic inline.
|
||||
3. After edits run:
|
||||
- `pre-commit run --all-files`
|
||||
- `yarn build`
|
||||
- `yarn test` Use Node 22. Address lint or formatting issues before
|
||||
proceeding.
|
||||
4. Do **not** start the application yourself; ask the user to run it if runtime
|
||||
validation is required.
|
||||
5. Capture any assumptions or open questions for the user, especially when
|
||||
touching configs, introducing dependencies, or modifying public APIs.
|
||||
|
||||
## Additional Tips
|
||||
|
||||
- Concurrency-sensitive sections (session start/stop) rely on `async-lock`. When
|
||||
adding async flows, reuse `SessionManager.withLock` or existing retry
|
||||
utilities (`promiseTimeout`, `waitUntil`).
|
||||
- Media features centralize through `MediaManager` and `MediaStorageFactory`.
|
||||
When altering media behavior, update both Core and Plus variants where
|
||||
applicable.
|
||||
- Keep docs and code ASCII unless a file already uses other characters. When
|
||||
updating documentation, mirror the concise, actionable tone used here.
|
||||
|
||||
Following this playbook keeps contributions aligned with WAHA’s structure,
|
||||
automation hooks, and release process.
|
||||
+6
-1
@@ -162,7 +162,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
|
||||
# Install Chrome
|
||||
# Available versions:
|
||||
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
|
||||
ARG CHROME_VERSION="137.0.7151.103-1"
|
||||
ARG CHROME_VERSION="140.0.7339.80-1"
|
||||
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \
|
||||
&& apt-get update \
|
||||
@@ -195,6 +195,11 @@ COPY --from=build /git/node_modules ./node_modules
|
||||
COPY --from=build /git/dist ./dist
|
||||
COPY --from=dashboard /dashboard ./dist/dashboard
|
||||
COPY --from=gows /go/gows/bin/gows /app/gows
|
||||
COPY .env.example ./.env.example
|
||||
COPY scripts/init-waha.js ./scripts/init-waha.js
|
||||
RUN chmod +x ./scripts/init-waha.js \
|
||||
&& printf '%s\n' '#!/bin/sh' 'exec node /app/scripts/init-waha.js "$@"' > /usr/local/bin/init-waha \
|
||||
&& chmod +x /usr/local/bin/init-waha
|
||||
ENV WAHA_GOWS_PATH=/app/gows
|
||||
ENV WAHA_GOWS_SOCKET=/tmp/gows.sock
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@ for-swagger:
|
||||
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true . ${NVM_DIR}/nvm.sh && nvm exec yarn start
|
||||
|
||||
up-noweb:
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-07-04
|
||||
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-11-07
|
||||
|
||||
up-noweb-libsignal:
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
|
||||
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
|
||||
|
||||
up-webjs:
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-06-09
|
||||
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-11-05
|
||||
|
||||
start-proxy:
|
||||
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ services:
|
||||
# NOTE: Only if you're using PostgreSQL to save sessions
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#media---postgresql
|
||||
# postgres:
|
||||
# image: postgres:17
|
||||
# image: postgres:17.6
|
||||
# restart: always
|
||||
# environment:
|
||||
# POSTGRES_USER: postgres
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Learn about the various environment variables at
|
||||
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
|
||||
|
||||
# Increase rack default timeout (30s) to avoid timeout errors
|
||||
# Workaround for https://github.com/devlikeapro/waha/issues/1389
|
||||
RACK_TIMEOUT_SERVICE_TIMEOUT=60
|
||||
|
||||
# Used to verify the integrity of signed cookies. so ensure a secure value is set
|
||||
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
|
||||
# Use `rake secret` to generate this variable
|
||||
@@ -216,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
# ENABLE_RACK_ATTACK=true
|
||||
# RACK_ATTACK_LIMIT=300
|
||||
# ENABLE_RACK_ATTACK_WIDGET_API=true
|
||||
CONVERSATION_MESSAGE_PER_MINUTE_LIMIT=3000
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
|
||||
@@ -3,31 +3,32 @@
|
||||
# https://waha.devlike.pro/docs/how-to/config/
|
||||
#
|
||||
|
||||
# ====================
|
||||
# ===== SECURITY =====
|
||||
# ====================
|
||||
WAHA_API_KEY_PLAIN=00000000000000000000000000000000
|
||||
WAHA_API_KEY=00000000000000000000000000000000
|
||||
WAHA_DASHBOARD_USERNAME=admin
|
||||
WAHA_DASHBOARD_PASSWORD=11111111111111111111111111111111
|
||||
WHATSAPP_SWAGGER_USERNAME=admin
|
||||
WHATSAPP_SWAGGER_PASSWORD=11111111111111111111111111111111
|
||||
|
||||
# Disable Dashboard or Swagger
|
||||
WAHA_DASHBOARD_ENABLED=True
|
||||
WHATSAPP_SWAGGER_ENABLED=True
|
||||
|
||||
# ==================
|
||||
# ===== COMMON =====
|
||||
# ==================
|
||||
# Base URL for the API (used for webhooks, file URLs, etc.)
|
||||
WAHA_BASE_URL=http://waha:3000
|
||||
WAHA_PUBLIC_URL=http://localhost:3000
|
||||
|
||||
# Server configuration (if you need to customize hostname/port)
|
||||
# WHATSAPP_API_SCHEMA=http
|
||||
# WHATSAPP_API_PORT=3000
|
||||
# WHATSAPP_API_HOSTNAME=localhost
|
||||
|
||||
# ====================
|
||||
# ===== SECURITY =====
|
||||
# ====================
|
||||
# WAHA Apps requires both sha and plain version
|
||||
WAHA_API_KEY_PLAIN=00000000000000000000000000000000
|
||||
WAHA_API_KEY=sha512:98b6d128682e280b74b324ca82a6bae6e8a3f7174e0605bfd52eb9948fad8984854ec08f7652f32055c4a9f12b69add4850481d9503a7f2225501671d6124648
|
||||
WAHA_DASHBOARD_USERNAME=admin
|
||||
WAHA_DASHBOARD_PASSWORD=11111111111111111111111111111111
|
||||
WHATSAPP_SWAGGER_USERNAME=admin
|
||||
WHATSAPP_SWAGGER_PASSWORD=11111111111111111111111111111111
|
||||
|
||||
WAHA_DASHBOARD_ENABLED=True
|
||||
WHATSAPP_SWAGGER_ENABLED=True
|
||||
|
||||
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
|
||||
WHATSAPP_DEFAULT_ENGINE=GOWS
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ services:
|
||||
- 8.8.8.8
|
||||
|
||||
base: &base
|
||||
image: chatwoot/chatwoot:v4.3.0
|
||||
image: chatwoot/chatwoot:v4.7.0
|
||||
env_file: .chatwoot.env ## Change this file for customized env variables
|
||||
volumes:
|
||||
- chatwoot_storage:/app/storage
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
logging:
|
||||
driver: 'json-file'
|
||||
options:
|
||||
|
||||
@@ -41,11 +41,6 @@ if [ -n "$key" ]; then
|
||||
# If already hashed, use it as is
|
||||
export WAHA_API_KEY="$key"
|
||||
else
|
||||
# Display warning about using plain text API key
|
||||
echo "⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️"
|
||||
echo "WARNING: Plain text API key detected. Converting to hashed format for security."
|
||||
echo "For better security, use WAHA_API_KEY=sha512:{SHA512_HASH_FOR_YOUR_API_KEY}"
|
||||
echo "⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️"
|
||||
# Hash the key using sha512sum
|
||||
HASHED_KEY=$(echo -n "$key" | sha512sum | awk '{print $1}')
|
||||
export WAHA_API_KEY="sha512:$HASHED_KEY"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
-1
@@ -7,7 +7,8 @@
|
||||
"dashboard/**",
|
||||
"core/engines/webjs/*",
|
||||
"plus/engines/webjs/*",
|
||||
"apps/chatwoot/i18n/*.yaml"
|
||||
"apps/chatwoot/i18n/locales/*.yaml",
|
||||
"apps/chatwoot/i18n/locales/*.yml"
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
|
||||
+22
-17
@@ -14,8 +14,8 @@
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:prod-exit": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix --max-warnings=0",
|
||||
"lint-cache": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix --cache --max-warnings=0",
|
||||
"lint": "oxlint --deny-warnings",
|
||||
"lint-fix": "oxlint --fix --deny-warnings",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
@@ -26,7 +26,7 @@
|
||||
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-07-04",
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-11-07",
|
||||
"@adiwajshing/keyed-db": "^0.2.4",
|
||||
"@aws-sdk/client-s3": "^3.633.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.633.0",
|
||||
@@ -65,21 +65,25 @@
|
||||
"chokidar": "^3.6.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "0.14.0",
|
||||
"commander": "^14.0.1",
|
||||
"del": "^8.0.0",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"file-type": "16.5.4",
|
||||
"fs-extra": "^11.2.0",
|
||||
"google-protobuf": "^3.21.4",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"ical.js": "^2.2.1",
|
||||
"ioredis": "^5",
|
||||
"joi": "^17.13.3",
|
||||
"knex": "^3.1.0",
|
||||
"libphonenumber-js": "^1.10.36",
|
||||
"libsignal": "github:devlikeapro/libsignal-node#fork-master",
|
||||
"libsignal": "github:WhiskeySockets/libsignal-node#master",
|
||||
"link-preview-js": "^3.0.4",
|
||||
"lodash": "^4.17.21",
|
||||
"long": "^5.3.2",
|
||||
"mime-types": "^2.1.27",
|
||||
"mongodb": "6.9.0",
|
||||
"ms": "^2.1.3",
|
||||
"mustache": "^4.2.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"node-cache": "5.1.2",
|
||||
@@ -90,17 +94,19 @@
|
||||
"pino-pretty": "^11.2.1",
|
||||
"pretty-bytes": "5.6.0",
|
||||
"promise-retry": "^2.0.1",
|
||||
"puppeteer": "^24.10.0",
|
||||
"puppeteer": "^24.19.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^4.3.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.4",
|
||||
"shell-quote": "^1.8.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"swagger-ui-express": "^4.1.4",
|
||||
"ulid": "^2.3.0",
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-06-09",
|
||||
"undici": "^7.16.0",
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-11-05",
|
||||
"write-file-atomic": "^6.0.0",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
@@ -110,9 +116,8 @@
|
||||
"resolutions": {
|
||||
"bufferutil": "^4.0.8",
|
||||
"ws": "^8.18.0",
|
||||
"puppeteer": "^24.10.0",
|
||||
"puppeteer": "^24.19.0",
|
||||
"whatwg-url": "13.0.0",
|
||||
"libsignal": "github:devlikeapro/libsignal-node#fork-master",
|
||||
"axios": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -124,24 +129,21 @@
|
||||
"@types/express": "^4.17.3",
|
||||
"@types/jest": "26.0.10",
|
||||
"@types/node": "^18.14.2",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "3.9.1",
|
||||
"@typescript-eslint/parser": "3.9.1",
|
||||
"eslint": "7.7.0",
|
||||
"eslint-config-prettier": "^6.10.0",
|
||||
"eslint-plugin-import": "^2.20.1",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"grpc-tools": "^1.13.0",
|
||||
"jest": "^29.7.0",
|
||||
"oxlint": "^1.13.0",
|
||||
"prettier": "^1.19.1",
|
||||
"protoc-gen-ts": "^0.8.7",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "^29.1.3",
|
||||
"ts-loader": "^6.2.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^4.8.4",
|
||||
"yargs": "^17.7.2"
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
@@ -150,6 +152,9 @@
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^@waha/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"testRegex": ".test.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
const { readFileSync } = require('fs');
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const YAML = require('yaml');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const enPath = path.join(repoRoot, 'src/apps/chatwoot/i18n/locales/en-US.yaml');
|
||||
const contents = readFileSync(enPath, 'utf8');
|
||||
const document = YAML.parse(contents);
|
||||
|
||||
const flatten = (node, prefix = '') => {
|
||||
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
||||
return Object.entries(node).reduce((acc, [key, value]) => {
|
||||
const next = prefix ? `${prefix}.${key}` : key;
|
||||
return acc.concat(flatten(value, next));
|
||||
}, []);
|
||||
}
|
||||
|
||||
return [prefix];
|
||||
};
|
||||
|
||||
const allKeys = flatten(document).filter(Boolean);
|
||||
|
||||
const ignoredPrefixes = ['datetime.', 'locale.'];
|
||||
|
||||
const ignored = new Set();
|
||||
const unused = [];
|
||||
|
||||
const searchRoots = ['src', 'tests'];
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (
|
||||
ignoredPrefixes.some((prefix) => key.startsWith(prefix)) ||
|
||||
!key.includes('.')
|
||||
) {
|
||||
ignored.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
'rg',
|
||||
[
|
||||
'--files-with-matches',
|
||||
'--fixed-strings',
|
||||
key,
|
||||
...searchRoots,
|
||||
'--glob',
|
||||
'!apps/chatwoot/i18n/locales/*.yaml',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status !== 0 || !result.stdout.trim()) {
|
||||
unused.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const message = [
|
||||
`Scanned ${allKeys.length} keys.`,
|
||||
`Ignored ${ignored.size} keys (datetime.*, locale.*, or without dot).`,
|
||||
unused.length
|
||||
? `Found ${unused.length} potentially unused keys:\n - ${unused.join(
|
||||
'\n - ',
|
||||
)}`
|
||||
: 'No unused keys detected.',
|
||||
];
|
||||
|
||||
console.log(message.join('\n'));
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const forceIndex = args.indexOf('--force');
|
||||
const forceOverwrite = forceIndex !== -1;
|
||||
|
||||
if (forceOverwrite) {
|
||||
args.splice(forceIndex, 1);
|
||||
}
|
||||
|
||||
const folderArg = args[0] || '.';
|
||||
const sourceArgRaw = args[1];
|
||||
const targetArg = args[2] || '.env';
|
||||
|
||||
const envDir = path.resolve(process.cwd(), folderArg);
|
||||
const sourceArg = sourceArgRaw || '.env.example';
|
||||
const templateBaseDir = sourceArgRaw ? envDir : projectRoot;
|
||||
const templatePath = path.isAbsolute(sourceArg)
|
||||
? sourceArg
|
||||
: path.resolve(templateBaseDir, sourceArg);
|
||||
const targetPath = path.isAbsolute(targetArg)
|
||||
? targetArg
|
||||
: path.resolve(envDir, targetArg);
|
||||
|
||||
function exitWithMessage(message, code = 1) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(envDir)) {
|
||||
exitWithMessage(`Target directory does not exist: ${envDir}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
exitWithMessage(`Missing template file: ${templatePath}`);
|
||||
}
|
||||
|
||||
if (!forceOverwrite && fs.existsSync(targetPath)) {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (stats.size > 0) {
|
||||
exitWithMessage(
|
||||
`${targetPath} already exists and is not empty. Remove it manually or rerun with --force to overwrite.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const exampleContent = fs.readFileSync(templatePath, 'utf8');
|
||||
|
||||
function generateSecret() {
|
||||
return crypto.randomUUID({ disableEntropyCache: true }).replace(/-/g, '');
|
||||
}
|
||||
|
||||
const apiKey = generateSecret();
|
||||
const adminPassword = generateSecret();
|
||||
|
||||
const updatedContent = exampleContent
|
||||
.replace(/^WAHA_API_KEY=.*/m, `WAHA_API_KEY=${apiKey}`)
|
||||
.replace(/^WAHA_API_KEY_PLAIN=.*/m, `WAHA_API_KEY_PLAIN=${apiKey}`)
|
||||
.replace(/^WAHA_DASHBOARD_USERNAME=.*/m, 'WAHA_DASHBOARD_USERNAME=admin')
|
||||
.replace(
|
||||
/^WAHA_DASHBOARD_PASSWORD=.*/m,
|
||||
`WAHA_DASHBOARD_PASSWORD=${adminPassword}`,
|
||||
)
|
||||
.replace(/^WHATSAPP_SWAGGER_USERNAME=.*/m, 'WHATSAPP_SWAGGER_USERNAME=admin')
|
||||
.replace(
|
||||
/^WHATSAPP_SWAGGER_PASSWORD=.*/m,
|
||||
`WHATSAPP_SWAGGER_PASSWORD=${adminPassword}`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(targetPath, updatedContent, { encoding: 'utf8' });
|
||||
|
||||
const lines = [
|
||||
'Credentials generated.',
|
||||
'',
|
||||
'Dashboard and Swagger:',
|
||||
' - Username: admin',
|
||||
` - Password: ${adminPassword}`,
|
||||
'',
|
||||
'API key: ',
|
||||
` - ${apiKey}`,
|
||||
'',
|
||||
'Use it in the Dashboard connection flow: https://waha.devlike.pro/docs/how-to/dashboard/#api-key',
|
||||
];
|
||||
|
||||
console.log(lines.join('\n'));
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isJidGroup } from '@adiwajshing/baileys';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
GetChatMessageQuery,
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
MessageSortField,
|
||||
OverviewBodyRequest,
|
||||
OverviewFilter,
|
||||
OverviewPaginationParams,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
transformAck,
|
||||
} from '../structures/chats.dto';
|
||||
import { EditMessageRequest } from '../structures/chatting.dto';
|
||||
import { SortOrder } from '@waha/structures/pagination.dto';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/chats')
|
||||
@@ -121,6 +122,11 @@ class ChatsController {
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('chatId') chatId: string,
|
||||
) {
|
||||
if (query.sortBy == MessageSortField.MESSAGE_TIMESTAMP) {
|
||||
query.sortBy = MessageSortField.TIMESTAMP;
|
||||
}
|
||||
query.sortBy = query.sortBy || MessageSortField.TIMESTAMP;
|
||||
query.sortOrder = query.sortOrder || SortOrder.DESC;
|
||||
filter = transformAck(filter);
|
||||
return session.getChatMessages(chatId, query, filter);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UnprocessableEntityException,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
transformAck,
|
||||
} from '@waha/structures/chats.dto';
|
||||
import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto';
|
||||
import { SendListRequest } from '@waha/structures/chatting.list.dto';
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import {
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
MessageLinkPreviewRequest,
|
||||
MessageLocationRequest,
|
||||
MessagePollRequest,
|
||||
MessagePollVoteRequest,
|
||||
MessageReactionRequest,
|
||||
MessageReplyRequest,
|
||||
MessageStarRequest,
|
||||
@@ -42,6 +45,20 @@ import {
|
||||
WANumberExistResult,
|
||||
} from '../structures/chatting.dto';
|
||||
import { WAMessage } from '../structures/responses.dto';
|
||||
import { isJidGroup } from '@waha/core/utils/jids';
|
||||
|
||||
function validateRequestMentions(request: MessageTextRequest) {
|
||||
if (!isJidGroup(request.chatId)) {
|
||||
throw new UnprocessableEntityException(
|
||||
`"mentions":["all"] can be used only in group chats, not in '${request.chatId}'`,
|
||||
);
|
||||
}
|
||||
if (request.mentions.length > 1) {
|
||||
throw new UnprocessableEntityException(
|
||||
`"mentions":["all"] cannot be used with other mentions`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api')
|
||||
@@ -53,6 +70,15 @@ export class ChattingController {
|
||||
@ApiOperation({ summary: 'Send a text message' })
|
||||
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (request.mentions && request.mentions.includes('all')) {
|
||||
validateRequestMentions(request);
|
||||
const participants = await whatsapp.getGroupParticipants(request.chatId);
|
||||
let mentions = participants.map((p) => p.id);
|
||||
// Exclude my ids
|
||||
const me = whatsapp.getSessionMeInfo();
|
||||
mentions = mentions.filter((id) => id !== me.id && id !== me.lid);
|
||||
request.mentions = mentions;
|
||||
}
|
||||
return whatsapp.sendText(request);
|
||||
}
|
||||
|
||||
@@ -121,8 +147,9 @@ export class ChattingController {
|
||||
|
||||
@Post('/sendButtons')
|
||||
@ApiOperation({
|
||||
summary: 'Send buttons (interactive message)',
|
||||
summary: 'Send buttons message (interactive)',
|
||||
description: 'Send Buttons',
|
||||
deprecated: true,
|
||||
})
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendButtons(@Body() request: SendButtonsRequest) {
|
||||
@@ -130,6 +157,17 @@ export class ChattingController {
|
||||
return whatsapp.sendButtons(request);
|
||||
}
|
||||
|
||||
@Post('/sendList')
|
||||
@ApiOperation({
|
||||
summary: 'Send a list message (interactive)',
|
||||
description: 'Send a List message with sections and rows',
|
||||
})
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendList(@Body() request: SendListRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendList(request);
|
||||
}
|
||||
|
||||
@Post('/forwardMessage')
|
||||
async forwardMessage(
|
||||
@Body() request: MessageForwardRequest,
|
||||
@@ -193,6 +231,17 @@ export class ChattingController {
|
||||
return whatsapp.sendPoll(request);
|
||||
}
|
||||
|
||||
@Post('/sendPollVote')
|
||||
@ApiOperation({
|
||||
summary: 'Vote on a poll',
|
||||
description: 'Cast vote(s) on an existing poll message',
|
||||
})
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendPollVote(@Body() request: MessagePollVoteRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendPollVote(request);
|
||||
}
|
||||
|
||||
@Post('/sendLocation')
|
||||
async sendLocation(@Body() request: MessageLocationRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
CreateGroupRequest,
|
||||
DescriptionRequest,
|
||||
GroupField,
|
||||
GroupParticipant,
|
||||
GroupsListFields,
|
||||
GroupsPaginationParams,
|
||||
JoinGroupRequest,
|
||||
@@ -320,6 +321,17 @@ export class GroupsController {
|
||||
return session.getParticipants(id);
|
||||
}
|
||||
|
||||
@Get(':id/participants/v2')
|
||||
@GroupIdApiParam
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get group participants.' })
|
||||
getGroupParticipants(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('id') id: string,
|
||||
): Promise<GroupParticipant[]> {
|
||||
return session.getGroupParticipants(id);
|
||||
}
|
||||
|
||||
@Post(':id/participants/add')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { isLidUser } from '@waha/core/utils/jids';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/lids')
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { PingResponse } from '@waha/structures/ping.dto';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('ping')
|
||||
@ApiTags('🔍 Observability')
|
||||
export class PingController {
|
||||
|
||||
@@ -7,6 +7,12 @@ import { DelayedError } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
export interface JobDataTimeout {
|
||||
timeout?: {
|
||||
job?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for app consumers that provides common functionality
|
||||
* like mutex locking, logging setup, and error handling.
|
||||
@@ -27,6 +33,14 @@ export abstract class AppConsumer extends BaseWorkerHost {
|
||||
});
|
||||
}
|
||||
|
||||
protected signal(job) {
|
||||
if (job.data?.timeout?.job != null) {
|
||||
return AbortSignal.timeout(job.data?.timeout?.job);
|
||||
}
|
||||
// Aka never abort
|
||||
return new AbortController().signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a job with mutex locking to ensure only one job processes a specific resource at a time.
|
||||
* @param job The job to process
|
||||
|
||||
@@ -21,7 +21,7 @@ export class AxiosLogging {
|
||||
: '';
|
||||
const body = config.data ? JSON.stringify(config.data) : '';
|
||||
|
||||
this.logger.info(`${method} ${url}${query} ${body}`);
|
||||
this.logger.debug(`${method} ${url}${query} ${body}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ export class AxiosLogging {
|
||||
const responseData = JSON.stringify(response.data);
|
||||
|
||||
if (status >= 200 && status < 400) {
|
||||
this.logger.info(`${methodStr} ${status}:OK ${url}`);
|
||||
this.logger.debug(`${methodStr} ${status}:OK ${url} ${responseData}`);
|
||||
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
|
||||
this.logger.trace(`${methodStr} ${status}:OK ${url} ${responseData}`);
|
||||
} else {
|
||||
this.logger.warn(`${methodStr} ${status} ${url} ${responseData}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { Backoffs } from 'bullmq';
|
||||
import { Backoffs, Job } from 'bullmq';
|
||||
import { FlowJob } from 'bullmq/dist/esm/interfaces';
|
||||
|
||||
/**
|
||||
* Calculates the delay until the next attempt for a job.
|
||||
@@ -50,3 +50,59 @@ export function HasBeenRetried(job: Job): boolean {
|
||||
const attemptsMade = job.attemptsMade + 1;
|
||||
return attemptsMade > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide chatwoot and waha from queue name for user faced messages
|
||||
*/
|
||||
export function QueueNameRepr(name: string): string {
|
||||
// No "chatwoot" in the name always (it start with chatwoot)
|
||||
name = name.replace('chatwoot.', '');
|
||||
// Replace "waha" with "whatsapp"
|
||||
name = name.replace('waha |', 'whatsapp |');
|
||||
return name;
|
||||
}
|
||||
|
||||
let base =
|
||||
process.env.WAHA_PUBLIC_URL ||
|
||||
process.env.WAHA_BASE_URL ||
|
||||
'http://localhost:3000';
|
||||
// cut / at the end
|
||||
base = base.replace(/\/+$/, '');
|
||||
|
||||
export function JobLink(job: Job): { text: string; url: string } {
|
||||
// Use repr name for text
|
||||
const name = QueueNameRepr(job.queueName);
|
||||
const text = `${name} => ${job.id}`;
|
||||
// Use original queue name in the URL
|
||||
const queue = encodeURIComponent(job.queueName);
|
||||
const id = encodeURIComponent(job.id);
|
||||
const url = `${base}/jobs/queue/${queue}/${id}`;
|
||||
return { text: text, url: url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain jobs so that they run one at a time
|
||||
* A | B | C
|
||||
* to
|
||||
* A -> B -> C
|
||||
*/
|
||||
export function ChainJobsOneAtATime(jobs: FlowJob[]): FlowJob {
|
||||
if (jobs.length == 0) {
|
||||
throw new Error('No jobs provided');
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.children.length != 0) {
|
||||
throw new Error('Jobs with children are not supported');
|
||||
}
|
||||
}
|
||||
const left = [...jobs];
|
||||
const root = left.pop();
|
||||
let parent = root;
|
||||
while (left.length != 0) {
|
||||
const job = left.pop();
|
||||
parent.children = [job];
|
||||
parent = job;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
@@ -48,10 +48,17 @@ export class AppsController {
|
||||
return await this.appsService.create(this.manager, app);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
@ApiOperation({ summary: 'Get app by ID' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async get(@Param('id') id: string): Promise<App> {
|
||||
return await this.appsService.get(this.manager, id);
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@ApiOperation({ summary: 'Update an existing app' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async update(@Param('id') id: string, @Body() app: App): Promise<void> {
|
||||
async update(@Param('id') id: string, @Body() app: App): Promise<App> {
|
||||
if (!app.id) {
|
||||
app.id = id;
|
||||
} else if (app.id !== id) {
|
||||
@@ -60,7 +67,7 @@ export class AppsController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.appsService.update(this.manager, app);
|
||||
return await this.appsService.update(this.manager, app);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
|
||||
@@ -2,21 +2,24 @@ import * as process from 'node:process';
|
||||
|
||||
import { parseBool } from '@waha/helpers';
|
||||
import * as basicAuth from 'express-basic-auth';
|
||||
import { Auth } from '@waha/core/auth/config';
|
||||
|
||||
export function BullAuthMiddleware() {
|
||||
let username = process.env.WAHA_DASHBOARD_USERNAME;
|
||||
let password = process.env.WAHA_DASHBOARD_PASSWORD;
|
||||
const enabled = parseBool(process.env.WAHA_DASHBOARD_ENABLED);
|
||||
if (!enabled) {
|
||||
// Generate a random uuid4 username /password to prevent access
|
||||
username = 'admin';
|
||||
password = crypto.randomUUID();
|
||||
let username = Auth.dashboard.username.value || '';
|
||||
let password = Auth.dashboard.password.value || '';
|
||||
if (process.env.WAHA_DASHBOARD_ENABLED) {
|
||||
const enabled = parseBool(process.env.WAHA_DASHBOARD_ENABLED);
|
||||
if (!enabled) {
|
||||
// Generate a random uuid4 username/password to effectively disable access
|
||||
username = 'admin';
|
||||
password = crypto.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
return basicAuth({
|
||||
challenge: true,
|
||||
users: {
|
||||
[username]: password,
|
||||
[String(username)]: String(password),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsString, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export type AllowedAppConfig = ChatWootAppConfig;
|
||||
|
||||
@@ -8,6 +15,7 @@ export enum AppName {
|
||||
chatwoot = 'chatwoot',
|
||||
}
|
||||
|
||||
@ApiExtraModels(ChatWootAppConfig)
|
||||
export class App<T extends AllowedAppConfig = any> {
|
||||
@IsString()
|
||||
id: string;
|
||||
@@ -19,6 +27,16 @@ export class App<T extends AllowedAppConfig = any> {
|
||||
@IsEnum(AppName)
|
||||
app: AppName;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Enable or disable this app without deleting it. If omitted, treated as enabled (true).',
|
||||
required: false,
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean = true;
|
||||
|
||||
@ValidateNested()
|
||||
@Type((options) => {
|
||||
if (options && options.object && options.object.app) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
|
||||
export function JestLogger(): ILogger {
|
||||
return {
|
||||
fatal: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
trace: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
exports.up = async function (knex: Knex) {
|
||||
// Add a boolean column 'enabled' defaulting to true and not nullable.
|
||||
const hasColumn = await knex.schema.hasColumn('apps', 'enabled');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable('apps', (table) => {
|
||||
table.boolean('enabled').defaultTo(true);
|
||||
});
|
||||
// Ensure existing rows have enabled=true
|
||||
await knex('apps').update({ enabled: true });
|
||||
// Make the column NOT NULL
|
||||
await knex.schema.alterTable('apps', (table) => {
|
||||
table.boolean('enabled').notNullable().defaultTo(true).alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex: Knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('apps', 'enabled');
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable('apps', (table) => {
|
||||
table.dropColumn('enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -21,11 +21,15 @@ export class AppsDisabledService implements IAppsService {
|
||||
throw new AppsIsDisabledError();
|
||||
}
|
||||
|
||||
async get(manager: SessionManager, appId: string): Promise<App> {
|
||||
throw new AppsIsDisabledError();
|
||||
}
|
||||
|
||||
async create(manager: SessionManager, app: App): Promise<App> {
|
||||
throw new AppsIsDisabledError();
|
||||
}
|
||||
|
||||
async update(manager: SessionManager, app: App) {
|
||||
async update(manager: SessionManager, app: App): Promise<App> {
|
||||
throw new AppsIsDisabledError();
|
||||
}
|
||||
|
||||
@@ -40,4 +44,8 @@ export class AppsDisabledService implements IAppsService {
|
||||
async beforeSessionStart(session: WhatsappSession, store: DataStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
async afterSessionStart(session: WhatsappSession, store: DataStore) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -58,15 +58,32 @@ export class AppsEnabledService implements IAppsService {
|
||||
}
|
||||
|
||||
const service = this.getAppService(app);
|
||||
await service.beforeCreated(app);
|
||||
service.validate(app);
|
||||
// Only run beforeCreated when app is enabled (default true if omitted)
|
||||
if (app.enabled !== false) {
|
||||
await service.beforeCreated(app);
|
||||
}
|
||||
|
||||
const result = await repo.save(app);
|
||||
await this.restartIfRunning(manager, app.session);
|
||||
if (app.enabled) {
|
||||
await this.restartIfRunning(manager, app.session);
|
||||
}
|
||||
delete result.pk;
|
||||
return result;
|
||||
}
|
||||
|
||||
async update(manager: SessionManager, app: App) {
|
||||
async get(manager: SessionManager, appId: string): Promise<App> {
|
||||
const knex = manager.store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
const app = await repo.getById(appId);
|
||||
if (!app) {
|
||||
throw new NotFoundException(`App '${appId}' not found`);
|
||||
}
|
||||
delete (app as any).pk;
|
||||
return app;
|
||||
}
|
||||
|
||||
async update(manager: SessionManager, app: App): Promise<App> {
|
||||
await this.checkSessionExists(manager, app.session);
|
||||
const knex = manager.store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
@@ -86,11 +103,26 @@ export class AppsEnabledService implements IAppsService {
|
||||
}
|
||||
|
||||
const service = this.getAppService(app);
|
||||
await service.beforeUpdated(savedApp, app);
|
||||
service.validate(app);
|
||||
|
||||
const hasEnabledChange = savedApp.enabled !== app.enabled;
|
||||
|
||||
if (hasEnabledChange) {
|
||||
if (app.enabled) {
|
||||
await service.beforeEnabled(savedApp, app);
|
||||
} else {
|
||||
await service.beforeDisabled(savedApp, app);
|
||||
}
|
||||
} else {
|
||||
await service.beforeUpdated(savedApp, app);
|
||||
}
|
||||
|
||||
await repo.update(app.id, app);
|
||||
await this.restartIfRunning(manager, app.session);
|
||||
return;
|
||||
|
||||
const updated = await repo.getById(app.id);
|
||||
delete (updated as any)?.pk;
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async delete(manager: SessionManager, appId: string) {
|
||||
@@ -110,10 +142,20 @@ export class AppsEnabledService implements IAppsService {
|
||||
async beforeSessionStart(session: WhatsappSession, store: DataStore) {
|
||||
const knex = store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
const apps = await repo.getAllBySession(session.name);
|
||||
const apps = await repo.getEnabledBySession(session.name);
|
||||
for (const app of apps) {
|
||||
const service = this.getAppService(app);
|
||||
await service.beforeSessionStart(app, session);
|
||||
service.beforeSessionStart(app, session);
|
||||
}
|
||||
}
|
||||
|
||||
async afterSessionStart(session: WhatsappSession, store: DataStore) {
|
||||
const knex = store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
const apps = await repo.getEnabledBySession(session.name);
|
||||
for (const app of apps) {
|
||||
const service = this.getAppService(app);
|
||||
service.afterSessionStart(app, session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,25 @@ import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
* Exact App service
|
||||
*/
|
||||
export interface IAppService {
|
||||
validate(app: App): void;
|
||||
|
||||
beforeCreated(app: App): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called only when the app transitions from disabled -> enabled.
|
||||
*/
|
||||
beforeEnabled(savedApp: App, newApp: App): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called only when the app transitions from enabled -> disabled.
|
||||
*/
|
||||
beforeDisabled(savedApp: App, newApp: App): Promise<void>;
|
||||
|
||||
beforeUpdated(savedApp: App, newApp: App): Promise<void>;
|
||||
|
||||
beforeDeleted(app: App): Promise<void>;
|
||||
|
||||
beforeSessionStart(app: App, session: WhatsappSession): Promise<void>;
|
||||
beforeSessionStart(app: App, session: WhatsappSession): void;
|
||||
|
||||
afterSessionStart(app: App, session: WhatsappSession): void;
|
||||
}
|
||||
@@ -7,14 +7,18 @@ import { Knex } from 'knex';
|
||||
export interface IAppsService {
|
||||
list(manager: SessionManager, session: string): Promise<App[]>;
|
||||
|
||||
get(manager: SessionManager, appId: string): Promise<App>;
|
||||
|
||||
create(manager: SessionManager, app: App): Promise<App>;
|
||||
|
||||
update(manager: SessionManager, app: App): Promise<void>;
|
||||
update(manager: SessionManager, app: App): Promise<App>;
|
||||
|
||||
delete(manager: SessionManager, appId: string): Promise<void>;
|
||||
|
||||
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
|
||||
|
||||
afterSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
|
||||
|
||||
migrate(knex: Knex): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AppRepository {
|
||||
* Saves an app to the database
|
||||
*/
|
||||
async save(app: Omit<App, 'pk'>): Promise<AppDB> {
|
||||
const appToSave = { ...app };
|
||||
const appToSave: any = { ...app };
|
||||
if (appToSave.config && typeof appToSave.config === 'object') {
|
||||
appToSave.config = JSON.stringify(appToSave.config);
|
||||
}
|
||||
@@ -29,7 +29,25 @@ export class AppRepository {
|
||||
private deserialize(app: AppDB): AppDB {
|
||||
const parsedConfig =
|
||||
typeof app.config === 'string' ? JSON.parse(app.config) : app.config;
|
||||
return { ...app, config: parsedConfig };
|
||||
|
||||
// Ensure 'enabled' is boolean (SQLite may return 0/1)
|
||||
let enabled: boolean;
|
||||
switch (app.enabled as any) {
|
||||
case undefined:
|
||||
case true:
|
||||
case 1:
|
||||
case '1':
|
||||
enabled = true;
|
||||
break;
|
||||
case false:
|
||||
case 0:
|
||||
enabled = false;
|
||||
break;
|
||||
default:
|
||||
enabled = true;
|
||||
}
|
||||
|
||||
return { ...(app as any), enabled: enabled, config: parsedConfig } as AppDB;
|
||||
}
|
||||
|
||||
private serialize<T extends Partial<App>>(app: T): T {
|
||||
@@ -51,6 +69,20 @@ export class AppRepository {
|
||||
return this.deserialize(app);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an enabled app by id, or null if not found or disabled.
|
||||
*/
|
||||
async findEnabledAppById(id: string): Promise<AppDB | null> {
|
||||
const app = await this.knex(this.tableName)
|
||||
.where('id', id)
|
||||
.andWhere('enabled', true)
|
||||
.first();
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
return this.deserialize(app);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all apps
|
||||
*/
|
||||
@@ -62,11 +94,26 @@ export class AppRepository {
|
||||
.then((apps) => apps.map((app) => this.deserialize(app)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets only enabled apps for a session.
|
||||
*/
|
||||
async getEnabledBySession(session: string): Promise<AppDB[]> {
|
||||
return this.knex(this.tableName)
|
||||
.select('*')
|
||||
.where('session', session)
|
||||
.andWhere('enabled', true)
|
||||
.orderBy('id', 'asc')
|
||||
.then((apps) => apps.map((app) => this.deserialize(app)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an app
|
||||
*/
|
||||
async update(id: string, app: Partial<Omit<App, 'id'>>): Promise<void> {
|
||||
const appToUpdate = this.serialize(app);
|
||||
const appToUpdate: any = this.serialize(app);
|
||||
if (appToUpdate.enabled === undefined) {
|
||||
delete appToUpdate.enabled; // keep current value
|
||||
}
|
||||
await this.knex(this.tableName).where('id', id).update(appToUpdate);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as lodash from 'lodash';
|
||||
|
||||
type Call<Record> = (processed: number) => Promise<Record[]>;
|
||||
|
||||
export interface PaginatorParams {
|
||||
processed?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const DefaultParams: PaginatorParams = {
|
||||
processed: 0,
|
||||
max: Infinity,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic paginator for APIs that return arrays of records with limits and offsets.
|
||||
*/
|
||||
export class ArrayPaginator<Record> {
|
||||
private readonly params: PaginatorParams;
|
||||
|
||||
constructor(params: PaginatorParams = DefaultParams) {
|
||||
this.params = lodash.merge({}, DefaultParams, params) as PaginatorParams;
|
||||
}
|
||||
|
||||
async *iterate(call: Call<Record>): AsyncGenerator<Record> {
|
||||
let processed = this.params.processed;
|
||||
let records: Record[] = [];
|
||||
|
||||
while (true) {
|
||||
records = await call(processed);
|
||||
if (records.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const record of records) {
|
||||
yield record;
|
||||
processed += 1;
|
||||
if (processed >= this.params.max) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { Channel } from '@waha/structures/channels.dto';
|
||||
import {
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
} from '@waha/structures/chats.dto';
|
||||
import {
|
||||
ChatRequest,
|
||||
MessageFileRequest,
|
||||
MessageImageRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
WANumberExistResult,
|
||||
} from '@waha/structures/chatting.dto';
|
||||
import { SessionInfo } from '@waha/structures/sessions.dto';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { Auth } from '@waha/core/auth/config';
|
||||
import { ContactSortField } from '@waha/structures/contacts.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
export interface RequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class WAHASelf {
|
||||
public client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
// Set 'X-Api-Key'
|
||||
const key = Auth.keyplain.value;
|
||||
const port =
|
||||
parseInt(process.env.PORT) ||
|
||||
parseInt(process.env.WHATSAPP_API_PORT) ||
|
||||
3000;
|
||||
const url = `http://localhost:${port}`;
|
||||
this.client = axios.create({
|
||||
baseURL: url,
|
||||
headers: {
|
||||
'X-Api-Key': key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async fetch(url: string, opts?: RequestOptions): Promise<Buffer> {
|
||||
const response = await this.client.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
signal: opts?.signal,
|
||||
});
|
||||
return Buffer.from(response.data);
|
||||
}
|
||||
|
||||
async qr(session: string, opts?: RequestOptions): Promise<Buffer> {
|
||||
const url = `/api/${session}/auth/qr`;
|
||||
return await this.fetch(url, opts);
|
||||
}
|
||||
|
||||
async screenshot(session: string, opts?: RequestOptions): Promise<Buffer> {
|
||||
const url = `/api/screenshot?session=${session}`;
|
||||
return await this.fetch(url, opts);
|
||||
}
|
||||
|
||||
async restart(session: string, opts?: RequestOptions): Promise<any> {
|
||||
const url = `/api/sessions/${session}/restart`;
|
||||
return await this.client.post(url, undefined, { signal: opts?.signal });
|
||||
}
|
||||
|
||||
async logout(session: string, opts?: RequestOptions): Promise<any> {
|
||||
const url = `/api/sessions/${session}/logout`;
|
||||
return await this.client.post(url, undefined, { signal: opts?.signal });
|
||||
}
|
||||
|
||||
async stop(session: string, opts?: RequestOptions): Promise<any> {
|
||||
const url = `/api/sessions/${session}/stop`;
|
||||
return await this.client.post(url, undefined, { signal: opts?.signal });
|
||||
}
|
||||
|
||||
async get(session: string, opts?: RequestOptions): Promise<SessionInfo> {
|
||||
const url = `/api/sessions/${session}/`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getChats(
|
||||
session: string,
|
||||
page: PaginationParams,
|
||||
opts?: RequestOptions,
|
||||
) {
|
||||
const url = `/api/${session}/chats`;
|
||||
const params = {
|
||||
...page,
|
||||
};
|
||||
return await this.client
|
||||
.get(url, { params: params, signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getContacts(
|
||||
session: string,
|
||||
page: PaginationParams,
|
||||
opts?: RequestOptions,
|
||||
) {
|
||||
const url = `/api/contacts/all`;
|
||||
const params = {
|
||||
session: session,
|
||||
...page,
|
||||
sortBy: ContactSortField.ID,
|
||||
sortOrder: ContactSortField.ID,
|
||||
};
|
||||
return await this.client
|
||||
.get(url, { params: params, signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getContact(session: string, contactId: string, opts?: RequestOptions) {
|
||||
const url = `/api/contacts`;
|
||||
const params = {
|
||||
session: session,
|
||||
contactId: contactId,
|
||||
};
|
||||
return await this.client
|
||||
.get(url, { params: params, signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async contactCheckExists(
|
||||
session: string,
|
||||
phone: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<WANumberExistResult> {
|
||||
const url = `/api/contacts/check-exists`;
|
||||
const params = {
|
||||
phone: phone,
|
||||
session: session,
|
||||
};
|
||||
return await this.client
|
||||
.get(url, { params: params, signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getGroup(session: string, groupId: string, opts?: RequestOptions) {
|
||||
const url = `/api/${session}/groups/${groupId}`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getChannel(
|
||||
session: string,
|
||||
channelId: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<Channel> {
|
||||
const url = `/api/${session}/channels/${channelId}`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getChatPicture(
|
||||
session: string,
|
||||
chatId: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<string | null> {
|
||||
const url = `/api/${session}/chats/${chatId}/picture`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data?.url);
|
||||
}
|
||||
|
||||
async sendText(
|
||||
body: MessageTextRequest,
|
||||
opts?: RequestOptions,
|
||||
): Promise<any> {
|
||||
const url = `/api/sendText`;
|
||||
return await this.client
|
||||
.post(url, body, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async sendImage(
|
||||
body: MessageImageRequest,
|
||||
opts?: RequestOptions,
|
||||
): Promise<any> {
|
||||
const url = `/api/sendImage`;
|
||||
return await this.client
|
||||
.post(url, body, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async sendVideo(
|
||||
body: MessageVideoRequest,
|
||||
opts?: RequestOptions,
|
||||
): Promise<any> {
|
||||
const url = `/api/sendVideo`;
|
||||
return await this.client
|
||||
.post(url, body, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async sendVoice(
|
||||
body: MessageVoiceRequest,
|
||||
opts?: RequestOptions,
|
||||
): Promise<any> {
|
||||
const url = `/api/sendVoice`;
|
||||
return await this.client
|
||||
.post(url, body, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async sendFile(
|
||||
body: MessageFileRequest,
|
||||
opts?: RequestOptions,
|
||||
): Promise<any> {
|
||||
const url = `/api/sendFile`;
|
||||
return await this.client
|
||||
.post(url, body, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async deleteMessage(
|
||||
session: string,
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
opts?: RequestOptions,
|
||||
) {
|
||||
const url = `/api/${session}/chats/${chatId}/messages/${messageId}`;
|
||||
return await this.client
|
||||
.delete(url, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async startTyping(body: ChatRequest, opts?: RequestOptions) {
|
||||
const url = `/api/startTyping`;
|
||||
return await this.client
|
||||
.post(url, body, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async stopTyping(body: ChatRequest, opts?: RequestOptions) {
|
||||
const url = `/api/stopTyping`;
|
||||
return await this.client.post(url, body, { signal: opts?.signal });
|
||||
}
|
||||
|
||||
async readMessages(session: string, chatId: string, opts?: RequestOptions) {
|
||||
const url = `/api/${session}/chats/${chatId}/messages/read`;
|
||||
return await this.client
|
||||
.post(url, undefined, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getMessages(
|
||||
session: string,
|
||||
chatId: string,
|
||||
query: GetChatMessagesQuery,
|
||||
filter: GetChatMessagesFilter,
|
||||
opts?: RequestOptions,
|
||||
) {
|
||||
const url = `/api/${session}/chats/${chatId}/messages`;
|
||||
const params = {
|
||||
...query,
|
||||
...filter,
|
||||
};
|
||||
return await this.client
|
||||
.get(url, { params: params, signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getMessageById(
|
||||
session: string,
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
media: boolean,
|
||||
opts?: RequestOptions,
|
||||
) {
|
||||
const url = `/api/${session}/chats/${chatId}/messages/${messageId}`;
|
||||
const params = {
|
||||
downloadMedia: media,
|
||||
};
|
||||
return await this.client
|
||||
.get(url, { params: params, signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async findPNByLid(
|
||||
session: string,
|
||||
lid: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<string | null> {
|
||||
const url = `/api/${session}/lids/${lid}`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data.pn);
|
||||
}
|
||||
|
||||
async findLIDByPN(
|
||||
session: string,
|
||||
pn: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<string | null> {
|
||||
const url = `/api/${session}/lids/pn/${pn}`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data.lid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server version
|
||||
* @returns Server version information
|
||||
*/
|
||||
async serverVersion(opts?: RequestOptions): Promise<any> {
|
||||
const url = `/api/server/version`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server status
|
||||
* @returns Server status information
|
||||
*/
|
||||
async serverStatus(opts?: RequestOptions): Promise<any> {
|
||||
const url = `/api/server/status`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboot the server
|
||||
* @param force Whether to force reboot (true) or gracefully reboot (false)
|
||||
* @returns Server stop response
|
||||
*/
|
||||
async serverReboot(
|
||||
force: boolean = false,
|
||||
opts?: RequestOptions,
|
||||
): Promise<any> {
|
||||
const url = `/api/server/stop`;
|
||||
return await this.client
|
||||
.post(url, { force }, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
}
|
||||
|
||||
export class WAHASessionAPI {
|
||||
constructor(
|
||||
private session: string,
|
||||
private api: WAHASelf,
|
||||
) {}
|
||||
|
||||
getChats(page: PaginationParams, opts?: RequestOptions) {
|
||||
return this.api.getChats(this.session, page, opts);
|
||||
}
|
||||
|
||||
getContacts(page: PaginationParams, opts?: RequestOptions): Promise<any> {
|
||||
return this.api.getContacts(this.session, page, opts);
|
||||
}
|
||||
|
||||
getContact(contactId: string, opts?: RequestOptions): Promise<any> {
|
||||
return this.api.getContact(this.session, contactId, opts);
|
||||
}
|
||||
|
||||
contactCheckExists(
|
||||
phone: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<WANumberExistResult> {
|
||||
return this.api.contactCheckExists(this.session, phone, opts);
|
||||
}
|
||||
|
||||
getGroup(groupId: string, opts?: RequestOptions): Promise<any> {
|
||||
return this.api.getGroup(this.session, groupId, opts);
|
||||
}
|
||||
|
||||
getChannel(channelId: string, opts?: RequestOptions): Promise<Channel> {
|
||||
return this.api.getChannel(this.session, channelId, opts);
|
||||
}
|
||||
|
||||
getChatPicture(
|
||||
chatId: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<string | null> {
|
||||
return this.api.getChatPicture(this.session, chatId, opts);
|
||||
}
|
||||
|
||||
sendText(body: MessageTextRequest, opts?: RequestOptions): Promise<any> {
|
||||
body.session = this.session;
|
||||
return this.api.sendText(body, opts);
|
||||
}
|
||||
|
||||
sendImage(body: MessageImageRequest, opts?: RequestOptions): Promise<any> {
|
||||
body.session = this.session;
|
||||
return this.api.sendImage(body, opts);
|
||||
}
|
||||
|
||||
sendVideo(body: MessageVideoRequest, opts?: RequestOptions): Promise<any> {
|
||||
body.session = this.session;
|
||||
return this.api.sendVideo(body, opts);
|
||||
}
|
||||
|
||||
sendVoice(body: MessageVoiceRequest, opts?: RequestOptions): Promise<any> {
|
||||
body.session = this.session;
|
||||
return this.api.sendVoice(body, opts);
|
||||
}
|
||||
|
||||
sendFile(body: MessageFileRequest, opts?: RequestOptions): Promise<any> {
|
||||
body.session = this.session;
|
||||
return this.api.sendFile(body, opts);
|
||||
}
|
||||
|
||||
deleteMessage(chatId: string, messageId: string, opts?: RequestOptions) {
|
||||
return this.api.deleteMessage(this.session, chatId, messageId, opts);
|
||||
}
|
||||
|
||||
startTyping(body: ChatRequest, opts?: RequestOptions) {
|
||||
body.session = this.session;
|
||||
return this.api.startTyping(body, opts);
|
||||
}
|
||||
|
||||
stopTyping(body: ChatRequest, opts?: RequestOptions) {
|
||||
body.session = this.session;
|
||||
return this.api.stopTyping(body, opts);
|
||||
}
|
||||
|
||||
readMessages(chatId: string, opts?: RequestOptions) {
|
||||
return this.api.readMessages(this.session, chatId, opts);
|
||||
}
|
||||
|
||||
async getMessages(
|
||||
chatId: string,
|
||||
query: GetChatMessagesQuery,
|
||||
filter: GetChatMessagesFilter,
|
||||
opts?: RequestOptions,
|
||||
) {
|
||||
return this.api.getMessages(this.session, chatId, query, filter, opts);
|
||||
}
|
||||
|
||||
async getMessageById(
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
media: boolean,
|
||||
opts?: RequestOptions,
|
||||
) {
|
||||
return this.api.getMessageById(
|
||||
this.session,
|
||||
chatId,
|
||||
messageId,
|
||||
media,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Lids
|
||||
//
|
||||
findPNByLid(lid: string, opts?: RequestOptions) {
|
||||
return this.api.findPNByLid(this.session, lid, opts);
|
||||
}
|
||||
|
||||
findLIDByPN(pn: string, opts?: RequestOptions) {
|
||||
return this.api.findLIDByPN(this.session, pn, opts);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
|
||||
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
|
||||
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
|
||||
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
|
||||
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
|
||||
import { parseBool } from '@waha/helpers';
|
||||
import { RMutexModule } from '@waha/modules/rmutex';
|
||||
import { Auth } from '@waha/core/auth/config';
|
||||
|
||||
const IMPORTS = [
|
||||
BullModule.forRoot({
|
||||
@@ -88,15 +90,15 @@ const AppsDisabled = {
|
||||
},
|
||||
],
|
||||
imports: [],
|
||||
controllers: [AppsController],
|
||||
controllers: [AppsController, ChatwootLocalesController],
|
||||
};
|
||||
|
||||
function checkApiKey() {
|
||||
const key = process.env.WHATSAPP_API_KEY || process.env.WAHA_API_KEY;
|
||||
const key = Auth.key.value;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const plain = process.env.WAHA_API_KEY_PLAIN;
|
||||
const plain = Auth.keyplain.value;
|
||||
if (!plain) {
|
||||
throw Error(
|
||||
'WAHA_API_KEY set, please provide WAHA_API_KEY_PLAIN when WAHA_APPS_ENABLED',
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { WAHASessionStatus } from '@waha/structures/enums.dto';
|
||||
|
||||
export function SessionStatusEmoji(status: WAHASessionStatus): string {
|
||||
switch (status) {
|
||||
case WAHASessionStatus.STOPPED:
|
||||
return '⚠️';
|
||||
case WAHASessionStatus.STARTING:
|
||||
return '⏳';
|
||||
case WAHASessionStatus.SCAN_QR_CODE:
|
||||
return '⚠️';
|
||||
case WAHASessionStatus.WORKING:
|
||||
return '🟢';
|
||||
case WAHASessionStatus.FAILED:
|
||||
return '🛑';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { sortBy } from 'lodash';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { LOCALE_NAMES } from '@waha/apps/chatwoot/locale';
|
||||
import { i18n } from '@waha/apps/chatwoot/i18n';
|
||||
|
||||
interface LanguageResponse {
|
||||
name: string;
|
||||
@@ -17,9 +18,14 @@ export class ChatwootLocalesController {
|
||||
description: 'Get available languages for Chatwoot app',
|
||||
})
|
||||
getLanguages(): LanguageResponse[] {
|
||||
return Object.entries(LOCALE_NAMES).map(([locale, name]) => ({
|
||||
name: name,
|
||||
locale: locale,
|
||||
}));
|
||||
const locales = i18n.available();
|
||||
const priority = ['en-US', 'pt-BR', 'es-ES'];
|
||||
return sortBy(locales, [
|
||||
(x) => {
|
||||
const idx = priority.indexOf(x.locale);
|
||||
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
|
||||
},
|
||||
'locale',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Body, Controller, Param, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { GetChatID } from '@waha/apps/chatwoot/client/ids';
|
||||
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
||||
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
import { INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
|
||||
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import { ChatWootQueueService } from '@waha/apps/chatwoot/services/ChatWootQueueService';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
|
||||
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
|
||||
|
||||
@Controller('webhooks/chatwoot/')
|
||||
@ApiTags('🧩 Apps')
|
||||
export class ChatwootWebhookController {
|
||||
constructor(private readonly chatWootQueueService: ChatWootQueueService) {}
|
||||
constructor(
|
||||
private readonly chatWootQueueService: ChatWootQueueService,
|
||||
private readonly manager: SessionManager,
|
||||
) {}
|
||||
|
||||
@Post(':session/:id')
|
||||
@ApiOperation({
|
||||
@@ -25,29 +36,43 @@ export class ChatwootWebhookController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Ignore all private notes
|
||||
if (body.private) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Ignore all incoming messages
|
||||
if (body.message_type == MessageType.INCOMING) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const isCommandsChat = IsCommandsChat(body);
|
||||
// Ignore private notes (most of them)
|
||||
const deleted = body?.content_attributes?.deleted;
|
||||
if (body.private) {
|
||||
// Ignore any private note in commands chats
|
||||
if (isCommandsChat) {
|
||||
return { success: true };
|
||||
}
|
||||
// keep "deleted" notes
|
||||
// So Agent can delete "Sent From API/WhatsApp" messages in ChatWoot
|
||||
if (!deleted) {
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
const data: InboxData = {
|
||||
session: session,
|
||||
app: id,
|
||||
body: body,
|
||||
};
|
||||
|
||||
// Check if it's a command message (sent to the special inbox contact)
|
||||
const sender = body?.conversation?.meta?.sender;
|
||||
const chatId = GetChatID(sender);
|
||||
const isCommandsChat = chatId === INBOX_CONTACT_CHAT_ID;
|
||||
// Skip if app is disabled or does not exist
|
||||
const knex = this.manager.store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
const app = await repo.findEnabledAppById(id);
|
||||
if (!app || app.session !== session) {
|
||||
throw new NotFoundException(`App '${id}' not found`);
|
||||
}
|
||||
|
||||
// Check if it's a command message (sent to the special inbox contact)
|
||||
// Check if it's a deleted message
|
||||
if (body.content_attributes?.deleted && !isCommandsChat) {
|
||||
if (deleted && !isCommandsChat) {
|
||||
await this.chatWootQueueService.addMessageDeletedJob(data);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -55,21 +80,35 @@ export class ChatwootWebhookController {
|
||||
// Route to specific queues based on an event type
|
||||
switch (body.event) {
|
||||
case EventName.MESSAGE_CREATED:
|
||||
if (!isCommandsChat) {
|
||||
await this.chatWootQueueService.addMessageCreatedJob(data);
|
||||
} else {
|
||||
if (isCommandsChat || body.content?.startsWith(CommandPrefix)) {
|
||||
await this.chatWootQueueService.addCommandsJob(body.event, data);
|
||||
} else {
|
||||
await this.chatWootQueueService.addMessageCreatedJob(data);
|
||||
}
|
||||
return { success: true };
|
||||
case EventName.MESSAGE_UPDATED:
|
||||
if (!isCommandsChat) {
|
||||
await this.chatWootQueueService.addMessageUpdatedJob(data);
|
||||
} else {
|
||||
// We handle only "retries" on message_update
|
||||
// This is the attribute ChatWoot send
|
||||
// There's NO other way to identify "status: read" updates right now
|
||||
// There's no "body.status" in message_updated webhook :(
|
||||
const isRetryNull = body.content_attributes?.external_error === null;
|
||||
const isRetrySomething = Boolean(
|
||||
body.content_attributes?.external_error,
|
||||
);
|
||||
const isRetry = isRetryNull || isRetrySomething;
|
||||
if (!isRetry) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (isCommandsChat || body.content?.startsWith(CommandPrefix)) {
|
||||
await this.chatWootQueueService.addCommandsJob(body.event, data);
|
||||
} else {
|
||||
await this.chatWootQueueService.addMessageUpdatedJob(data);
|
||||
}
|
||||
return { success: true };
|
||||
default:
|
||||
// Ignore other events
|
||||
await this.chatWootQueueService.addJobToQueue(body.event, data);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { public_conversation } from '@figuro/chatwoot-sdk';
|
||||
import { ChatWootAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
|
||||
import * as NodeCache from 'node-cache';
|
||||
|
||||
import { IConversationCache } from './IConversationCache';
|
||||
import { ContactIds } from '@waha/apps/chatwoot/client/ConversationService';
|
||||
|
||||
const cache: NodeCache = new NodeCache({
|
||||
stdTTL: 24 * 60 * 60, // 1 day
|
||||
@@ -16,7 +16,7 @@ export function CacheForConfig(config: ChatWootAPIConfig): ConversationCache {
|
||||
class ConversationCache implements IConversationCache {
|
||||
constructor(private prefix: string) {}
|
||||
|
||||
fullKey(key: string) {
|
||||
private fullKey(key: string) {
|
||||
return `${this.prefix}.${key}`;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class ConversationCache implements IConversationCache {
|
||||
cache.del(fullKey);
|
||||
}
|
||||
|
||||
get(key: string): public_conversation | null {
|
||||
get(key: string): ContactIds | null {
|
||||
const fullKey = this.fullKey(key);
|
||||
return cache.get(fullKey) || null;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class ConversationCache implements IConversationCache {
|
||||
return cache.has(fullKey);
|
||||
}
|
||||
|
||||
set(key: string, value: public_conversation): void {
|
||||
set(key: string, value: ContactIds): void {
|
||||
const fullKey = this.fullKey(key);
|
||||
cache.set(fullKey, value);
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { public_conversation } from '@figuro/chatwoot-sdk';
|
||||
import { ContactIds } from '@waha/apps/chatwoot/client/ConversationService';
|
||||
|
||||
export interface IConversationCache {
|
||||
get(key: string): public_conversation | null;
|
||||
set(key: string, value: public_conversation): void;
|
||||
get(key: string): ContactIds | null;
|
||||
set(key: string, value: ContactIds): void;
|
||||
has(key: string): boolean;
|
||||
delete(key: string): void;
|
||||
clean(): void;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RegisterAppQueue } from '@waha/apps/app_sdk/BullUtils';
|
||||
import {
|
||||
ExponentialRetriesJobOptions,
|
||||
@@ -16,20 +15,31 @@ import { ChatWootInboxCommandsConsumer } from './consumers/inbox/commands';
|
||||
import { ChatWootInboxMessageCreatedConsumer } from './consumers/inbox/message_created';
|
||||
import { ChatWootInboxMessageDeletedConsumer } from './consumers/inbox/message_deleted';
|
||||
import { ChatWootInboxMessageUpdatedConsumer } from './consumers/inbox/message_updated';
|
||||
import { QueueName } from './consumers/QueueName';
|
||||
import { FlowProducerName, QueueName } from './consumers/QueueName';
|
||||
import { CheckVersionConsumer } from './consumers/scheduled/check.version';
|
||||
import { WAHAMessageAnyConsumer } from './consumers/waha/message.any';
|
||||
import { WAHAMessageEditedConsumer } from './consumers/waha/message.edited';
|
||||
import { WAHAMessageReactionConsumer } from './consumers/waha/message.reaction';
|
||||
import { WAHAMessageRevokedConsumer } from './consumers/waha/message.revoked';
|
||||
import { WAHAMessageAckConsumer } from './consumers/waha/message.ack';
|
||||
import { WAHASessionStatusConsumer } from './consumers/waha/session.status';
|
||||
import { ChatWootQueueService } from './services/ChatWootQueueService';
|
||||
import { ChatWootScheduleService } from './services/ChatWootScheduleService';
|
||||
import { ChatWootWAHAQueueService } from './services/ChatWootWAHAQueueService';
|
||||
import { QueueRegistry } from './services/QueueRegistry';
|
||||
import { ChatWootConversationCreatedConsumer } from './consumers/inbox/conversation_created';
|
||||
import { ChatWootConversationStatusChangedConsumer } from '@waha/apps/chatwoot/consumers/inbox/conversation_status_changed';
|
||||
import { TaskContactsPullConsumer } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
|
||||
import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
|
||||
|
||||
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
|
||||
|
||||
const IMPORTS = lodash.flatten([
|
||||
BullModule.registerFlowProducer({
|
||||
name: FlowProducerName.MESSAGES_PULL_FLOW,
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.SCHEDULED_MESSAGE_CLEANUP,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
@@ -38,6 +48,14 @@ const IMPORTS = lodash.flatten([
|
||||
name: QueueName.SCHEDULED_CHECK_VERSION,
|
||||
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.TASK_CONTACTS_PULL,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.TASK_MESSAGES_PULL,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.WAHA_MESSAGE_ANY,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
@@ -54,6 +72,10 @@ const IMPORTS = lodash.flatten([
|
||||
name: QueueName.WAHA_MESSAGE_REVOKED,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.WAHA_MESSAGE_ACK,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.WAHA_SESSION_STATUS,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
@@ -66,6 +88,14 @@ const IMPORTS = lodash.flatten([
|
||||
name: QueueName.INBOX_MESSAGE_UPDATED,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.INBOX_CONVERSATION_CREATED,
|
||||
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.INBOX_CONVERSATION_STATUS_CHANGED,
|
||||
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.INBOX_MESSAGE_DELETED,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
@@ -80,18 +110,30 @@ const PROVIDERS = [
|
||||
ChatWootInboxMessageCreatedConsumer,
|
||||
ChatWootInboxMessageUpdatedConsumer,
|
||||
ChatWootInboxMessageDeletedConsumer,
|
||||
// Conversation events
|
||||
ChatWootConversationCreatedConsumer,
|
||||
ChatWootConversationStatusChangedConsumer,
|
||||
ChatWootInboxCommandsConsumer,
|
||||
// Tasks
|
||||
TaskContactsPullConsumer,
|
||||
TaskMessagesPullConsumer,
|
||||
// WAHA
|
||||
WAHASessionStatusConsumer,
|
||||
WAHAMessageAnyConsumer,
|
||||
WAHAMessageReactionConsumer,
|
||||
WAHAMessageEditedConsumer,
|
||||
WAHAMessageRevokedConsumer,
|
||||
WAHAMessageAckConsumer,
|
||||
// Scheduled
|
||||
MessageCleanupConsumer,
|
||||
CheckVersionConsumer,
|
||||
// Services
|
||||
ChatWootWAHAQueueService,
|
||||
ChatWootQueueService,
|
||||
ChatWootScheduleService,
|
||||
ChatWootAppService,
|
||||
QueueRegistry,
|
||||
QueueManager,
|
||||
];
|
||||
|
||||
export const ChatWootExports = {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import {
|
||||
ContactsPullOptions,
|
||||
ContactsPullStatusMessage,
|
||||
} from '@waha/apps/chatwoot/consumers/task/contacts.pull';
|
||||
import { Job, JobsOptions, Queue } from 'bullmq';
|
||||
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
|
||||
import {
|
||||
ExponentialRetriesJobOptions,
|
||||
JobRemoveOptions,
|
||||
merge,
|
||||
} from '@waha/apps/app_sdk/constants';
|
||||
|
||||
export async function ContactsPullStart(
|
||||
ctx: CommandContext,
|
||||
options: ContactsPullOptions,
|
||||
jobOptions: JobsOptions & JobDataTimeout,
|
||||
) {
|
||||
const jobId = ChatWootScheduleService.SingleJobId(ctx.data.app);
|
||||
const job: Job = await ctx.queues.contactsPull.getJob(jobId);
|
||||
if (job) {
|
||||
const state = await job.getState();
|
||||
const done = state === 'completed' || state === 'failed';
|
||||
if (!done) {
|
||||
const msg = ctx.l.r('cli.cmd.contacts.pull.already-running');
|
||||
await ctx.conversation.incoming(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove already done job
|
||||
await ContactsPullRemove(ctx.queues.contactsPull, ctx.data.app, ctx.logger);
|
||||
}
|
||||
|
||||
const opts: JobsOptions = merge(
|
||||
ExponentialRetriesJobOptions,
|
||||
JobRemoveOptions,
|
||||
jobOptions,
|
||||
);
|
||||
const data = {
|
||||
...ctx.data,
|
||||
timeout: {
|
||||
job: jobOptions.timeout.job,
|
||||
},
|
||||
options: options,
|
||||
};
|
||||
opts.jobId = jobId;
|
||||
await ctx.queues.contactsPull.add(QueueName.TASK_CONTACTS_PULL, data, opts);
|
||||
}
|
||||
|
||||
export async function ContactsPullStatus(ctx: CommandContext) {
|
||||
const jobId = ChatWootScheduleService.SingleJobId(ctx.data.app);
|
||||
const job: Job | null = await ctx.queues.contactsPull.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
const msg = ctx.l.r('cli.cmd.contacts.status.not-found');
|
||||
await ctx.conversation.incoming(msg);
|
||||
return;
|
||||
}
|
||||
const state = await job.getState();
|
||||
const msg = ContactsPullStatusMessage(ctx.l, job, state);
|
||||
await ctx.conversation.incoming(msg);
|
||||
}
|
||||
|
||||
export async function ContactsPullRemove(
|
||||
queue: Queue,
|
||||
app: string,
|
||||
logger: ILogger,
|
||||
): Promise<boolean> {
|
||||
const jobId = ChatWootScheduleService.SingleJobId(app);
|
||||
const job: Job = await queue.getJob(jobId);
|
||||
if (!job) {
|
||||
logger.info('Pull Contacts job has already been removed');
|
||||
return false;
|
||||
}
|
||||
await job.remove();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
export async function CommandDisabled(ctx: CommandContext, command: string) {
|
||||
const text = ctx.l.r('cli.cmd.disabled', { command: command });
|
||||
await ctx.conversation.incoming(text);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as lodash from 'lodash';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import {
|
||||
ChatID,
|
||||
MessagesPullOptions,
|
||||
MessagesPullStatusMessage,
|
||||
TaskActivity,
|
||||
} from '@waha/apps/chatwoot/consumers/task/messages.pull';
|
||||
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
|
||||
import { FlowProducer, Job, JobsOptions, Queue } from 'bullmq';
|
||||
import { FlowJob } from 'bullmq/dist/esm/interfaces';
|
||||
import { GetAllChatIDs, IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
||||
import { ChainJobsOneAtATime } from '@waha/apps/app_sdk/JobUtils';
|
||||
import {
|
||||
ExponentialRetriesJobOptions,
|
||||
JobRemoveOptions,
|
||||
merge,
|
||||
} from '@waha/apps/app_sdk/constants';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/waha';
|
||||
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import {
|
||||
IgnoreJidConfig,
|
||||
isNullJid,
|
||||
isPnUser,
|
||||
JidFilter,
|
||||
} from '@waha/core/utils/jids';
|
||||
|
||||
function oneJob(chat: string, data, opts: JobsOptions): FlowJob {
|
||||
data = lodash.cloneDeep(data);
|
||||
data.options.chat = chat;
|
||||
opts = lodash.cloneDeep(opts);
|
||||
return {
|
||||
name: chat,
|
||||
queueName: QueueName.TASK_MESSAGES_PULL,
|
||||
data: data,
|
||||
opts: merge(ExponentialRetriesJobOptions, JobRemoveOptions, opts),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function MessagesPullStart(
|
||||
ctx: CommandContext,
|
||||
options: MessagesPullOptions,
|
||||
jobOptions: JobsOptions & JobDataTimeout,
|
||||
) {
|
||||
const queue = ctx.queues.messagesPull;
|
||||
const jobId = ChatWootScheduleService.SingleJobId(ctx.data.app);
|
||||
const job: Job | null = await queue.getJob(jobId);
|
||||
|
||||
if (job) {
|
||||
const state = await job.getState();
|
||||
const done = state === 'completed' || state === 'failed';
|
||||
if (!done) {
|
||||
const msg = ctx.l.r('cli.cmd.messages.pull.already-running', {
|
||||
chat: job.data.options.chat,
|
||||
});
|
||||
await ctx.conversation.incoming(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
await MessagesPullRemove(queue, ctx.data.app, ctx.logger);
|
||||
}
|
||||
const opts: JobsOptions = {
|
||||
...jobOptions,
|
||||
ignoreDependencyOnFailure: true,
|
||||
delay: 1_000,
|
||||
};
|
||||
const data = {
|
||||
...ctx.data,
|
||||
timeout: {
|
||||
job: jobOptions.timeout.job,
|
||||
},
|
||||
options: options,
|
||||
};
|
||||
const producer: FlowProducer = ctx.flows.messagesPull;
|
||||
let chats: string[] = [options.chat];
|
||||
|
||||
if (options.chat === ChatID.ALL) {
|
||||
if (IsCommandsChat(data.body)) {
|
||||
if (EngineHelper.SupportsAllChatForMessage()) {
|
||||
// keep "all" chats - that's fine
|
||||
} else {
|
||||
// "all" chats for engine that doesn't support it
|
||||
chats = await resolveAllToChats(ctx.waha, ctx.data.session, options);
|
||||
if (chats.length == 0) {
|
||||
throw new Error(ctx.l.r('cli.cmd.messages.pull.no-chats-found'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's per-chat command, get the chat ids and run the pulling process
|
||||
chats = GetAllChatIDs(data.body?.conversation?.meta?.sender);
|
||||
chats = EngineHelper.FilterChatIdsForMessages(chats);
|
||||
}
|
||||
}
|
||||
const children = chats.map((chat) => oneJob(chat, data, opts));
|
||||
|
||||
let root: FlowJob;
|
||||
if (children.length == 1) {
|
||||
// Schedule one job, no "summary" job required
|
||||
root = children[0];
|
||||
} else {
|
||||
// Add "summary" parent job so we get the overall progress at the end
|
||||
root = oneJob(ChatID.SUMMARY, data, opts);
|
||||
root.children = [ChainJobsOneAtATime(children)];
|
||||
}
|
||||
root.opts.jobId = jobId;
|
||||
await producer.add(root);
|
||||
const activity = new TaskActivity(ctx.l, ctx.conversation);
|
||||
await activity.details(data);
|
||||
}
|
||||
|
||||
export async function MessagesPullStatus(ctx: CommandContext) {
|
||||
const queue = ctx.queues.messagesPull;
|
||||
const jobId = ChatWootScheduleService.SingleJobId(ctx.data.app);
|
||||
const job: Job | null = await queue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
const msg = ctx.l.r('cli.cmd.messages.status.not-found');
|
||||
await ctx.conversation.incoming(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const msg = MessagesPullStatusMessage(ctx.l, job, state);
|
||||
await ctx.conversation.incoming(msg);
|
||||
}
|
||||
|
||||
export async function MessagesPullRemove(
|
||||
queue: Queue,
|
||||
app: string,
|
||||
logger: ILogger,
|
||||
): Promise<boolean> {
|
||||
const jobId = ChatWootScheduleService.SingleJobId(app);
|
||||
const job: Job | null = await queue.getJob(jobId);
|
||||
if (!job) {
|
||||
logger.info('Pull Messages job has already been removed');
|
||||
return false;
|
||||
}
|
||||
|
||||
await job.remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* WORKING ONLY FOR WEBJS
|
||||
*/
|
||||
async function resolveAllToChats(
|
||||
waha: WAHASelf,
|
||||
sessionName: string,
|
||||
options: MessagesPullOptions,
|
||||
): Promise<string[]> {
|
||||
const session = new WAHASessionAPI(sessionName, waha);
|
||||
let chats = await session.getChats({
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
});
|
||||
// new chat last
|
||||
chats = lodash.sortBy(chats, (c) => c.timestamp || 0);
|
||||
|
||||
const gte = Date.now() - options.period.start;
|
||||
const result = [];
|
||||
const jids = new JidFilter(options.ignore);
|
||||
for (const chat of chats) {
|
||||
const id = chat.id._serialized;
|
||||
if (!jids.include(id)) {
|
||||
continue;
|
||||
}
|
||||
if (isNullJid(id)) {
|
||||
continue;
|
||||
}
|
||||
const timestamp = (chat.timestamp || Infinity) * 1000;
|
||||
if (timestamp < gte) {
|
||||
// Too old chat
|
||||
continue;
|
||||
}
|
||||
result.push(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as lodash from 'lodash';
|
||||
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||
import { QueueRegistry } from '@waha/apps/chatwoot/services/QueueRegistry';
|
||||
import { QueueNameRepr } from '@waha/apps/app_sdk/JobUtils';
|
||||
|
||||
export interface QueueCommandContext {
|
||||
queues: {
|
||||
registry: QueueRegistry;
|
||||
};
|
||||
l: Locale;
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
export async function QueueStatus(ctx: QueueCommandContext, name: string) {
|
||||
const manager = new QueueManager(ctx.queues.registry);
|
||||
const names = manager.resolve(name);
|
||||
let result = await manager.status(names);
|
||||
for (const status of result) {
|
||||
status.name = QueueNameRepr(status.name);
|
||||
}
|
||||
// locked: true - last
|
||||
result = lodash.sortBy(result, [(x) => !!x.locked, 'name']);
|
||||
const msg = ctx.l.r('cli.cmd.queue.status.result', {
|
||||
queues: result,
|
||||
});
|
||||
await ctx.conversation.incoming(msg);
|
||||
}
|
||||
|
||||
export async function QueueStart(ctx: QueueCommandContext, name: string) {
|
||||
const manager = new QueueManager(ctx.queues.registry);
|
||||
const names = manager.resolve(name);
|
||||
await manager.resume(names);
|
||||
const msg = ctx.l.r('cli.cmd.queue.resumed');
|
||||
await ctx.conversation.activity(msg);
|
||||
}
|
||||
|
||||
export async function QueueStop(ctx: QueueCommandContext, name?: string) {
|
||||
const manager = new QueueManager(ctx.queues.registry);
|
||||
const names = manager.resolve(name);
|
||||
await manager.pause(names);
|
||||
const msg = ctx.l.r('cli.cmd.queue.paused');
|
||||
await ctx.conversation.activity(msg);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
export async function ServerStatus(ctx: CommandContext) {
|
||||
ctx.logger.info('Getting server version and status');
|
||||
const version = await ctx.waha.serverVersion();
|
||||
const status = await ctx.waha.serverStatus();
|
||||
const text = ctx.l.key(TKey.APP_SERVER_VERSION_AND_STATUS).render({
|
||||
version: JSON.stringify(version, null, 2),
|
||||
status: JSON.stringify(status, null, 2),
|
||||
});
|
||||
await ctx.conversation.incoming(text);
|
||||
}
|
||||
|
||||
export async function ServerReboot(ctx: CommandContext, force: boolean) {
|
||||
const type = force ? 'force' : 'graceful';
|
||||
const tkey = force ? TKey.APP_SERVER_REBOOT_FORCE : TKey.APP_SERVER_REBOOT;
|
||||
ctx.logger.info(`Rebooting server (${type})`);
|
||||
const text = ctx.l.key(tkey).render();
|
||||
await ctx.conversation.incoming(text);
|
||||
await ctx.waha.serverReboot(false);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { SessionStatusEmoji } from '@waha/apps/chatwoot/emoji';
|
||||
import { AttachmentFromBuffer } from '@waha/apps/chatwoot/client/messages';
|
||||
import { MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
|
||||
export async function SessionRestart(ctx: CommandContext) {
|
||||
await ctx.waha.restart(ctx.data.session);
|
||||
}
|
||||
|
||||
export async function SessionStart(ctx: CommandContext) {
|
||||
return SessionRestart(ctx);
|
||||
}
|
||||
|
||||
export async function SessionLogout(ctx: CommandContext) {
|
||||
ctx.logger.info(`Logging out session ${ctx.data.session}`);
|
||||
await ctx.waha.logout(ctx.data.session);
|
||||
const text = ctx.l.key(TKey.APP_LOGOUT_SUCCESS).render();
|
||||
await ctx.conversation.incoming(text);
|
||||
}
|
||||
|
||||
export async function SessionStop(ctx: CommandContext) {
|
||||
ctx.logger.info(`Stopping session ${ctx.data.session}`);
|
||||
await ctx.waha.stop(ctx.data.session);
|
||||
}
|
||||
|
||||
export async function SessionStatus(ctx: CommandContext) {
|
||||
ctx.logger.info(`Getting status for session ${ctx.data.session}`);
|
||||
const session = await ctx.waha.get(ctx.data.session);
|
||||
const emoji = SessionStatusEmoji(session.status);
|
||||
const text = ctx.l.key(TKey.APP_SESSION_CURRENT_STATUS).render({
|
||||
emoji: emoji,
|
||||
session: session.name,
|
||||
status: session.status,
|
||||
name: session.me?.pushName || 'Unknown',
|
||||
id: session.me?.id || 'No phone number',
|
||||
});
|
||||
await ctx.conversation.incoming(text);
|
||||
}
|
||||
|
||||
export async function SessionQR(ctx: CommandContext) {
|
||||
const content = await ctx.waha.qr(ctx.data.session);
|
||||
const message = AttachmentFromBuffer(content, 'qr.jpg');
|
||||
message.message_type = MessageType.INCOMING;
|
||||
await ctx.conversation.send(message);
|
||||
}
|
||||
|
||||
export async function SessionScreenshot(ctx: CommandContext) {
|
||||
ctx.logger.info(`Getting screenshot for session ${ctx.data.session}`);
|
||||
const content = await ctx.waha.screenshot(ctx.data.session);
|
||||
const message = AttachmentFromBuffer(content, 'screenshot.jpg');
|
||||
message.message_type = MessageType.INCOMING;
|
||||
await ctx.conversation.send(message);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CommanderError } from 'commander';
|
||||
import { parse } from 'shell-quote';
|
||||
import { BufferedOutput } from '@waha/apps/chatwoot/cli/utils/BufferedOutput';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { ChatWootCommandsConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { BuildProgram } from '@waha/apps/chatwoot/cli/program.a';
|
||||
import { CHATWOOT_COMMAND_PREFIX } from '@waha/apps/chatwoot/env';
|
||||
|
||||
export async function runText(
|
||||
commands: ChatWootCommandsConfig,
|
||||
ctx: CommandContext,
|
||||
text: string,
|
||||
) {
|
||||
// Prepare text for cli like command
|
||||
text = text
|
||||
.split('\n') // split into lines
|
||||
.map((line) => line.trimStart()) // remove indentation
|
||||
.join(' ') // join with space
|
||||
.trim(); // final cleanup
|
||||
const output = new BufferedOutput();
|
||||
const program = BuildProgram(commands, ctx, output);
|
||||
const argv = parse(text);
|
||||
try {
|
||||
await program.parseAsync(argv, { from: 'user' });
|
||||
} catch (err) {
|
||||
if (err instanceof CommanderError) {
|
||||
return output;
|
||||
} else {
|
||||
throw err; // unexpected bug in your action code
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export const CommandPrefix = CHATWOOT_COMMAND_PREFIX;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ChatWootCommandsConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { Argument, Command, Option, OutputConfiguration } from 'commander';
|
||||
import {
|
||||
buildFormatHelp,
|
||||
fullCommandPath,
|
||||
} from '@waha/apps/chatwoot/cli/utils/help';
|
||||
import { AddSessionCommand } from '@waha/apps/chatwoot/cli/program.session';
|
||||
import { AddContactsCommand } from '@waha/apps/chatwoot/cli/program.contacts';
|
||||
import { AddMessagesCommand } from '@waha/apps/chatwoot/cli/program.messages';
|
||||
import { AddServerCommand } from '@waha/apps/chatwoot/cli/program.server';
|
||||
import { AddQueueCommand } from '@waha/apps/chatwoot/cli/program.queue';
|
||||
|
||||
function Program(ctx: CommandContext, output: OutputConfiguration) {
|
||||
const l = ctx.l;
|
||||
const program = new Command();
|
||||
program
|
||||
.name('')
|
||||
.description(l.r('cli.cmd.root.description'))
|
||||
.exitOverride()
|
||||
.configureOutput(output)
|
||||
.showSuggestionAfterError(true)
|
||||
.configureHelp({
|
||||
helpWidth: 200,
|
||||
styleOptionTerm(str: string) {
|
||||
return `- \`${str}\``;
|
||||
},
|
||||
styleArgumentTerm(str: string) {
|
||||
return `- **${str}**`;
|
||||
},
|
||||
styleUsage(str) {
|
||||
return `**${str}**`;
|
||||
},
|
||||
styleSubcommandTerm(str: string) {
|
||||
return `- **${str}**`;
|
||||
},
|
||||
styleSubcommandDescription(str: string) {
|
||||
return str ? `- ${str}` : str;
|
||||
},
|
||||
subcommandTerm: fullCommandPath,
|
||||
commandUsage(cmd) {
|
||||
// For the root program - no usage
|
||||
if (!cmd.parent)
|
||||
return `${l.r('cli.usage.command')} ${l.r('cli.usage.options')}`;
|
||||
// For subcommands, keep default behavior
|
||||
if (cmd.commands.length > 0) {
|
||||
return `${fullCommandPath(cmd)} ${l.r('cli.usage.command')} ${l.r(
|
||||
'cli.usage.options',
|
||||
)}`;
|
||||
}
|
||||
return `${fullCommandPath(cmd)} ${l.r('cli.usage.options')}`;
|
||||
},
|
||||
formatHelp: buildFormatHelp(l),
|
||||
});
|
||||
return program;
|
||||
}
|
||||
|
||||
export function BuildProgram(
|
||||
commands: ChatWootCommandsConfig,
|
||||
ctx: CommandContext,
|
||||
output: OutputConfiguration,
|
||||
) {
|
||||
const program = Program(ctx, output);
|
||||
AddSessionCommand(program, ctx);
|
||||
AddContactsCommand(program, ctx);
|
||||
AddMessagesCommand(program, ctx, commands.queue);
|
||||
AddQueueCommand(program, ctx, commands.queue);
|
||||
AddServerCommand(program, ctx, commands.server);
|
||||
return program;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Argument, Command, Option } from 'commander';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import {
|
||||
JobAttemptsOption,
|
||||
JobTimeoutOption,
|
||||
ParseMS,
|
||||
NotNegativeNumber,
|
||||
ProgressOption,
|
||||
} from '@waha/apps/chatwoot/cli/utils/options';
|
||||
import {
|
||||
ContactsPullStart,
|
||||
ContactsPullStatus,
|
||||
} from '@waha/apps/chatwoot/cli/cmd.contacts';
|
||||
import { ContactsPullOptions } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
|
||||
import { JobsOptions } from 'bullmq';
|
||||
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
|
||||
|
||||
export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
||||
const l = ctx.l;
|
||||
const SyncGroup = l.r('cli.cmd.root.sub.sync');
|
||||
program.commandsGroup(SyncGroup);
|
||||
|
||||
program
|
||||
.command('contacts')
|
||||
.summary(l.r('cli.cmd.contacts.summary'))
|
||||
.description(l.r('cli.cmd.contacts.description'))
|
||||
.helpGroup(SyncGroup)
|
||||
.addArgument(
|
||||
new Argument(
|
||||
'[action]',
|
||||
l.r('cli.cmd.contacts.action.description'),
|
||||
).choices(['pull', 'status', 'help']),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
'-a, --avatar <mode>',
|
||||
l.r('cli.cmd.contacts.pull.option.avatar'),
|
||||
)
|
||||
.choices(['if-missing', 'update'])
|
||||
.preset('if-missing'),
|
||||
)
|
||||
.option('-g, --groups', l.r('cli.cmd.contacts.pull.option.groups'))
|
||||
.option('-l, --lids', l.r('cli.cmd.contacts.pull.option.lids'))
|
||||
.option(
|
||||
'--na, --no-attributes',
|
||||
l.r('cli.cmd.contacts.pull.option.no-attributes'),
|
||||
)
|
||||
.option(
|
||||
'-b, --batch <number>',
|
||||
l.r('cli.cmd.contacts.pull.option.batch'),
|
||||
NotNegativeNumber,
|
||||
100,
|
||||
)
|
||||
.addOption(
|
||||
ProgressOption(l.r('cli.cmd.contacts.pull.option.progress'), 100),
|
||||
)
|
||||
.addOption(new JobAttemptsOption(l, 6))
|
||||
.addOption(new JobTimeoutOption(l, '10m'))
|
||||
.addOption(
|
||||
new Option(
|
||||
'--dc, --delay-contact <duration>',
|
||||
l.r('cli.cmd.contacts.pull.option.delay-contact'),
|
||||
)
|
||||
.argParser(ParseMS)
|
||||
.default(ParseMS('0.1s'), '0.1s'),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
'--db, --delay-batch <duration>',
|
||||
l.r('cli.cmd.contacts.pull.option.delay-batch'),
|
||||
)
|
||||
.argParser(ParseMS)
|
||||
.default(ParseMS('1s'), '1s'),
|
||||
)
|
||||
.action(async (action, opts, cmd: Command) => {
|
||||
if (!action) {
|
||||
cmd.outputHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'help') {
|
||||
cmd.outputHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'status') {
|
||||
await ContactsPullStatus(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const options: ContactsPullOptions = {
|
||||
batch: opts.batch,
|
||||
progress: opts.progress,
|
||||
avatar: opts.avatar,
|
||||
attributes: opts.attributes,
|
||||
contacts: {
|
||||
lids: opts.lids,
|
||||
groups: opts.groups,
|
||||
},
|
||||
delay: {
|
||||
contact: opts.delayContact,
|
||||
batch: opts.delayBatch,
|
||||
},
|
||||
};
|
||||
const jobOptions: JobsOptions & JobDataTimeout = {
|
||||
attempts: opts.attempts,
|
||||
timeout: {
|
||||
job: opts.timeout,
|
||||
},
|
||||
};
|
||||
await ContactsPullStart(ctx, options, jobOptions);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Argument, Command, Option } from 'commander';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import {
|
||||
JobAttemptsOption,
|
||||
JobTimeoutOption,
|
||||
ParseMS,
|
||||
NotNegativeNumber,
|
||||
ProgressOption,
|
||||
} from '@waha/apps/chatwoot/cli/utils/options';
|
||||
import {
|
||||
MessagesPullStart,
|
||||
MessagesPullStatus,
|
||||
} from '@waha/apps/chatwoot/cli/cmd.messages';
|
||||
import { MessagesPullOptions } from '@waha/apps/chatwoot/consumers/task/messages.pull';
|
||||
import { JobsOptions } from 'bullmq';
|
||||
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
|
||||
|
||||
export function AddMessagesCommand(
|
||||
program: Command,
|
||||
ctx: CommandContext,
|
||||
queue: boolean,
|
||||
) {
|
||||
const l = ctx.l;
|
||||
const SyncGroup = l.r('cli.cmd.root.sub.sync');
|
||||
program.commandsGroup(SyncGroup);
|
||||
|
||||
program
|
||||
.command('messages')
|
||||
.summary(l.r('cli.cmd.messages.summary'))
|
||||
.description(l.r('cli.cmd.messages.description'))
|
||||
.helpGroup(SyncGroup)
|
||||
.addArgument(
|
||||
new Argument(
|
||||
'[action]',
|
||||
l.r('cli.cmd.messages.action.description'),
|
||||
).choices(['pull', 'status', 'help']),
|
||||
)
|
||||
.addArgument(
|
||||
new Argument('[end]', l.r('cli.cmd.messages.pull.argument.end'))
|
||||
.argParser(ParseMS)
|
||||
.default(ParseMS('1d'), '1d'),
|
||||
)
|
||||
.addArgument(
|
||||
new Argument('[start]', l.r('cli.cmd.messages.pull.argument.start'))
|
||||
.argParser(ParseMS)
|
||||
.default(ParseMS('0d'), '0d'),
|
||||
)
|
||||
.option(
|
||||
'-c, --chat <chat>',
|
||||
l.r('cli.cmd.messages.pull.option.chat'),
|
||||
'all',
|
||||
)
|
||||
.option('-f, --force', l.r('cli.cmd.messages.pull.option.force'))
|
||||
.option('--nd, --no-dm', l.r('cli.cmd.messages.pull.option.no-dm'))
|
||||
.option('-g, --groups', l.r('cli.cmd.messages.pull.option.groups'))
|
||||
.option('--ch, --channels', l.r('cli.cmd.messages.pull.option.channels'))
|
||||
.option('-s, --status', l.r('cli.cmd.messages.pull.option.status'))
|
||||
.option('--bc, --broadcast', l.r('cli.cmd.messages.pull.option.broadcast'))
|
||||
.option('-m, --media', l.r('cli.cmd.messages.pull.option.media'))
|
||||
.option('--pause', l.r('cli.cmd.messages.pull.option.pause'))
|
||||
.option(
|
||||
'--rc, --resolve-conversations',
|
||||
l.r('cli.cmd.messages.pull.option.resolve-conversations'),
|
||||
)
|
||||
.option(
|
||||
'-b, --batch <number>',
|
||||
l.r('cli.cmd.messages.pull.option.batch'),
|
||||
NotNegativeNumber,
|
||||
100 as any,
|
||||
)
|
||||
.addOption(ProgressOption(l.r('cli.cmd.messages.pull.option.progress')))
|
||||
.addOption(new JobAttemptsOption(l, 6))
|
||||
.addOption(new JobTimeoutOption(l, '10m'))
|
||||
.addOption(
|
||||
new Option(
|
||||
'--tm, --timeout-media <duration>',
|
||||
l.r('cli.cmd.messages.pull.option.timeout-media'),
|
||||
)
|
||||
.argParser(ParseMS)
|
||||
.default(ParseMS('30s'), '30s'),
|
||||
)
|
||||
.action(async (action, end, start, opts, cmd: Command) => {
|
||||
if (!action) {
|
||||
cmd.outputHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'help') {
|
||||
cmd.outputHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'status') {
|
||||
await MessagesPullStatus(ctx);
|
||||
return;
|
||||
}
|
||||
if (end > start) {
|
||||
// Swap
|
||||
const tmp = end;
|
||||
end = start;
|
||||
start = tmp;
|
||||
}
|
||||
|
||||
if (opts.pause && !queue) {
|
||||
await ctx.conversation.incoming(
|
||||
l.r('cli.cmd.messages.pull.error.pause-no-queue'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const options: MessagesPullOptions = {
|
||||
chat: opts.chat,
|
||||
progress: opts.progress,
|
||||
period: {
|
||||
end: end,
|
||||
start: start,
|
||||
},
|
||||
media: opts.media,
|
||||
force: opts.force,
|
||||
pause: opts.pause,
|
||||
timeout: {
|
||||
media: opts.timeoutMedia,
|
||||
},
|
||||
batch: opts.batch,
|
||||
ignore: {
|
||||
dm: !opts.dm,
|
||||
status: !opts.status,
|
||||
groups: !opts.groups,
|
||||
channels: !opts.channels,
|
||||
broadcast: !opts.broadcast,
|
||||
},
|
||||
resolveConversations: opts.resolveConversations,
|
||||
};
|
||||
const jobOptions: JobsOptions & JobDataTimeout = {
|
||||
attempts: opts.attempts,
|
||||
timeout: {
|
||||
job: opts.timeout,
|
||||
},
|
||||
};
|
||||
await MessagesPullStart(ctx, options, jobOptions);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Argument, Command } from 'commander';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { CommandDisabled } from '@waha/apps/chatwoot/cli/cmd.disabled';
|
||||
import {
|
||||
QueueStart,
|
||||
QueueStatus,
|
||||
QueueStop,
|
||||
} from '@waha/apps/chatwoot/cli/cmd.queue';
|
||||
|
||||
export function AddQueueCommand(
|
||||
program: Command,
|
||||
ctx: CommandContext,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const l = ctx.l;
|
||||
const QueueGroup = l.r('cli.cmd.root.sub.queue');
|
||||
program.commandsGroup(QueueGroup);
|
||||
|
||||
program
|
||||
.command('queue', { hidden: !enabled })
|
||||
.alias('q')
|
||||
.summary(l.r('cli.cmd.queue.summary'))
|
||||
.description(l.r('cli.cmd.queue.description'))
|
||||
.helpGroup(QueueGroup)
|
||||
.addArgument(
|
||||
new Argument('[action]', l.r('cli.cmd.queue.action.description'))
|
||||
.choices(['status', 'start', 'stop', 'help'])
|
||||
.default('help'),
|
||||
)
|
||||
.addArgument(
|
||||
new Argument('[name]', l.r('cli.cmd.queue.argument.name')).default(''),
|
||||
)
|
||||
.action(async function (this: Command, action: string, name: string) {
|
||||
if (!action) {
|
||||
this.outputHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'help') {
|
||||
this.outputHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
await CommandDisabled(ctx, 'queue');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'status') {
|
||||
await QueueStatus(ctx, name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'start') {
|
||||
await QueueStart(ctx, name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'stop') {
|
||||
await QueueStop(ctx, name);
|
||||
return;
|
||||
}
|
||||
|
||||
this.outputHelp();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Command } from 'commander';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { CommandDisabled } from '@waha/apps/chatwoot/cli/cmd.disabled';
|
||||
import { ServerReboot, ServerStatus } from '@waha/apps/chatwoot/cli/cmd.server';
|
||||
|
||||
export function AddServerCommand(
|
||||
program: Command,
|
||||
ctx: CommandContext,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const l = ctx.l;
|
||||
const ServerGroup = l.r('cli.cmd.root.sub.server');
|
||||
program.commandsGroup(ServerGroup);
|
||||
|
||||
const server = program
|
||||
.command('server', { hidden: !enabled })
|
||||
.description(l.r('cli.cmd.server.description'))
|
||||
.helpGroup(ServerGroup);
|
||||
if (!enabled) {
|
||||
// Do not show help, show disabled ASAP
|
||||
server.action(() => CommandDisabled(ctx, 'server'));
|
||||
}
|
||||
server
|
||||
.command('status')
|
||||
.description(l.r('cli.cmd.server.status.description'))
|
||||
.action(() =>
|
||||
enabled ? ServerStatus(ctx) : CommandDisabled(ctx, 'server status'),
|
||||
);
|
||||
server
|
||||
.command('reboot')
|
||||
.description(l.r('cli.cmd.server.reboot.description'))
|
||||
.option('-f, --force', l.r('cli.cmd.server.reboot.option.force'), false)
|
||||
.action((opts) =>
|
||||
enabled
|
||||
? ServerReboot(ctx, opts.force)
|
||||
: CommandDisabled(ctx, 'server reboot'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Command } from 'commander';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import {
|
||||
SessionLogout,
|
||||
SessionQR,
|
||||
SessionRestart,
|
||||
SessionScreenshot,
|
||||
SessionStart,
|
||||
SessionStatus,
|
||||
SessionStop,
|
||||
} from '@waha/apps/chatwoot/cli/cms.session';
|
||||
|
||||
export function AddSessionCommand(program: Command, ctx: CommandContext) {
|
||||
const l = ctx.l;
|
||||
const SessionGroup = l.r('cli.cmd.root.sub.session');
|
||||
program.commandsGroup(SessionGroup);
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.alias('1')
|
||||
.description(l.r('cli.cmd.session.status.description'))
|
||||
.helpGroup(SessionGroup)
|
||||
.action(() => SessionStatus(ctx));
|
||||
program
|
||||
.command('restart')
|
||||
.alias('2')
|
||||
.description(l.r('cli.cmd.session.restart.description'))
|
||||
.helpGroup(SessionGroup)
|
||||
.action(() => SessionRestart(ctx));
|
||||
program
|
||||
.command('start')
|
||||
.alias('3')
|
||||
.description(l.r('cli.cmd.session.start.description'))
|
||||
.helpGroup(SessionGroup)
|
||||
.action(() => SessionStart(ctx));
|
||||
program
|
||||
.command('stop')
|
||||
.alias('4')
|
||||
.description(l.r('cli.cmd.session.stop.description'))
|
||||
.helpGroup(SessionGroup)
|
||||
.action(() => SessionStop(ctx));
|
||||
program
|
||||
.command('logout')
|
||||
.alias('5')
|
||||
.description(l.r('cli.cmd.session.logout.description'))
|
||||
.helpGroup(SessionGroup)
|
||||
.action(() => SessionLogout(ctx));
|
||||
program
|
||||
.command('qr')
|
||||
.alias('6')
|
||||
.description(l.r('cli.cmd.session.qr.description'))
|
||||
.helpGroup(SessionGroup)
|
||||
.action(() => SessionQR(ctx));
|
||||
program
|
||||
.command('screenshot')
|
||||
.alias('7')
|
||||
.description(l.r('cli.cmd.session.screenshot.description'))
|
||||
.helpGroup(SessionGroup)
|
||||
.action(() => SessionScreenshot(ctx));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { Conversation } from '../client/Conversation';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { FlowProducer, Queue } from 'bullmq';
|
||||
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import { QueueRegistry } from '@waha/apps/chatwoot/services/QueueRegistry';
|
||||
|
||||
export interface CommandContext {
|
||||
data: InboxData;
|
||||
logger: ILogger;
|
||||
l: Locale;
|
||||
waha: WAHASelf;
|
||||
conversation: Conversation;
|
||||
queues: {
|
||||
registry: QueueRegistry;
|
||||
contactsPull: Queue;
|
||||
messagesPull: Queue;
|
||||
};
|
||||
flows: {
|
||||
messagesPull: FlowProducer;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { OutputConfiguration } from 'commander';
|
||||
|
||||
export class BufferedOutput implements OutputConfiguration {
|
||||
private outBuffer: string[] = [];
|
||||
private errBuffer: string[] = [];
|
||||
|
||||
writeOut = (str: string): void => {
|
||||
this.outBuffer.push(str);
|
||||
};
|
||||
|
||||
writeErr = (str: string): void => {
|
||||
this.errBuffer.push(str);
|
||||
};
|
||||
|
||||
outputError = (str: string, write: (s: string) => void) => {
|
||||
this.errBuffer.push(str);
|
||||
};
|
||||
|
||||
get out() {
|
||||
return this.outBuffer.join('');
|
||||
}
|
||||
|
||||
get err() {
|
||||
return this.errBuffer.join('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Command, Help, Option } from 'commander';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
|
||||
export function fullCommandPath(sub: Command): string {
|
||||
const parts: string[] = [];
|
||||
let cmd: Command | null = sub;
|
||||
// collect names up to (but not including) the root program
|
||||
const name = cmd.name();
|
||||
if (name) parts.push(name);
|
||||
while (cmd && cmd.parent) {
|
||||
cmd = cmd.parent!;
|
||||
const name = cmd.name();
|
||||
if (name && name != 'program') parts.push(name);
|
||||
}
|
||||
return parts.reverse().join(' ');
|
||||
}
|
||||
|
||||
export function buildFormatHelp(l: Locale) {
|
||||
/**
|
||||
* Commands first, then Arguments, then Options
|
||||
* @param cmd
|
||||
* @param helper
|
||||
*/
|
||||
|
||||
return function formatHelp(this: Help, cmd: Command, helper: Help) {
|
||||
const termWidth = helper.padWidth(cmd, helper);
|
||||
const helpWidth = helper.helpWidth ?? 80;
|
||||
|
||||
const callFormatItem = (term: string, description: string) =>
|
||||
helper.formatItem(term, termWidth, description, helper);
|
||||
let output: string[] = [];
|
||||
|
||||
const commandDescription = helper.commandDescription(cmd);
|
||||
if (commandDescription.length > 0) {
|
||||
output = output.concat([
|
||||
helper.boxWrap(
|
||||
helper.styleCommandDescription(commandDescription),
|
||||
helpWidth,
|
||||
),
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
const usage = helper.commandUsage(cmd);
|
||||
if (usage) {
|
||||
output.push(
|
||||
`${
|
||||
helper.styleTitle(l.r('cli.help.usage.title')) + '\n'
|
||||
} ${helper.styleUsage(helper.commandUsage(cmd))}`,
|
||||
);
|
||||
output.push('');
|
||||
}
|
||||
|
||||
const commandGroups = this.groupItems(
|
||||
cmd.commands as Command[],
|
||||
helper.visibleCommands(cmd),
|
||||
(sub) => sub.helpGroup() || l.r('cli.help.commands.defaultGroup'),
|
||||
);
|
||||
commandGroups.forEach((commands, group) => {
|
||||
if (!commands.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commandLines = commands.map((sub) => {
|
||||
const commandTerm = helper.styleSubcommandTerm(
|
||||
helper.subcommandTerm(sub),
|
||||
);
|
||||
const description = helper.subcommandDescription(sub);
|
||||
const styledDescription = description
|
||||
? helper.styleSubcommandDescription(description)
|
||||
: '';
|
||||
return `${commandTerm} ${styledDescription}`;
|
||||
});
|
||||
|
||||
output.push(helper.styleTitle(group));
|
||||
output.push(...commandLines);
|
||||
output.push('');
|
||||
});
|
||||
|
||||
const argumentList = helper
|
||||
.visibleArguments(cmd)
|
||||
.map((argument) =>
|
||||
callFormatItem(
|
||||
helper.styleArgumentTerm(helper.argumentTerm(argument)),
|
||||
helper.styleArgumentDescription(helper.argumentDescription(argument)),
|
||||
),
|
||||
);
|
||||
output = output.concat(
|
||||
this.formatItemList(
|
||||
l.r('cli.help.arguments.title'),
|
||||
argumentList,
|
||||
helper,
|
||||
),
|
||||
);
|
||||
|
||||
const optionGroups = this.groupItems(
|
||||
cmd.options as Option[],
|
||||
helper.visibleOptions(cmd),
|
||||
(option) =>
|
||||
option.helpGroupHeading ?? l.r('cli.help.options.defaultGroup'),
|
||||
);
|
||||
optionGroups.forEach((options, group) => {
|
||||
const optionList = options.map((option) =>
|
||||
callFormatItem(
|
||||
helper.styleOptionTerm(helper.optionTerm(option)),
|
||||
helper.styleOptionDescription(helper.optionDescription(option)),
|
||||
),
|
||||
);
|
||||
output = output.concat(this.formatItemList(group, optionList, helper));
|
||||
});
|
||||
|
||||
if (helper.showGlobalOptions) {
|
||||
const globalOptionList = helper
|
||||
.visibleGlobalOptions(cmd)
|
||||
.map((option) =>
|
||||
callFormatItem(
|
||||
helper.styleOptionTerm(helper.optionTerm(option)),
|
||||
helper.styleOptionDescription(helper.optionDescription(option)),
|
||||
),
|
||||
);
|
||||
output = output.concat(
|
||||
this.formatItemList(
|
||||
l.r('cli.help.globalOptions.title'),
|
||||
globalOptionList,
|
||||
helper,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as ms from 'ms';
|
||||
import { Option } from 'commander';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
|
||||
/**
|
||||
* Parse human string into milliseconds
|
||||
*/
|
||||
export function ParseMS(value: string) {
|
||||
const duration = ms(value);
|
||||
if (duration == null) throw new Error(`Invalid duration: "${value}"`);
|
||||
if (duration < 0) throw new Error(`Duration cannot be negative: "${value}"`);
|
||||
return duration;
|
||||
}
|
||||
|
||||
export function ParseSeconds(value: string): number {
|
||||
const duration = ParseMS(value);
|
||||
return Math.floor(duration / 1000);
|
||||
}
|
||||
|
||||
export class JobAttemptsOption extends Option {
|
||||
constructor(l: Locale, def: number) {
|
||||
super('--at, --attempts <number>', l.r('cli.cmd.options.job.attempts'));
|
||||
this.argParser(NotNegativeNumber);
|
||||
this.default(def);
|
||||
}
|
||||
}
|
||||
|
||||
export class JobTimeoutOption extends Option {
|
||||
constructor(l: Locale, def: string) {
|
||||
super('-t, --timeout <duration>', l.r('cli.cmd.options.job.timeout'));
|
||||
this.argParser(ParseMS);
|
||||
this.default(ParseMS(def));
|
||||
}
|
||||
}
|
||||
|
||||
export function ProgressOption(
|
||||
description: string,
|
||||
def: number = 1000,
|
||||
): Option {
|
||||
return new Option('-p, --progress [number]', description)
|
||||
.argParser(NotNegativeNumber)
|
||||
.default(def);
|
||||
}
|
||||
|
||||
export function NotNegativeNumber(value: string): number {
|
||||
const n = parseInt(value, 10);
|
||||
if (isNaN(n)) {
|
||||
throw new Error(`Invalid number: "${value}"`);
|
||||
}
|
||||
if (n < 0) {
|
||||
throw new Error(`Number must be positive or 0: "${value}"`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function PositiveNumber(value: string): number {
|
||||
const n = parseInt(value, 10);
|
||||
if (isNaN(n)) {
|
||||
throw new Error(`Invalid number: "${value}"`);
|
||||
}
|
||||
if (n <= 0) {
|
||||
throw new Error(`Number must be positive: "${value}"`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export class ChatWootInboxNewAPI {
|
||||
constructor(
|
||||
private base: string,
|
||||
private inboxIdentifier: string,
|
||||
) {}
|
||||
|
||||
async updateLastSeen(sourceId: string, conversationId: number) {
|
||||
// TODO: Move to the lib (fork?)
|
||||
// Call the update_last_seen endpoint using a direct HTTP request
|
||||
// since the SDK doesn't have this method
|
||||
const path = `/public/api/v1/inboxes/${this.inboxIdentifier}/contacts/${sourceId}/conversations/${conversationId}/update_last_seen`;
|
||||
const url = new URL(path, this.base).toString();
|
||||
await axios.post(
|
||||
url,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
api_access_token: this.inboxIdentifier,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import ChatwootClient, {
|
||||
ApiError,
|
||||
ApiError as ChatWootAPIError,
|
||||
public_contact_create_update_payload,
|
||||
public_conversation,
|
||||
} from '@figuro/chatwoot-sdk';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { ContactAPI } from '@waha/apps/chatwoot/client/ContactAPI';
|
||||
import {
|
||||
AvatarUpdateMode,
|
||||
ContactService,
|
||||
} from '@waha/apps/chatwoot/client/ContactService';
|
||||
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||
import { ConversationAPI } from '@waha/apps/chatwoot/client/ConversationAPI';
|
||||
import {
|
||||
ContactIds,
|
||||
ConversationService,
|
||||
} from '@waha/apps/chatwoot/client/ConversationService';
|
||||
import { ChatWootAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
|
||||
import { InboxContactInfo } from '@waha/apps/chatwoot/contacts/InboxContactInfo';
|
||||
import { Locale } from '@waha/apps/chatwoot/locale';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
|
||||
import { CacheForConfig } from '../cache/ConversationCache';
|
||||
import { IConversationCache } from '../cache/IConversationCache';
|
||||
@@ -29,8 +34,8 @@ export class ContactConversationService {
|
||||
|
||||
constructor(
|
||||
private config: ChatWootAPIConfig,
|
||||
private contactAPI: ContactAPI,
|
||||
private conversationAPI: ConversationAPI,
|
||||
private contactService: ContactService,
|
||||
private conversationService: ConversationService,
|
||||
private accountAPI: ChatwootClient,
|
||||
private logger: ILogger,
|
||||
private l: Locale,
|
||||
@@ -38,9 +43,9 @@ export class ContactConversationService {
|
||||
this.cache = CacheForConfig(config);
|
||||
}
|
||||
|
||||
private async upsertByContactInfo(
|
||||
public async upsertByContactInfo(
|
||||
contactInfo: ContactInfo,
|
||||
): Promise<public_conversation> {
|
||||
): Promise<ContactIds> {
|
||||
const chatId = contactInfo.ChatId();
|
||||
|
||||
// Check cache for chat id
|
||||
@@ -48,64 +53,65 @@ export class ContactConversationService {
|
||||
return this.cache.get(chatId);
|
||||
}
|
||||
|
||||
//
|
||||
// Find or create contact
|
||||
//
|
||||
let contact = await this.contactAPI.searchByAnyID(chatId);
|
||||
if (!contact) {
|
||||
const request = await contactInfo.PublicContactCreate();
|
||||
contact = await this.contactAPI.create(chatId, request);
|
||||
}
|
||||
let [cwContact, created] =
|
||||
await this.contactService.findOrCreateContact(contactInfo);
|
||||
|
||||
// Update custom attributes - always
|
||||
const attributes = await contactInfo.Attributes();
|
||||
this.logger.info(
|
||||
`Updating contact custom attributes for chat.id: ${chatId}, contact.id: ${contact.data.id}`,
|
||||
this.logger.debug(
|
||||
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
|
||||
);
|
||||
await this.contactAPI.updateCustomAttributes(contact.data, attributes);
|
||||
|
||||
// Update Avatar if nothing, but keep the original one if any
|
||||
if (!contact.data.thumbnail) {
|
||||
const avatarUrl = await contactInfo.AvatarUrl();
|
||||
if (avatarUrl) {
|
||||
this.contactAPI.updateAvatarUrlSafe(contact.data.id, avatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`Using contact for chat.id: ${chatId}, contact.id: ${contact.sourceId}`,
|
||||
const attributes = await contactInfo.Attributes();
|
||||
await this.contactService.upsertCustomAttributes(
|
||||
cwContact.data,
|
||||
attributes,
|
||||
);
|
||||
await this.contactService.updateAvatar(
|
||||
cwContact,
|
||||
contactInfo,
|
||||
AvatarUpdateMode.IF_MISSING,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Using contact for chat.id: ${chatId}, contact.id: ${cwContact.data.id}, contact.sourceId: ${cwContact.sourceId}`,
|
||||
);
|
||||
|
||||
//
|
||||
// Get or create a conversation for this inbox
|
||||
//
|
||||
const conversation = await this.conversationAPI.upsert(contact.sourceId);
|
||||
this.logger.info(
|
||||
`Using conversation for chat.id: ${chatId}, conversation.id: ${conversation.id}, contact.id: ${contact.sourceId}`,
|
||||
const conversation = await this.conversationService.upsert({
|
||||
id: cwContact.data.id,
|
||||
sourceId: cwContact.sourceId,
|
||||
});
|
||||
this.logger.debug(
|
||||
`Using conversation for chat.id: ${chatId}, conversation.id: ${conversation.id}, contact.id: ${cwContact.sourceId}`,
|
||||
);
|
||||
|
||||
// Save to cache
|
||||
this.cache.set(chatId, conversation);
|
||||
return conversation;
|
||||
const ids = {
|
||||
id: conversation.id,
|
||||
sourceId: cwContact.sourceId,
|
||||
};
|
||||
this.cache.set(chatId, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async ConversationByContact(
|
||||
contactInfo: ContactInfo,
|
||||
): Promise<Conversation> {
|
||||
const chatId = contactInfo.ChatId();
|
||||
const publicConversation = await this.upsertByContactInfo(contactInfo);
|
||||
const ids = await this.upsertByContactInfo(contactInfo);
|
||||
const conversation = new Conversation(
|
||||
this.accountAPI,
|
||||
this.config.accountId,
|
||||
publicConversation.id,
|
||||
ids.id,
|
||||
ids.sourceId,
|
||||
);
|
||||
conversation.onError = (err) => {
|
||||
if (err instanceof ApiError) {
|
||||
if (err instanceof ChatWootAPIError) {
|
||||
// invalidate cache
|
||||
this.cache.delete(chatId);
|
||||
this.logger.error(`ApiError: ${err.message}`);
|
||||
this.logger.error(
|
||||
`ApiError occurred, invalidating cache for chat.id: ${chatId}, conversation.id: ${publicConversation.id}`,
|
||||
`ApiError occurred, invalidating cache for chat.id: ${chatId}, conversation.id: ${ids.id}, source.id: ${ids.sourceId}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -127,4 +133,33 @@ export class ContactConversationService {
|
||||
public async InboxNotifications() {
|
||||
return this.ConversationByContact(new InboxContactInfo(this.l));
|
||||
}
|
||||
|
||||
public ResetCache(chatIds: Array<string>) {
|
||||
this.logger.debug(`Resetting cache chat ids: ${chatIds.join(', ')}`);
|
||||
for (const chatId of chatIds) {
|
||||
this.cache.delete(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
public ResetMismatchedCache(chatIds: Array<string>, contactId: number) {
|
||||
for (const chatId of chatIds) {
|
||||
if (!this.cache.has(chatId)) {
|
||||
continue;
|
||||
}
|
||||
const current = this.cache.get(chatId);
|
||||
if (current.id !== contactId) {
|
||||
this.logger.debug(
|
||||
`Resetting cache for chat id: ${chatId}, value changed from ${current} to ${contactId}`,
|
||||
);
|
||||
this.cache.delete(chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async markConversationAsRead(
|
||||
conversationId: number,
|
||||
sourceId: string,
|
||||
): Promise<void> {
|
||||
await this.conversationService.markAsRead(conversationId, sourceId);
|
||||
}
|
||||
}
|
||||
+62
-9
@@ -13,13 +13,20 @@ import { isJidCusFormat } from '@waha/utils/wa';
|
||||
import * as lodash from 'lodash';
|
||||
|
||||
import { AttributeKey } from '../const';
|
||||
import { E164Parser } from '@waha/core/utils/PhoneJidNormalizer';
|
||||
import { ContactInfo } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
|
||||
export interface ContactResponse {
|
||||
data: generic_id & contact;
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export class ContactAPI {
|
||||
export enum AvatarUpdateMode {
|
||||
IF_MISSING,
|
||||
ALWAYS,
|
||||
}
|
||||
|
||||
export class ContactService {
|
||||
constructor(
|
||||
private config: ChatWootAPIConfig,
|
||||
private accountAPI: ChatwootClient,
|
||||
@@ -27,6 +34,20 @@ export class ContactAPI {
|
||||
private logger: ILogger,
|
||||
) {}
|
||||
|
||||
async findOrCreateContact(
|
||||
contactInfo: ContactInfo,
|
||||
): Promise<[ContactResponse, boolean]> {
|
||||
const chatId = contactInfo.ChatId();
|
||||
let contact = await this.searchByAnyID(chatId);
|
||||
if (contact) {
|
||||
return [contact, false];
|
||||
}
|
||||
|
||||
const request = await contactInfo.PublicContactCreate();
|
||||
contact = await this.create(chatId, request);
|
||||
return [contact, true];
|
||||
}
|
||||
|
||||
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
|
||||
const payload: any[] = [
|
||||
{
|
||||
@@ -64,8 +85,8 @@ export class ContactAPI {
|
||||
|
||||
if (isJidCusFormat(chatId)) {
|
||||
// Search by phone
|
||||
let phone_number = chatId.split('@')[0];
|
||||
phone_number = phone_number.replace('+', '');
|
||||
const phoneNumberE164 = E164Parser.fromJid(chatId);
|
||||
const phone_number = phoneNumberE164.replace('+', '');
|
||||
payload[payload.length - 1].query_operator = 'OR';
|
||||
payload.push({
|
||||
attribute_key: 'phone_number',
|
||||
@@ -96,18 +117,22 @@ export class ContactAPI {
|
||||
};
|
||||
}
|
||||
|
||||
public updateCustomAttributes(
|
||||
public async upsertCustomAttributes(
|
||||
contact: generic_id & contact,
|
||||
attributes: any,
|
||||
) {
|
||||
): Promise<boolean> {
|
||||
if (lodash.isEqual(attributes, contact.custom_attributes)) {
|
||||
return false;
|
||||
}
|
||||
const update: contact_update = {
|
||||
custom_attributes: { ...contact.custom_attributes, ...attributes },
|
||||
};
|
||||
return this.accountAPI.contacts.update({
|
||||
await this.accountAPI.contacts.update({
|
||||
id: contact.id,
|
||||
accountId: this.config.accountId,
|
||||
data: update,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public async create(
|
||||
@@ -131,8 +156,32 @@ export class ContactAPI {
|
||||
};
|
||||
}
|
||||
|
||||
public updateAvatarUrlSafe(contactId, avatarUrl: string) {
|
||||
this.accountAPI.contacts
|
||||
public async updateAvatar(
|
||||
contact: ContactResponse,
|
||||
contactInfo: ContactInfo,
|
||||
mode: AvatarUpdateMode,
|
||||
): Promise<boolean> {
|
||||
// Update Avatar if nothing, but keep the original one if any
|
||||
if (contact.data.thumbnail && mode == AvatarUpdateMode.IF_MISSING) {
|
||||
return false;
|
||||
}
|
||||
const chatId = contactInfo.ChatId();
|
||||
const avatarUrl = await contactInfo.AvatarUrl().catch((err) => {
|
||||
this.logger.warn(
|
||||
`Error getting avatar for chat.id from WhatsApp: ${chatId}`,
|
||||
);
|
||||
this.logger.warn(err);
|
||||
return null;
|
||||
});
|
||||
if (!avatarUrl) {
|
||||
return false;
|
||||
}
|
||||
const success = await this.updateAvatarUrlSafe(contact.data.id, avatarUrl);
|
||||
return success;
|
||||
}
|
||||
|
||||
public updateAvatarUrlSafe(contactId, avatarUrl: string): Promise<boolean> {
|
||||
return this.accountAPI.contacts
|
||||
.update({
|
||||
accountId: this.config.accountId,
|
||||
id: contactId,
|
||||
@@ -140,11 +189,15 @@ export class ContactAPI {
|
||||
avatar_url: avatarUrl,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
return true;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.warn(
|
||||
'Error updating avatar_url for contact.id: ' + contactId,
|
||||
`Error updating avatar_url for contact.id: ${contactId}`,
|
||||
);
|
||||
this.logger.warn(e);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,19 @@ import { MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
|
||||
export class Conversation {
|
||||
public onError: (e: any) => void;
|
||||
public overrideIncoming: any = null;
|
||||
|
||||
constructor(
|
||||
private accountAPI: ChatwootClient,
|
||||
private accountId: number,
|
||||
public conversationId: number,
|
||||
public sourceId: string | null = null,
|
||||
) {}
|
||||
|
||||
public async send(data: conversation_message_create) {
|
||||
if (data.message_type === MessageType.INCOMING && this.overrideIncoming) {
|
||||
data = { ...data, ...this.overrideIncoming };
|
||||
}
|
||||
try {
|
||||
const message = await this.accountAPI.messages.create({
|
||||
accountId: this.accountId,
|
||||
@@ -28,10 +33,13 @@ export class Conversation {
|
||||
}
|
||||
|
||||
public async incoming(text: string) {
|
||||
const data: conversation_message_create = {
|
||||
let data: conversation_message_create = {
|
||||
content: text,
|
||||
message_type: MessageType.INCOMING as any,
|
||||
};
|
||||
if (this.overrideIncoming) {
|
||||
data = { ...data, ...this.overrideIncoming };
|
||||
}
|
||||
return this.send(data);
|
||||
}
|
||||
|
||||
@@ -43,11 +51,22 @@ export class Conversation {
|
||||
return this.send(data);
|
||||
}
|
||||
|
||||
public async outgoing(text: string) {
|
||||
public async note(text: string) {
|
||||
const data: conversation_message_create = {
|
||||
content: text,
|
||||
private: true,
|
||||
message_type: MessageType.OUTGOING as any,
|
||||
};
|
||||
return this.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a note message (private outgoing) for all communications
|
||||
*/
|
||||
public forceNote() {
|
||||
this.overrideIncoming = {
|
||||
private: true,
|
||||
message_type: MessageType.OUTGOING as any,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { public_conversation } from '@figuro/chatwoot-sdk';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import {
|
||||
ChatWootAPIConfig,
|
||||
ChatWootInboxAPI,
|
||||
} from '@waha/apps/chatwoot/client/interfaces';
|
||||
|
||||
export class ConversationAPI {
|
||||
constructor(
|
||||
private config: ChatWootAPIConfig,
|
||||
private inboxAPI: ChatWootInboxAPI,
|
||||
private logger: ILogger,
|
||||
) {}
|
||||
|
||||
private async find(
|
||||
contactIdentifier: string,
|
||||
): Promise<public_conversation | null> {
|
||||
const request = {
|
||||
inboxIdentifier: this.config.inboxIdentifier,
|
||||
contactIdentifier: contactIdentifier,
|
||||
};
|
||||
const conversations = await this.inboxAPI.conversations.list(request);
|
||||
if (conversations.length != 0) {
|
||||
return conversations[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async create(
|
||||
contactIdentifier: string,
|
||||
): Promise<public_conversation> {
|
||||
const conversation = await this.inboxAPI.conversations.create({
|
||||
inboxIdentifier: this.config.inboxIdentifier,
|
||||
contactIdentifier: contactIdentifier,
|
||||
});
|
||||
this.logger.info(
|
||||
`Created conversation.id: ${conversation.id} for contact.id: ${contactIdentifier}`,
|
||||
);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async upsert(contactIdentifier: string): Promise<public_conversation> {
|
||||
let conversation = await this.find(contactIdentifier);
|
||||
if (!conversation) {
|
||||
conversation = await this.create(contactIdentifier);
|
||||
}
|
||||
this.logger.info(
|
||||
`Using conversation.id: ${conversation.id} for contact.id: ${contactIdentifier}`,
|
||||
);
|
||||
return conversation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import ChatwootClient, { contact_conversations } from '@figuro/chatwoot-sdk';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import {
|
||||
ChatWootAPIConfig,
|
||||
ChatWootInboxAPI,
|
||||
} from '@waha/apps/chatwoot/client/interfaces';
|
||||
import type { conversation } from '@figuro/chatwoot-sdk/dist/models/conversation';
|
||||
import { ConversationSelector } from '@waha/apps/chatwoot/services/ConversationSelector';
|
||||
import axios from 'axios';
|
||||
import { ChatWootInboxNewAPI } from '@waha/apps/chatwoot/client/ChatWootInboxNewAPI';
|
||||
|
||||
export type ConversationResult = Pick<conversation, 'id' | 'account_id'>;
|
||||
|
||||
export interface ContactIds {
|
||||
id: number;
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export class ConversationService {
|
||||
constructor(
|
||||
private config: ChatWootAPIConfig,
|
||||
private accountAPI: ChatwootClient,
|
||||
private inboxAPI: ChatWootInboxAPI,
|
||||
private selector: ConversationSelector,
|
||||
private logger: ILogger,
|
||||
) {}
|
||||
|
||||
private async find(contact: ContactIds): Promise<ConversationResult | null> {
|
||||
const result: { payload: contact_conversations } =
|
||||
(await this.accountAPI.contacts.listConversations({
|
||||
accountId: this.config.accountId,
|
||||
id: contact.id,
|
||||
})) as any;
|
||||
const conversations = result.payload;
|
||||
return this.selector.select(conversations);
|
||||
}
|
||||
|
||||
private async create(contact: ContactIds): Promise<ConversationResult> {
|
||||
const conversation = await this.inboxAPI.conversations.create({
|
||||
inboxIdentifier: this.config.inboxIdentifier,
|
||||
contactIdentifier: contact.sourceId,
|
||||
});
|
||||
this.logger.debug(
|
||||
`Created conversation.id: ${conversation.id} for contact.id: ${contact.id}, contact.sourceId: ${contact.sourceId}`,
|
||||
);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async upsert(contact: ContactIds): Promise<ConversationResult> {
|
||||
let conversation = await this.find(contact);
|
||||
if (!conversation) {
|
||||
conversation = await this.create(contact);
|
||||
}
|
||||
this.logger.debug(
|
||||
`Using conversation.id: ${conversation.id} for contact.id: ${contact.id}, contact.sourceId: ${contact.sourceId}`,
|
||||
);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async markAsRead(conversationId: number, sourceId: string): Promise<void> {
|
||||
try {
|
||||
const inboxNewAPI = new ChatWootInboxNewAPI(
|
||||
this.config.url,
|
||||
this.config.inboxIdentifier,
|
||||
);
|
||||
await inboxNewAPI.updateLastSeen(sourceId, conversationId);
|
||||
this.logger.info(
|
||||
`Marked conversation.id: ${conversationId} as read in inbox: ${this.config.inboxIdentifier} for contact: ${sourceId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`Error marking conversation.id: ${conversationId} as read: ${reason}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(conversationId: number): Promise<any> {
|
||||
return this.accountAPI.conversations.toggleStatus({
|
||||
accountId: this.config.accountId,
|
||||
conversationId: conversationId,
|
||||
data: {
|
||||
status: 'resolved',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import ChatwootClient from '@figuro/chatwoot-sdk';
|
||||
import { ChatWootAccountAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
|
||||
import {
|
||||
CustomAttributeModel,
|
||||
CustomAttributeType,
|
||||
} from '@waha/apps/chatwoot/client/types';
|
||||
|
||||
export interface CustomAttribute {
|
||||
key: string;
|
||||
name: string;
|
||||
type: CustomAttributeType;
|
||||
description: string;
|
||||
model: CustomAttributeModel;
|
||||
}
|
||||
|
||||
export class CustomAttributesAPI {
|
||||
constructor(
|
||||
private config: ChatWootAccountAPIConfig,
|
||||
private accountAPI: ChatwootClient,
|
||||
) {}
|
||||
|
||||
async upsert(attribute: CustomAttribute): Promise<void> {
|
||||
const attributes = await this.accountAPI.customAttributes.list({
|
||||
accountId: this.config.accountId,
|
||||
attributeModel: String(attribute.model) as '0' | '1',
|
||||
});
|
||||
const existing = attributes.find((a) => a.attribute_key === attribute.key);
|
||||
|
||||
if (existing) {
|
||||
await this.accountAPI.customAttributes.update({
|
||||
accountId: this.config.accountId,
|
||||
id: existing.id,
|
||||
data: {
|
||||
attribute_key: attribute.key,
|
||||
attribute_display_name: attribute.name,
|
||||
attribute_display_type: attribute.type,
|
||||
attribute_description: attribute.description,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.accountAPI.customAttributes.create({
|
||||
accountId: this.config.accountId,
|
||||
data: {
|
||||
attribute_key: attribute.key,
|
||||
attribute_display_name: attribute.name,
|
||||
attribute_display_type: attribute.type,
|
||||
attribute_description: attribute.description,
|
||||
attribute_model: attribute.model,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import ChatwootClient from '@figuro/chatwoot-sdk';
|
||||
import { ChatWootAccountAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
|
||||
import {
|
||||
CustomAttributeModel,
|
||||
CustomAttributeType,
|
||||
} from '@waha/apps/chatwoot/client/types';
|
||||
import type { custom_attribute } from '@figuro/chatwoot-sdk/dist/models/custom_attribute';
|
||||
|
||||
export interface CustomAttribute {
|
||||
key: string;
|
||||
name: string;
|
||||
type: CustomAttributeType;
|
||||
description: string;
|
||||
model: CustomAttributeModel;
|
||||
}
|
||||
|
||||
export class CustomAttributesService {
|
||||
constructor(
|
||||
private config: ChatWootAccountAPIConfig,
|
||||
private accountAPI: ChatwootClient,
|
||||
) {}
|
||||
|
||||
async upsert(attributes: Array<CustomAttribute>): Promise<void> {
|
||||
const current: Record<CustomAttributeModel, Array<custom_attribute>> = {
|
||||
[CustomAttributeModel.CONVERSATION]: [],
|
||||
[CustomAttributeModel.CONTACT]: [],
|
||||
};
|
||||
const models = [
|
||||
CustomAttributeModel.CONVERSATION,
|
||||
CustomAttributeModel.CONTACT,
|
||||
];
|
||||
for (const model of models) {
|
||||
current[model] = await this.accountAPI.customAttributes.list({
|
||||
accountId: this.config.accountId,
|
||||
attributeModel: String(model) as '0' | '1',
|
||||
});
|
||||
}
|
||||
|
||||
for (const attribute of attributes) {
|
||||
const existing = current[attribute.model].find(
|
||||
(a) => a.attribute_key === attribute.key,
|
||||
);
|
||||
if (existing) {
|
||||
await this.accountAPI.customAttributes.update({
|
||||
accountId: this.config.accountId,
|
||||
id: existing.id,
|
||||
data: {
|
||||
attribute_key: attribute.key,
|
||||
attribute_display_name: attribute.name,
|
||||
attribute_display_type: attribute.type,
|
||||
attribute_description: attribute.description,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.accountAPI.customAttributes.create({
|
||||
accountId: this.config.accountId,
|
||||
data: {
|
||||
attribute_key: attribute.key,
|
||||
attribute_display_name: attribute.name,
|
||||
attribute_display_type: attribute.type,
|
||||
attribute_description: attribute.description,
|
||||
attribute_model: attribute.model,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
|
||||
import { AttributeKey } from '@waha/apps/chatwoot/const';
|
||||
import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
|
||||
import * as lodash from 'lodash';
|
||||
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
|
||||
import { buildMessageId } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { isLidUser } from '@waha/core/utils/jids';
|
||||
|
||||
export function GetJID(contact: any): string | null {
|
||||
return contact?.custom_attributes?.[AttributeKey.WA_JID];
|
||||
@@ -15,6 +16,29 @@ export function GetChatID(contact: any): string | null {
|
||||
return contact?.custom_attributes?.[AttributeKey.WA_CHAT_ID];
|
||||
}
|
||||
|
||||
export function GetAllChatIDs(contact: any): Array<string> {
|
||||
const attrs = contact?.custom_attributes || {};
|
||||
const ids = [
|
||||
attrs[AttributeKey.WA_CHAT_ID],
|
||||
attrs[AttributeKey.WA_JID],
|
||||
attrs[AttributeKey.WA_LID],
|
||||
];
|
||||
return lodash.uniq(ids.filter(Boolean));
|
||||
}
|
||||
|
||||
export function FindChatID(contact: any): string | null {
|
||||
if (GetJID(contact)) {
|
||||
return GetJID(contact);
|
||||
}
|
||||
if (GetLID(contact)) {
|
||||
return GetLID(contact);
|
||||
}
|
||||
if (GetChatID(contact)) {
|
||||
return GetChatID(contact);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SerializeWhatsAppKey(message: WhatsAppMessage): string {
|
||||
const key = {
|
||||
id: message.message_id,
|
||||
@@ -32,3 +56,9 @@ export function ContactAttr(chatId: string): AttributeKey {
|
||||
return AttributeKey.WA_JID;
|
||||
}
|
||||
}
|
||||
|
||||
export function IsCommandsChat(body): boolean {
|
||||
const sender = body?.conversation?.meta?.sender;
|
||||
const chatId = FindChatID(sender);
|
||||
return chatId === INBOX_CONTACT_CHAT_ID;
|
||||
}
|
||||
@@ -51,3 +51,10 @@ export enum CustomAttributeModel {
|
||||
CONVERSATION = 0,
|
||||
CONTACT = 1,
|
||||
}
|
||||
|
||||
export enum ConversationStatus {
|
||||
OPEN = 'open',
|
||||
PENDING = 'pending',
|
||||
SNOOZED = 'snoozed',
|
||||
RESOLVED = 'resolved',
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CustomAttribute } from '@waha/apps/chatwoot/client/CustomAttributesAPI';
|
||||
import { CustomAttribute } from '@waha/apps/chatwoot/client/CustomAttributesService';
|
||||
|
||||
import { CustomAttributeModel, CustomAttributeType } from './client/types';
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ export enum QueueName {
|
||||
SCHEDULED_MESSAGE_CLEANUP = 'chatwoot.scheduled | message.cleanup',
|
||||
SCHEDULED_CHECK_VERSION = 'chatwoot.scheduled | check.version',
|
||||
|
||||
//
|
||||
// Task
|
||||
//
|
||||
TASK_CONTACTS_PULL = 'chatwoot.task | contacts.pull',
|
||||
TASK_MESSAGES_PULL = 'chatwoot.task | messages.pull',
|
||||
|
||||
//
|
||||
// WAHA Events
|
||||
//
|
||||
@@ -13,14 +19,21 @@ export enum QueueName {
|
||||
WAHA_MESSAGE_REACTION = 'chatwoot.waha | message.reaction',
|
||||
WAHA_MESSAGE_EDITED = 'chatwoot.waha | message.edited',
|
||||
WAHA_MESSAGE_REVOKED = 'chatwoot.waha | message.revoked',
|
||||
WAHA_MESSAGE_ACK = 'chatwoot.waha | message.ack',
|
||||
//
|
||||
// ChatWoot Events - Real
|
||||
//
|
||||
INBOX_MESSAGE_CREATED = 'chatwoot.inbox | message_created',
|
||||
INBOX_MESSAGE_UPDATED = 'chatwoot.inbox | message_updated',
|
||||
INBOX_CONVERSATION_CREATED = 'chatwoot.inbox | conversation_created',
|
||||
INBOX_CONVERSATION_STATUS_CHANGED = 'chatwoot.inbox | conversation_status_changed',
|
||||
//
|
||||
// ChatWoot Events - Artificial
|
||||
//
|
||||
INBOX_MESSAGE_DELETED = 'chatwoot.inbox | message_deleted',
|
||||
INBOX_COMMANDS = 'chatwoot.inbox | commands',
|
||||
}
|
||||
|
||||
export enum FlowProducerName {
|
||||
MESSAGES_PULL_FLOW = 'messages.pull.flow',
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppConsumer } from '@waha/apps/app_sdk/AppConsumer';
|
||||
import { JobLoggerWrapper } from '@waha/apps/app_sdk/JobLoggerWrapper';
|
||||
import { HasBeenRetried } from '@waha/apps/app_sdk/JobUtils';
|
||||
import { GetChatID } from '@waha/apps/chatwoot/client/ids';
|
||||
import { FindChatID } from '@waha/apps/chatwoot/client/ids';
|
||||
import { EventName } from '@waha/apps/chatwoot/client/types';
|
||||
import { ChatWootConversationKey } from '@waha/apps/chatwoot/consumers/mutex';
|
||||
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
||||
@@ -10,14 +10,15 @@ import {
|
||||
ChatIDNotFoundForContactError,
|
||||
PhoneNumberNotFoundInWhatsAppError,
|
||||
} from '@waha/apps/chatwoot/errors';
|
||||
import { TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AppRepository } from '../../storage';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||
|
||||
/**
|
||||
* Base class for ChatWoot inbox consumers
|
||||
@@ -58,13 +59,20 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
|
||||
job: Job,
|
||||
): Promise<any>;
|
||||
|
||||
protected GetConversationID(body) {
|
||||
return body.conversation.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the job
|
||||
* This method is called by the queue processor
|
||||
*/
|
||||
async processJob(job: Job<InboxData, any, EventName>): Promise<any> {
|
||||
const body = job.data.body;
|
||||
const key = ChatWootConversationKey(job.data.app, body.conversation.id);
|
||||
const key = ChatWootConversationKey(
|
||||
job.data.app,
|
||||
this.GetConversationID(body),
|
||||
);
|
||||
return await this.withMutex(job, key, () =>
|
||||
this.ProcessAndReportStatus(job),
|
||||
);
|
||||
@@ -83,6 +91,12 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
protected conversationForReport(container, body): Conversation {
|
||||
return container
|
||||
.ContactConversationService()
|
||||
.ConversationById(body.conversation.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error for a message
|
||||
*/
|
||||
@@ -91,9 +105,7 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
|
||||
const header: string = this.ErrorHeaderKey()
|
||||
? container.Locale().key(this.ErrorHeaderKey()).render()
|
||||
: err.message || `${err}`;
|
||||
const conversation = container
|
||||
.ContactConversationService()
|
||||
.ConversationById(body.conversation.id);
|
||||
const conversation = this.conversationForReport(container, body);
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportError(
|
||||
conversation,
|
||||
@@ -111,9 +123,7 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
|
||||
}
|
||||
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const conversation = container
|
||||
.ContactConversationService()
|
||||
.ConversationById(body.conversation.id);
|
||||
const conversation = this.conversationForReport(container, body);
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportSucceeded(conversation, body.message_type, body.id);
|
||||
}
|
||||
@@ -124,7 +134,7 @@ export async function LookupAndCheckChatId(
|
||||
body: any,
|
||||
): Promise<string> {
|
||||
const sender = body.conversation.meta.sender;
|
||||
let chatId = GetChatID(sender);
|
||||
let chatId = FindChatID(sender);
|
||||
if (!chatId && sender.phone_number) {
|
||||
const existResult = await session.contactCheckExists(sender.phone_number);
|
||||
if (!existResult.numberExists) {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { InjectFlowProducer, InjectQueue, Processor } from '@nestjs/bullmq';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
import { AttachmentFromBuffer } from '@waha/apps/chatwoot/client/messages';
|
||||
import { MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
import { ChatWootInboxMessageConsumer } from '@waha/apps/chatwoot/consumers/inbox/base';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import {
|
||||
FlowProducerName,
|
||||
QueueName,
|
||||
} from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { Locale, TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { WAHASelf } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import { SessionStatusEmoji } from '@waha/apps/chatwoot/SessionStatusEmoji';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job } from 'bullmq';
|
||||
import { FlowProducer, Job, Queue } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { CommandPrefix, runText } from '@waha/apps/chatwoot/cli';
|
||||
import { CommandContext } from '@waha/apps/chatwoot/cli/types';
|
||||
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
||||
import { QueueRegistry } from '@waha/apps/chatwoot/services/QueueRegistry';
|
||||
|
||||
@Processor(QueueName.INBOX_COMMANDS, { concurrency: JOB_CONCURRENCY })
|
||||
export class ChatWootInboxCommandsConsumer extends ChatWootInboxMessageConsumer {
|
||||
@@ -21,6 +22,9 @@ export class ChatWootInboxCommandsConsumer extends ChatWootInboxMessageConsumer
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
private readonly queueRegistry: QueueRegistry,
|
||||
@InjectFlowProducer(FlowProducerName.MESSAGES_PULL_FLOW)
|
||||
private readonly messagesPullFlow: FlowProducer,
|
||||
) {
|
||||
super(manager, log, rmutex, 'ChatWootInboxCommandsConsumer');
|
||||
}
|
||||
@@ -29,180 +33,47 @@ export class ChatWootInboxCommandsConsumer extends ChatWootInboxMessageConsumer
|
||||
return null;
|
||||
}
|
||||
|
||||
protected conversationForReport(container, body) {
|
||||
const conversation = super.conversationForReport(container, body);
|
||||
if (!IsCommandsChat(body)) {
|
||||
conversation.forceNote();
|
||||
}
|
||||
return conversation;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, body, job: Job) {
|
||||
const handler = new SessionCommandHandler(
|
||||
container.Logger(),
|
||||
container.ContactConversationService(),
|
||||
job.data.session,
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
);
|
||||
return await handler.handle(body);
|
||||
}
|
||||
}
|
||||
|
||||
enum Command {
|
||||
STATUS = 'status',
|
||||
RESTART = 'restart',
|
||||
START = 'start',
|
||||
STOP = 'stop',
|
||||
LOGOUT = 'logout',
|
||||
QR = 'qr',
|
||||
SCREENSHOT = 'screenshot',
|
||||
HELP = 'help',
|
||||
SERVER_STATUS = 'server status',
|
||||
SERVER_REBOOT = 'server reboot',
|
||||
SERVER_REBOOT_FORCE = 'server reboot force',
|
||||
}
|
||||
|
||||
const CommandMap: Record<string, Command> = {
|
||||
// Status
|
||||
[Command.STATUS]: Command.STATUS,
|
||||
['1']: Command.STATUS,
|
||||
// Restart
|
||||
[Command.RESTART]: Command.RESTART,
|
||||
['2']: Command.RESTART,
|
||||
// Start
|
||||
[Command.START]: Command.START,
|
||||
['3']: Command.START,
|
||||
// Stop
|
||||
[Command.STOP]: Command.STOP,
|
||||
['4']: Command.STOP,
|
||||
// Logout
|
||||
[Command.LOGOUT]: Command.LOGOUT,
|
||||
['5']: Command.LOGOUT,
|
||||
// QR
|
||||
[Command.QR]: Command.QR,
|
||||
['6']: Command.QR,
|
||||
// Screenshot
|
||||
[Command.SCREENSHOT]: Command.SCREENSHOT,
|
||||
['7']: Command.SCREENSHOT,
|
||||
// Help
|
||||
[Command.HELP]: Command.HELP,
|
||||
['8']: Command.HELP,
|
||||
// Server commands
|
||||
[Command.SERVER_STATUS]: Command.SERVER_STATUS,
|
||||
[Command.SERVER_REBOOT]: Command.SERVER_REBOOT,
|
||||
[Command.SERVER_REBOOT_FORCE]: Command.SERVER_REBOOT_FORCE,
|
||||
};
|
||||
|
||||
class CommandIsNotImplementedError extends Error {
|
||||
constructor(private command: string) {
|
||||
super(`Command is not implemented`);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionCommandHandler {
|
||||
constructor(
|
||||
private logger: ILogger,
|
||||
private repo: ContactConversationService,
|
||||
private session: string,
|
||||
private l: Locale,
|
||||
private waha: WAHASelf,
|
||||
) {}
|
||||
|
||||
async handle(message: any) {
|
||||
const cmd = message.content?.toLowerCase();
|
||||
const command = this.findCommand(cmd) || Command.HELP;
|
||||
let cmd = body.content;
|
||||
cmd = cmd.startsWith(CommandPrefix) ? cmd.slice(CommandPrefix.length) : cmd;
|
||||
this.logger.info(
|
||||
`Executing command ${command} for session ${this.session}`,
|
||||
`Executing command '${cmd}' for session ${job.data.session}...`,
|
||||
);
|
||||
switch (command) {
|
||||
case Command.RESTART:
|
||||
await this.waha.restart(this.session);
|
||||
break;
|
||||
case Command.START:
|
||||
await this.waha.restart(this.session);
|
||||
break;
|
||||
case Command.LOGOUT:
|
||||
this.logger.info(`Logging out session ${this.session}`);
|
||||
await this.waha.logout(this.session);
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const text = this.l.key(TKey.APP_LOGOUT_SUCCESS).render();
|
||||
await conversation.incoming(text);
|
||||
break;
|
||||
case Command.STOP:
|
||||
this.logger.info(`Stopping session ${this.session}`);
|
||||
await this.waha.stop(this.session);
|
||||
break;
|
||||
case Command.STATUS: {
|
||||
this.logger.info(`Getting status for session ${this.session}`);
|
||||
const session = await this.waha.get(this.session);
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const emoji = SessionStatusEmoji(session.status);
|
||||
const text = this.l.key(TKey.APP_SESSION_CURRENT_STATUS).render({
|
||||
emoji: emoji,
|
||||
session: session.name,
|
||||
status: session.status,
|
||||
name: session.me?.pushName || 'Unknown',
|
||||
id: session.me?.id || 'No phone number',
|
||||
});
|
||||
await conversation.incoming(text);
|
||||
break;
|
||||
}
|
||||
case Command.SERVER_STATUS: {
|
||||
this.logger.info('Getting server version and status');
|
||||
const version = await this.waha.serverVersion();
|
||||
const status = await this.waha.serverStatus();
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const text = this.l.key(TKey.APP_SERVER_VERSION_AND_STATUS).render({
|
||||
version: JSON.stringify(version, null, 2),
|
||||
status: JSON.stringify(status, null, 2),
|
||||
});
|
||||
await conversation.incoming(text);
|
||||
break;
|
||||
}
|
||||
case Command.SERVER_REBOOT: {
|
||||
this.logger.info('Rebooting server (graceful)');
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const text = this.l.key(TKey.APP_SERVER_REBOOT).render();
|
||||
await conversation.incoming(text);
|
||||
await this.waha.serverReboot(false);
|
||||
break;
|
||||
}
|
||||
case Command.SERVER_REBOOT_FORCE: {
|
||||
this.logger.info('Rebooting server (force)');
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const text = this.l.key(TKey.APP_SERVER_REBOOT_FORCE).render();
|
||||
await conversation.incoming(text);
|
||||
await this.waha.serverReboot(true);
|
||||
break;
|
||||
}
|
||||
case Command.QR: {
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const content = await this.waha.qr(this.session);
|
||||
const message = AttachmentFromBuffer(content, 'qr.jpg');
|
||||
message.message_type = MessageType.INCOMING;
|
||||
await conversation.send(message);
|
||||
break;
|
||||
}
|
||||
case Command.SCREENSHOT: {
|
||||
this.logger.info(`Getting screenshot for session ${this.session}`);
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const content = await this.waha.screenshot(this.session);
|
||||
const message = AttachmentFromBuffer(content, 'screenshot.jpg');
|
||||
message.message_type = MessageType.INCOMING;
|
||||
await conversation.send(message);
|
||||
break;
|
||||
}
|
||||
case Command.HELP: {
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const text = this.l.key(TKey.APP_COMMANDS_LIST).render();
|
||||
await conversation.incoming(text);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new CommandIsNotImplementedError(cmd);
|
||||
const repo = container.ContactConversationService();
|
||||
let conversation = repo.ConversationById(body.conversation.id);
|
||||
if (!IsCommandsChat(body)) {
|
||||
conversation.forceNote();
|
||||
}
|
||||
const ctx: CommandContext = {
|
||||
data: job.data,
|
||||
logger: this.logger,
|
||||
l: container.Locale(),
|
||||
waha: container.WAHASelf(),
|
||||
conversation: conversation,
|
||||
queues: {
|
||||
registry: this.queueRegistry,
|
||||
contactsPull: this.queueRegistry.queue(QueueName.TASK_CONTACTS_PULL),
|
||||
messagesPull: this.queueRegistry.queue(QueueName.TASK_MESSAGES_PULL),
|
||||
},
|
||||
flows: {
|
||||
messagesPull: this.messagesPullFlow,
|
||||
},
|
||||
};
|
||||
const commands = container.ChatWootConfig().commands;
|
||||
const output = await runText(commands, ctx, cmd);
|
||||
if (output.out) {
|
||||
await conversation.incoming(output.out);
|
||||
}
|
||||
if (output.err) {
|
||||
await conversation.incoming(output.err);
|
||||
}
|
||||
}
|
||||
|
||||
findCommand(text: string): Command {
|
||||
// Remove empty from end
|
||||
text = text.trim();
|
||||
// Remove empty from start
|
||||
text = text.trimStart();
|
||||
// Remove empty from end
|
||||
text = text.trimEnd();
|
||||
return CommandMap[text] || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { ChatWootConversationKey } from '@waha/apps/chatwoot/consumers/mutex';
|
||||
import { ChatWootInboxMessageConsumer } from '@waha/apps/chatwoot/consumers/inbox/base';
|
||||
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import { Job } from 'bullmq';
|
||||
import { GetAllChatIDs } from '@waha/apps/chatwoot/client/ids';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { RMutexService } from '@waha/modules/rmutex';
|
||||
|
||||
@Processor(QueueName.INBOX_CONVERSATION_CREATED, {
|
||||
concurrency: JOB_CONCURRENCY,
|
||||
})
|
||||
export class ChatWootConversationCreatedConsumer extends ChatWootInboxMessageConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'ChatWootConversationCreatedConsumer');
|
||||
}
|
||||
protected ErrorHeaderKey(): TKey | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected GetConversationID(body) {
|
||||
return body.id;
|
||||
}
|
||||
|
||||
protected async Process(
|
||||
container: DIContainer,
|
||||
body: any,
|
||||
job: Job,
|
||||
): Promise<any> {
|
||||
const handler = new ConversationCreatedHandler(
|
||||
container.ContactConversationService(),
|
||||
);
|
||||
return handler.handle(body);
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationCreatedHandler {
|
||||
constructor(private service: ContactConversationService) {}
|
||||
|
||||
async handle(body: any) {
|
||||
const ids = GetAllChatIDs(body?.meta?.sender);
|
||||
if (!ids || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.service.ResetMismatchedCache(ids, body.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { ChatWootInboxMessageConsumer } from '@waha/apps/chatwoot/consumers/inbox/base';
|
||||
import { Job } from 'bullmq';
|
||||
import { DIContainer } from '../../di/DIContainer';
|
||||
import { TKey } from '../../i18n/templates';
|
||||
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
import { AttributeKey } from '@waha/apps/chatwoot/const';
|
||||
import { ConversationSelector } from '@waha/apps/chatwoot/services/ConversationSelector';
|
||||
import { GetAllChatIDs } from '@waha/apps/chatwoot/client/ids';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { RMutexService } from '@waha/modules/rmutex';
|
||||
|
||||
@Processor(QueueName.INBOX_CONVERSATION_STATUS_CHANGED, {
|
||||
concurrency: JOB_CONCURRENCY,
|
||||
})
|
||||
export class ChatWootConversationStatusChangedConsumer extends ChatWootInboxMessageConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'ChatWootConversationStatusChangedConsumer');
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected GetConversationID(body) {
|
||||
return body.id;
|
||||
}
|
||||
|
||||
protected async Process(
|
||||
container: DIContainer,
|
||||
body: any,
|
||||
job: Job,
|
||||
): Promise<any> {
|
||||
const handler = new ConversationStatusChangedHandler(
|
||||
container.ContactConversationService(),
|
||||
container.ConversationSelector(),
|
||||
);
|
||||
return await handler.handle(body);
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationStatusChangedHandler {
|
||||
constructor(
|
||||
private service: ContactConversationService,
|
||||
private selector: ConversationSelector,
|
||||
) {}
|
||||
|
||||
async handle(body) {
|
||||
if (!this.selector.hasStatusFilter()) {
|
||||
return;
|
||||
}
|
||||
const ids = GetAllChatIDs(body.meta?.sender);
|
||||
this.service.ResetCache(ids);
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
} from '@waha/apps/chatwoot/consumers/inbox/base';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/session';
|
||||
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/waha';
|
||||
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import {
|
||||
ChatwootMessage,
|
||||
MessageMapping,
|
||||
MessageMappingService,
|
||||
} from '@waha/apps/chatwoot/storage';
|
||||
import { MarkdownToWhatsApp } from '@waha/apps/chatwoot/text';
|
||||
import { MarkdownToWhatsApp } from '@waha/apps/chatwoot/messages/to/whatsapp/markdown';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import {
|
||||
@@ -30,6 +30,12 @@ import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { SerializeWhatsAppKey } from '../../client/ids';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import {
|
||||
ChatWootConfig,
|
||||
LinkPreview,
|
||||
} from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime-types');
|
||||
@@ -45,7 +51,7 @@ export class ChatWootInboxMessageCreatedConsumer extends ChatWootInboxMessageCon
|
||||
}
|
||||
|
||||
ErrorHeaderKey(): TKey | null {
|
||||
return TKey.WHATSAPP_ERROR_SENDING_MESSAGE;
|
||||
return TKey.WHATSAPP_MESSAGE_SENDING_ERROR;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, body, job: Job) {
|
||||
@@ -55,6 +61,8 @@ export class ChatWootInboxMessageCreatedConsumer extends ChatWootInboxMessageCon
|
||||
container.MessageMappingService(),
|
||||
container.Logger(),
|
||||
session,
|
||||
container.ChatWootConfig(),
|
||||
container.Locale(),
|
||||
);
|
||||
return await handler.handle(body);
|
||||
}
|
||||
@@ -65,12 +73,17 @@ export class MessageHandler {
|
||||
private mappingService: MessageMappingService,
|
||||
private logger: ILogger,
|
||||
private session: WAHASessionAPI,
|
||||
private config: ChatWootConfig,
|
||||
private l: Locale,
|
||||
) {}
|
||||
|
||||
async handle(body: any) {
|
||||
const chatId = await LookupAndCheckChatId(this.session, body);
|
||||
const message = body;
|
||||
if (message.content_type != 'text') {
|
||||
if (
|
||||
message.content_type != 'text' &&
|
||||
message.content_type != 'input_csat'
|
||||
) {
|
||||
this.logger.info(
|
||||
`Message content type not supported. Content type: ${message.content_type}`,
|
||||
);
|
||||
@@ -78,37 +91,60 @@ export class MessageHandler {
|
||||
}
|
||||
|
||||
const content = MarkdownToWhatsApp(message.content);
|
||||
const attachments = message.attachments || [];
|
||||
const results = [];
|
||||
const multipleAttachments = attachments.length > 1;
|
||||
let part = 1;
|
||||
let part = 0; // Start from 0 and increment before each send/check
|
||||
const replyTo = await this.getReplyTo(message).catch((err) => {
|
||||
this.logger.error(`Error getting reply to message ID: ${err}`);
|
||||
this.logger.error(
|
||||
`ChatWoot => WhatsApp: error getting reply to message ID: ${err}`,
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Send text
|
||||
if (attachments.length == 0 || multipleAttachments) {
|
||||
const msg = await this.sendTextMessage(chatId, content, replyTo);
|
||||
results.push(msg);
|
||||
part = await this.saveMapping(message, msg, part);
|
||||
this.logger.info(`Text message sent: ${msg.id}`);
|
||||
// Send text (Part 1 if present)
|
||||
const attachments = message.attachments || [];
|
||||
const sendText = content && attachments.length !== 1;
|
||||
if (sendText) {
|
||||
part += 1; // Text is the first possible part
|
||||
const exists = await this.getMapping(message, part);
|
||||
if (exists) {
|
||||
this.logger.warn(
|
||||
`Skip part ${part}: mapping exists for Chatwoot message ${message.id}`,
|
||||
);
|
||||
} else {
|
||||
const textTemplate = this.l.key(TKey.CW_TO_WA_MESSAGE_TEXT);
|
||||
const text = textTemplate.render({
|
||||
content: content,
|
||||
chatwoot: body,
|
||||
});
|
||||
const msg = await this.sendTextMessage(chatId, text, replyTo);
|
||||
results.push(msg);
|
||||
await this.saveMapping(message, msg, part);
|
||||
this.logger.info(`Text message sent: ${msg.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send files
|
||||
const fileMessageContent = multipleAttachments ? '' : content;
|
||||
const captionTemplate = this.l.key(TKey.CW_TO_WA_MESSAGE_MEDIA_CAPTION);
|
||||
for (const file of attachments) {
|
||||
const msg = await this.sendFile(
|
||||
chatId,
|
||||
fileMessageContent,
|
||||
file,
|
||||
replyTo,
|
||||
);
|
||||
part += 1; // Increment before each attachment send/check
|
||||
const exists = await this.getMapping(message, part);
|
||||
if (exists) {
|
||||
this.logger.warn(
|
||||
`Skip part ${part}: mapping exists for Chatwoot message ${message.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const caption = captionTemplate.render({
|
||||
content: content,
|
||||
chatwoot: body,
|
||||
singleAttachment: attachments.length == 1,
|
||||
});
|
||||
const msg = await this.sendFile(chatId, caption, file, replyTo);
|
||||
this.logger.info(
|
||||
`File message sent: ${msg.id} - ${file.data_url} - ${file.file_type}`,
|
||||
);
|
||||
results.push(msg);
|
||||
part = await this.saveMapping(message, msg, part);
|
||||
await this.saveMapping(message, msg, part);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -117,15 +153,27 @@ export class MessageHandler {
|
||||
chatwootMessage: any,
|
||||
whatsappMessage: any,
|
||||
part: number,
|
||||
): Promise<number> {
|
||||
): Promise<void> {
|
||||
const chatwoot: Omit<ChatwootMessage, 'id'> = {
|
||||
timestamp: new Date(chatwootMessage.created_at),
|
||||
conversation_id: chatwootMessage.conversation.id,
|
||||
message_id: chatwootMessage.id,
|
||||
};
|
||||
const whatsapp = EngineHelper.WhatsAppMessageKeys(whatsappMessage);
|
||||
await this.mappingService.map(chatwoot, whatsapp, 1);
|
||||
return part + 1;
|
||||
await this.mappingService.map(chatwoot, whatsapp, part);
|
||||
}
|
||||
|
||||
private async getMapping(
|
||||
message: any,
|
||||
part: number,
|
||||
): Promise<MessageMapping | null> {
|
||||
return this.mappingService.getMappingByChatwootCombinedKeyAndPart(
|
||||
{
|
||||
conversation_id: message.conversation.id,
|
||||
message_id: message.id,
|
||||
},
|
||||
part,
|
||||
);
|
||||
}
|
||||
|
||||
async getReplyTo(message): Promise<string | undefined> {
|
||||
@@ -154,6 +202,10 @@ export class MessageHandler {
|
||||
chatId: chatId,
|
||||
text: content,
|
||||
reply_to: replyTo,
|
||||
linkPreview: [LinkPreview.LQ, LinkPreview.HQ].includes(
|
||||
this.config.linkPreview,
|
||||
),
|
||||
linkPreviewHighQuality: this.config.linkPreview == LinkPreview.HQ,
|
||||
};
|
||||
const session = this.session;
|
||||
await session.readMessages(chatId);
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
} from '@waha/apps/chatwoot/consumers/inbox/base';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { MessageMappingService } from '@waha/apps/chatwoot/storage';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
@Processor(QueueName.INBOX_MESSAGE_DELETED, { concurrency: JOB_CONCURRENCY })
|
||||
export class ChatWootInboxMessageDeletedConsumer extends ChatWootInboxMessageConsumer {
|
||||
@@ -27,7 +27,7 @@ export class ChatWootInboxMessageDeletedConsumer extends ChatWootInboxMessageCon
|
||||
}
|
||||
|
||||
ErrorHeaderKey(): TKey | null {
|
||||
return TKey.WHATSAPP_ERROR_REMOVING_MESSAGE;
|
||||
return TKey.WHATSAPP_MESSAGE_REMOVING_ERROR;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, body, job: Job) {
|
||||
@@ -56,6 +56,13 @@ class MessageDeletedHandler {
|
||||
message_id: body.id,
|
||||
});
|
||||
if (!messages || messages.length == 0) {
|
||||
if (body.private) {
|
||||
// Private notes can have no whatsapp messages
|
||||
this.logger.info(
|
||||
`No WhatsApp message to delete for private note '${body.id}'`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw Error(
|
||||
`No WhatsApp message found for Chatwoot message '${body.id}'`,
|
||||
);
|
||||
|
||||
@@ -4,13 +4,13 @@ import { ChatWootInboxMessageConsumer } from '@waha/apps/chatwoot/consumers/inbo
|
||||
import { MessageHandler } from '@waha/apps/chatwoot/consumers/inbox/message_created';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { WAHASessionAPI } from '../../session/WAHASelf';
|
||||
import { WAHASessionAPI } from '../../../app_sdk/waha/WAHASelf';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
@Processor(QueueName.INBOX_MESSAGE_UPDATED, { concurrency: JOB_CONCURRENCY })
|
||||
export class ChatWootInboxMessageUpdatedConsumer extends ChatWootInboxMessageConsumer {
|
||||
@@ -23,7 +23,7 @@ export class ChatWootInboxMessageUpdatedConsumer extends ChatWootInboxMessageCon
|
||||
}
|
||||
|
||||
ErrorHeaderKey(): TKey | null {
|
||||
return TKey.WHATSAPP_ERROR_SENDING_MESSAGE;
|
||||
return TKey.WHATSAPP_MESSAGE_SENDING_ERROR;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, body, job: Job) {
|
||||
@@ -33,6 +33,8 @@ export class ChatWootInboxMessageUpdatedConsumer extends ChatWootInboxMessageCon
|
||||
container.MessageMappingService(),
|
||||
container.Logger(),
|
||||
session,
|
||||
container.ChatWootConfig(),
|
||||
container.Locale(),
|
||||
);
|
||||
return await handler.handle(body);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ import { HasBeenRetried } from '@waha/apps/app_sdk/JobUtils';
|
||||
import { MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
import { ScheduledData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AppRepository } from '../../storage';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
/**
|
||||
* Base class for ChatWoot scheduled consumers
|
||||
|
||||
@@ -4,7 +4,6 @@ import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { CHANGELOG_URL, SUPPORT_US_URL } from '@waha/core/constants';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
@@ -14,6 +13,7 @@ import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { ChatWootScheduledConsumer } from './base';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
/**
|
||||
* Scheduled consumer for version checking
|
||||
@@ -26,7 +26,7 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey {
|
||||
return TKey.APP_SCHEDULED_JOB_ERROR;
|
||||
return TKey.JOB_SCHEDULED_ERROR_HEADER;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +50,7 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
|
||||
return;
|
||||
}
|
||||
logger.info('WAHA is using the CORE version');
|
||||
const supportMessage = locale.key(TKey.APP_CORE_VERSION_USED).render({
|
||||
const supportMessage = locale.key(TKey.WAHA_CORE_VERSION_USED).render({
|
||||
supportUrl: SUPPORT_US_URL,
|
||||
});
|
||||
await conversation.incoming(supportMessage);
|
||||
@@ -74,7 +74,7 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
|
||||
|
||||
// Send a message to Chatwoot conversation about a new version
|
||||
const locale = container.Locale();
|
||||
const message = locale.key(TKey.APP_NEW_VERSION_AVAILABLE).render({
|
||||
const message = locale.key(TKey.WAHA_NEW_VERSION_AVAILABLE).render({
|
||||
currentVersion: currentVersion,
|
||||
newVersion: latestVersion,
|
||||
changelogUrl: CHANGELOG_URL,
|
||||
|
||||
@@ -3,13 +3,13 @@ import { Injectable } from '@nestjs/common';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { ChatWootScheduledConsumer } from './base';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
/**
|
||||
* Scheduled consumer for message cleanup tasks
|
||||
@@ -19,14 +19,14 @@ import { ChatWootScheduledConsumer } from './base';
|
||||
concurrency: JOB_CONCURRENCY,
|
||||
})
|
||||
export class MessageCleanupConsumer extends ChatWootScheduledConsumer {
|
||||
REMOVE_AFTER_DAYS = 14;
|
||||
REMOVE_AFTER_DAYS = 365;
|
||||
|
||||
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
|
||||
super(manager, log, rmutex, MessageCleanupConsumer.name);
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey {
|
||||
return TKey.APP_SCHEDULED_JOB_ERROR;
|
||||
return TKey.JOB_SCHEDULED_ERROR_HEADER;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { AppConsumer } from '@waha/apps/app_sdk/AppConsumer';
|
||||
import { JobLoggerWrapper } from '@waha/apps/app_sdk/JobLoggerWrapper';
|
||||
import { HasBeenRetried } from '@waha/apps/app_sdk/JobUtils';
|
||||
import { MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
import { ScheduledData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AppRepository } from '../../storage';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { SignalRace } from '@waha/utils/abortable';
|
||||
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
||||
|
||||
/**
|
||||
* Base class for ChatWoot background task consumers
|
||||
*/
|
||||
export abstract class ChatWootTaskConsumer extends AppConsumer {
|
||||
protected appRepository: AppRepository;
|
||||
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
protected readonly consumerName: string,
|
||||
) {
|
||||
super('ChatWoot', consumerName, log, rmutex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the DIContainer for the specified app.
|
||||
*/
|
||||
protected async DIContainer(job: Job, appId: string): Promise<DIContainer> {
|
||||
const knex = this.manager.store.getWAHADatabase();
|
||||
this.appRepository = new AppRepository(knex);
|
||||
const logger = new JobLoggerWrapper(job, this.logger);
|
||||
const app = await this.appRepository.getById(appId);
|
||||
return new DIContainer(app.pk, app.config, logger, knex);
|
||||
}
|
||||
|
||||
protected abstract ErrorHeaderKey(): TKey | null;
|
||||
|
||||
/**
|
||||
* Get the function that processes the scheduled job
|
||||
* This method must be implemented by subclasses
|
||||
*/
|
||||
protected abstract Process(
|
||||
container: DIContainer,
|
||||
job: Job,
|
||||
signal: AbortSignal,
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* Process the job
|
||||
* This method is called by the queue processor
|
||||
*/
|
||||
async processJob(job: Job<ScheduledData, any, string>): Promise<any> {
|
||||
const signal = this.signal(job);
|
||||
return SignalRace(this.ProcessAndReportStatus(job, signal), signal);
|
||||
}
|
||||
|
||||
private async ProcessAndReportStatus(job: Job, signal: AbortSignal) {
|
||||
try {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const result = await this.Process(container, job, signal);
|
||||
await this.ReportErrorRecovered(job);
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.ReportErrorForJob(job, err).catch((exc) =>
|
||||
this.logger.error(`Failed to report error for job ${job.id}: ${exc}`),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected conversationForReport(container, body) {
|
||||
const conversation = container
|
||||
.ContactConversationService()
|
||||
.ConversationById(body.conversation.id);
|
||||
if (!IsCommandsChat(body)) {
|
||||
conversation.forceNote();
|
||||
}
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error for a scheduled job
|
||||
*/
|
||||
protected async ReportErrorForJob(job: Job, err: any) {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
let header = this.ErrorHeaderKey()
|
||||
? container.Locale().key(this.ErrorHeaderKey()).render()
|
||||
: err.message || `${err}`;
|
||||
header = `${job.queueName}: ${header}`;
|
||||
|
||||
const conversation = this.conversationForReport(container, job.data.body);
|
||||
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportError(conversation, header, MessageType.INCOMING, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
protected async ReportErrorRecovered(job: Job) {
|
||||
if (!HasBeenRetried(job)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const conversation = this.conversationForReport(container, job.data.body);
|
||||
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportSucceeded(conversation, MessageType.INCOMING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import * as lodash from 'lodash';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job, JobState } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { ChatWootTaskConsumer } from '@waha/apps/chatwoot/consumers/task/base';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
|
||||
import { ContactSortField } from '@waha/structures/contacts.dto';
|
||||
import { SortOrder } from '@waha/structures/pagination.dto';
|
||||
import {
|
||||
AvatarUpdateMode,
|
||||
ContactService,
|
||||
} from '@waha/apps/chatwoot/client/ContactService';
|
||||
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { JobLink } from '@waha/apps/app_sdk/JobUtils';
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { isJidGroup, isLidUser, isPnUser } from '@waha/core/utils/jids';
|
||||
import {
|
||||
ArrayPaginator,
|
||||
PaginatorParams,
|
||||
} from '@waha/apps/app_sdk/waha/Paginator';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/waha';
|
||||
|
||||
export interface ContactsPullOptions {
|
||||
batch: number;
|
||||
progress: number | null;
|
||||
avatar: null | 'if-missing' | 'update';
|
||||
attributes: boolean;
|
||||
contacts: {
|
||||
groups: boolean;
|
||||
lids: boolean;
|
||||
};
|
||||
delay: {
|
||||
contact: number;
|
||||
batch: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Task consumer that pulls WhatsApp contacts into ChatWoot
|
||||
* Runs in batches to keep the job resumable and observable in ChatWoot
|
||||
*/
|
||||
@Processor(QueueName.TASK_CONTACTS_PULL, { concurrency: JOB_CONCURRENCY })
|
||||
export class TaskContactsPullConsumer extends ChatWootTaskConsumer {
|
||||
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
|
||||
super(manager, log, rmutex, TaskContactsPullConsumer.name);
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the contact pull job
|
||||
* Streams batches of contacts from WAHA into ChatWoot storage
|
||||
*/
|
||||
protected async Process(
|
||||
container: DIContainer,
|
||||
job: Job,
|
||||
signal?: AbortSignal,
|
||||
): Promise<any> {
|
||||
const options: ContactsPullOptions = job.data.options;
|
||||
const locale = container.Locale();
|
||||
const waha = container.WAHASelf();
|
||||
const session = new WAHASessionAPI(job.data.session, waha);
|
||||
const conversation = this.conversationForReport(container, job.data.body);
|
||||
const handler = new ContactsPullHandler(
|
||||
signal,
|
||||
container.Logger(),
|
||||
conversation,
|
||||
session,
|
||||
locale,
|
||||
container.ContactService(),
|
||||
);
|
||||
return await handler.handle(options, job);
|
||||
}
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
avatar: {
|
||||
updated: number;
|
||||
};
|
||||
}
|
||||
|
||||
function total(progress: Progress) {
|
||||
return (
|
||||
progress.created + progress.updated + progress.skipped + progress.errors
|
||||
);
|
||||
}
|
||||
|
||||
const NullProgress: Progress = {
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
avatar: {
|
||||
updated: 0,
|
||||
},
|
||||
};
|
||||
|
||||
class ContactsPullHandler {
|
||||
private activity: TaskActivity;
|
||||
|
||||
constructor(
|
||||
private signal: AbortSignal,
|
||||
private logger: ILogger,
|
||||
private conversation: Conversation,
|
||||
private session: WAHASessionAPI,
|
||||
private l: Locale,
|
||||
private contactService: ContactService,
|
||||
) {
|
||||
this.activity = new TaskActivity(this.l, this.conversation);
|
||||
}
|
||||
|
||||
async handle(options: ContactsPullOptions, job) {
|
||||
const batch = options.batch;
|
||||
let progress = lodash.merge({}, NullProgress, job.progress);
|
||||
this.logger.debug(
|
||||
`Pulling contacts for session ${job.data.session} with batch size ${batch}...`,
|
||||
);
|
||||
if (lodash.isEqual(progress, NullProgress)) {
|
||||
await this.activity.started(progress);
|
||||
}
|
||||
|
||||
const query = {
|
||||
sortBy: ContactSortField.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
limit: batch,
|
||||
offset: 0,
|
||||
};
|
||||
const params: PaginatorParams = {
|
||||
processed: total(progress),
|
||||
};
|
||||
const paginator = new ArrayPaginator<any>(params);
|
||||
const contacts = paginator.iterate((processed: number) => {
|
||||
query.offset = processed;
|
||||
return this.session.getContacts(query, {
|
||||
signal: this.signal,
|
||||
});
|
||||
});
|
||||
|
||||
for await (const contact of contacts) {
|
||||
this.signal.throwIfAborted();
|
||||
|
||||
//
|
||||
// Show progress
|
||||
//
|
||||
const thetotal = total(progress);
|
||||
if (options.progress && thetotal) {
|
||||
if (thetotal % options.progress == 0) {
|
||||
await this.activity.progress(progress);
|
||||
}
|
||||
}
|
||||
// Delay on batch
|
||||
if (thetotal && options.delay.batch) {
|
||||
if (thetotal % batch == 0) {
|
||||
await sleep(options.delay.batch);
|
||||
}
|
||||
}
|
||||
|
||||
// Process
|
||||
try {
|
||||
if (!EngineHelper.ContactIsMy(contact)) {
|
||||
progress.skipped += 1;
|
||||
await job.updateProgress(progress);
|
||||
continue;
|
||||
}
|
||||
const chatId = contact.id;
|
||||
if (isJidGroup(chatId)) {
|
||||
if (!options.contacts.groups) {
|
||||
this.logger.debug(`Skipping group contact ${chatId}...`);
|
||||
progress.skipped += 1;
|
||||
await job.updateProgress(progress);
|
||||
continue;
|
||||
}
|
||||
} else if (isLidUser(chatId)) {
|
||||
if (!options.contacts.lids) {
|
||||
this.logger.debug(`Skipping LID contact ${chatId}...`);
|
||||
progress.skipped += 1;
|
||||
await job.updateProgress(progress);
|
||||
continue;
|
||||
}
|
||||
} else if (!isPnUser(chatId)) {
|
||||
this.logger.info(`Skipping non-phone-number contact ${chatId}...`);
|
||||
progress.skipped += 1;
|
||||
await job.updateProgress(progress);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.debug(`Pulling ${chatId}...`);
|
||||
const result = await this.pullOneContact(options, chatId);
|
||||
progress.created += result.created;
|
||||
progress.updated += result.updated;
|
||||
progress.avatar.updated += result.avatar.updated;
|
||||
this.logger.info(
|
||||
`Contact ${chatId}: created=${result.created}, updated=${result.updated}, avatar.updated=${result.avatar.updated}`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(`Error pulling contact ${contact.id}: ${e}`);
|
||||
progress.errors += 1;
|
||||
}
|
||||
// update progress before signal fails
|
||||
await job.updateProgress(progress);
|
||||
await sleep(options.delay.contact);
|
||||
}
|
||||
|
||||
await this.activity.completed(progress);
|
||||
}
|
||||
|
||||
private async pullOneContact(options: ContactsPullOptions, chatId: string) {
|
||||
// Contact
|
||||
const contactInfo = WhatsAppContactInfo(this.session, chatId, this.l);
|
||||
let [cwContact, created] =
|
||||
await this.contactService.findOrCreateContact(contactInfo);
|
||||
// Attributes
|
||||
if (options.attributes) {
|
||||
const attributes = await contactInfo.Attributes();
|
||||
await this.contactService.upsertCustomAttributes(
|
||||
cwContact.data,
|
||||
attributes,
|
||||
);
|
||||
}
|
||||
// Avatar
|
||||
let avatarUpdated = false;
|
||||
switch (options.avatar) {
|
||||
case 'if-missing':
|
||||
avatarUpdated = await this.contactService.updateAvatar(
|
||||
cwContact,
|
||||
contactInfo,
|
||||
AvatarUpdateMode.IF_MISSING,
|
||||
);
|
||||
break;
|
||||
case 'update':
|
||||
avatarUpdated = await this.contactService.updateAvatar(
|
||||
cwContact,
|
||||
contactInfo,
|
||||
AvatarUpdateMode.ALWAYS,
|
||||
);
|
||||
break;
|
||||
}
|
||||
return {
|
||||
created: created ? 1 : 0,
|
||||
updated: created ? 0 : 1,
|
||||
avatar: {
|
||||
updated: avatarUpdated ? 1 : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TaskActivity {
|
||||
constructor(
|
||||
private l: Locale,
|
||||
private conversation: Conversation,
|
||||
) {}
|
||||
|
||||
public async started(progress: Progress) {
|
||||
await this.conversation.activity(
|
||||
this.l.r('task.contacts.started', { progress: progress }),
|
||||
);
|
||||
}
|
||||
|
||||
public async progress(progress: Progress) {
|
||||
await this.conversation.activity(
|
||||
this.l.r('task.contacts.progress', { progress: progress }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final report
|
||||
*/
|
||||
public async completed(progress: Progress) {
|
||||
await this.conversation.activity(
|
||||
this.l.r('task.contacts.completed', { progress: progress }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function ContactsPullStatusMessage(
|
||||
l: Locale,
|
||||
job: Job,
|
||||
state: JobState | 'unknown',
|
||||
) {
|
||||
const details = JobLink(job);
|
||||
const progress = lodash.merge({}, NullProgress, job.progress);
|
||||
const payload = {
|
||||
error: state === 'failed' || state === 'unknown' || progress.errors > 0,
|
||||
state: lodash.capitalize(state ?? 'unknown'),
|
||||
progress: progress,
|
||||
details: details,
|
||||
};
|
||||
return l.r('task.contacts.status', payload);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import * as ms from 'ms';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { JobLink } from '@waha/apps/app_sdk/JobUtils';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { ChatWootTaskConsumer } from '@waha/apps/chatwoot/consumers/task/base';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { Job, JobState } from 'bullmq';
|
||||
import * as lodash from 'lodash';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import {
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
MessageSortField,
|
||||
} from '@waha/structures/chats.dto';
|
||||
import { WAMessage } from '@waha/structures/responses.dto';
|
||||
import { EnsureSeconds } from '@waha/utils/timehelper';
|
||||
import { MessageAnyHandler } from '@waha/apps/chatwoot/consumers/waha/message.any';
|
||||
import { MessageReportInfo } from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
import { MessageAckEmoji } from '@waha/apps/chatwoot/emoji';
|
||||
import { SortOrder } from '@waha/structures/pagination.dto';
|
||||
import {
|
||||
ArrayPaginator,
|
||||
PaginatorParams,
|
||||
} from '@waha/apps/app_sdk/waha/Paginator';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/waha/engines';
|
||||
import { IgnoreJidConfig, isNullJid, JidFilter } from '@waha/core/utils/jids';
|
||||
import { ErrorRenderer } from '@waha/apps/chatwoot/error/ErrorRenderer';
|
||||
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
||||
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
|
||||
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
|
||||
import { addUnique } from '@waha/utils/list';
|
||||
|
||||
export enum ChatID {
|
||||
ALL = 'all',
|
||||
SUMMARY = 'summary',
|
||||
}
|
||||
|
||||
export type MessagesPullOptions = {
|
||||
chat: string;
|
||||
batch: number;
|
||||
progress: number | null;
|
||||
period: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
force: boolean;
|
||||
pause: boolean;
|
||||
timeout: {
|
||||
media: number;
|
||||
};
|
||||
media: boolean;
|
||||
ignore: IgnoreJidConfig;
|
||||
resolveConversations: boolean;
|
||||
};
|
||||
|
||||
interface Progress {
|
||||
ok: number;
|
||||
exists: number;
|
||||
ignored: number;
|
||||
errors: number;
|
||||
chats: string[];
|
||||
messages: {
|
||||
first?: number;
|
||||
last?: number;
|
||||
};
|
||||
// Conversations
|
||||
conversations: number[];
|
||||
conversationResolve: {
|
||||
success: number;
|
||||
error: number;
|
||||
};
|
||||
}
|
||||
|
||||
const NullProgress: Progress = {
|
||||
ok: 0,
|
||||
exists: 0,
|
||||
ignored: 0,
|
||||
errors: 0,
|
||||
chats: [],
|
||||
messages: {
|
||||
first: 0,
|
||||
last: 0,
|
||||
},
|
||||
conversations: [],
|
||||
conversationResolve: {
|
||||
success: 0,
|
||||
error: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function total(progress: Progress) {
|
||||
return progress.ok + progress.exists + progress.ignored + progress.errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder task consumer for pulling WhatsApp messages.
|
||||
* Throws until the implementation is completed.
|
||||
*/
|
||||
@Processor(QueueName.TASK_MESSAGES_PULL, { concurrency: JOB_CONCURRENCY })
|
||||
export class TaskMessagesPullConsumer extends ChatWootTaskConsumer {
|
||||
constructor(
|
||||
manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
protected queueManager: QueueManager,
|
||||
) {
|
||||
super(manager, log, rmutex, TaskMessagesPullConsumer.name);
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async Process(
|
||||
container: DIContainer,
|
||||
job: Job,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
const options: MessagesPullOptions = job.data.options;
|
||||
const locale = container.Locale();
|
||||
const conversation = this.conversationForReport(container, job.data.body);
|
||||
const waha = container.WAHASelf();
|
||||
const session = new WAHASessionAPI(job.data.session, waha);
|
||||
const handler = new MessagesPullHandler(
|
||||
this.queueManager,
|
||||
container,
|
||||
signal,
|
||||
container.Logger(),
|
||||
conversation,
|
||||
session,
|
||||
locale,
|
||||
);
|
||||
await handler.start(options, job);
|
||||
job = await handler.handle(options, job);
|
||||
return await handler.end(options, job);
|
||||
}
|
||||
}
|
||||
|
||||
class MessagesPullHandler {
|
||||
private activity: TaskActivity;
|
||||
|
||||
constructor(
|
||||
protected queueManager: QueueManager,
|
||||
protected container: DIContainer,
|
||||
protected signal: AbortSignal,
|
||||
protected logger: ILogger,
|
||||
conversation: Conversation,
|
||||
protected session: WAHASessionAPI,
|
||||
protected l: Locale,
|
||||
) {
|
||||
this.activity = new TaskActivity(l, conversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate summary progress including current task and direct children
|
||||
*/
|
||||
protected async summaryProgress(job: Job): Promise<Progress> {
|
||||
const current = lodash.merge({}, NullProgress, job.progress);
|
||||
const childrenValues = await job.getChildrenValues();
|
||||
const values: Progress[] = [current, ...Object.values(childrenValues)];
|
||||
let progress = lodash.merge({}, NullProgress);
|
||||
|
||||
// Result
|
||||
for (const p of values) {
|
||||
progress.ok += p.ok;
|
||||
progress.exists += p.exists;
|
||||
progress.ignored += p.ignored;
|
||||
progress.errors += p.errors;
|
||||
progress.conversationResolve.success += p.conversationResolve.success;
|
||||
progress.conversationResolve.error += p.conversationResolve.error;
|
||||
}
|
||||
// Chats
|
||||
progress.chats = lodash.uniq(lodash.flatten(values.map((p) => p.chats)));
|
||||
|
||||
// Conversations
|
||||
progress.conversations = lodash.uniq(
|
||||
lodash.flatten(values.map((p) => p.conversations)),
|
||||
);
|
||||
|
||||
// Messages timestamps
|
||||
progress.messages.first = lodash.min(
|
||||
values.map((p) => p.messages.first || Infinity),
|
||||
);
|
||||
if (progress.messages.first === Infinity) {
|
||||
progress.messages.first = undefined;
|
||||
}
|
||||
progress.messages.last = lodash.max(
|
||||
values.map((p) => p.messages.last || 0),
|
||||
);
|
||||
if (progress.messages.last === 0) {
|
||||
progress.messages.last = undefined;
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
|
||||
async start(options: MessagesPullOptions, job: Job) {
|
||||
const { ignored, processed, unprocessed, failed } =
|
||||
await job.getDependenciesCount();
|
||||
const total = ignored + processed + unprocessed + failed;
|
||||
const hasChildren = total > 0;
|
||||
if (hasChildren) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform some actions before any job started
|
||||
if (options.pause) {
|
||||
await this.queueManager.pause();
|
||||
await this.activity.queue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform actions after all jobs are done
|
||||
*/
|
||||
async end(options: MessagesPullOptions, job: Job) {
|
||||
if (job.parentKey) {
|
||||
// Not end yet, have parent tasks to process
|
||||
return await this.summaryProgress(job);
|
||||
}
|
||||
|
||||
// Resume queues if paused
|
||||
if (options.pause) {
|
||||
await this.queueManager.resume();
|
||||
await this.activity.queue(false);
|
||||
}
|
||||
// Progress summary
|
||||
const progress = await this.summaryProgress(job);
|
||||
await job.updateProgress(progress);
|
||||
|
||||
// Resolve conversations
|
||||
if (options.resolveConversations) {
|
||||
await this.resolveConversations(progress);
|
||||
await job.updateProgress(progress);
|
||||
}
|
||||
|
||||
// Report completed
|
||||
await this.activity.completed(progress, options);
|
||||
return progress;
|
||||
}
|
||||
|
||||
protected async resolveConversations(progress: Progress) {
|
||||
const service = this.container.ConversationService();
|
||||
for (const conversationId of progress.conversations) {
|
||||
try {
|
||||
await service.resolve(conversationId);
|
||||
progress.conversationResolve.success += 1;
|
||||
} catch (err) {
|
||||
progress.conversationResolve.error += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handle(options: MessagesPullOptions, job: Job): Promise<Job> {
|
||||
if (options.chat === ChatID.SUMMARY) {
|
||||
// Do nothing for "summary" chat
|
||||
return job;
|
||||
}
|
||||
const jids = new JidFilter(options.ignore);
|
||||
let progress = lodash.merge({}, NullProgress, job.progress);
|
||||
const batch = options.batch ?? 100;
|
||||
|
||||
this.logger.debug(
|
||||
`Pulling messages for session ${job.data.session} with batch size ${batch}...`,
|
||||
);
|
||||
if (lodash.isEqual(progress, NullProgress)) {
|
||||
await this.activity.started(job, options);
|
||||
}
|
||||
|
||||
// Handler
|
||||
const container = this.container;
|
||||
const info = new MessageReportInfo();
|
||||
const handler = new MessageAnyHistoryHandler(
|
||||
job,
|
||||
container.MessageMappingService(),
|
||||
container.ContactConversationService(),
|
||||
container.Logger(),
|
||||
info,
|
||||
this.session,
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
);
|
||||
handler.force = options.force;
|
||||
|
||||
// Messages
|
||||
const lte = job.timestamp - options.period.end;
|
||||
const gte = job.timestamp - options.period.start;
|
||||
const filters: GetChatMessagesFilter = {
|
||||
'filter.timestamp.lte': EnsureSeconds(lte),
|
||||
'filter.timestamp.gte': EnsureSeconds(gte),
|
||||
};
|
||||
|
||||
const query: GetChatMessagesQuery = {
|
||||
limit: batch,
|
||||
offset: 0,
|
||||
downloadMedia: false,
|
||||
sortBy: MessageSortField.TIMESTAMP,
|
||||
sortOrder: SortOrder.ASC,
|
||||
};
|
||||
const params: PaginatorParams = {
|
||||
processed: total(progress),
|
||||
};
|
||||
const paginator = new ArrayPaginator<WAMessage>(params);
|
||||
let messages = paginator.iterate((processed: number) => {
|
||||
query.offset = processed;
|
||||
return this.session.getMessages(options.chat, query, filters, {
|
||||
signal: this.signal,
|
||||
});
|
||||
});
|
||||
|
||||
messages = EngineHelper.IterateMessages(messages);
|
||||
const all = options.chat == ChatID.ALL;
|
||||
|
||||
for await (let message of messages) {
|
||||
this.signal.throwIfAborted();
|
||||
|
||||
//
|
||||
// Show progress
|
||||
//
|
||||
const thetotal = total(progress);
|
||||
if (options.progress && thetotal) {
|
||||
if (thetotal % options.progress == 0) {
|
||||
await this.activity.progress(progress, options);
|
||||
}
|
||||
}
|
||||
|
||||
// Process
|
||||
try {
|
||||
progress.messages.first = progress.messages.first || message.timestamp;
|
||||
progress.messages.last = message.timestamp;
|
||||
if (all && !jids.include(EngineHelper.ChatID(message))) {
|
||||
progress.ignored += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNullJid(EngineHelper.ChatID(message))) {
|
||||
progress.ignored += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
options.media &&
|
||||
message.hasMedia &&
|
||||
(await handler.ShouldProcessMessage(message))
|
||||
) {
|
||||
// Fetch media for the message
|
||||
let signal = AbortSignal.timeout(options.timeout.media);
|
||||
signal = AbortSignal.any([signal, this.signal]);
|
||||
message = await this.session.getMessageById('all', message.id, true, {
|
||||
signal: signal,
|
||||
});
|
||||
}
|
||||
|
||||
const chatwoot = await handler.handle(message);
|
||||
if (chatwoot) {
|
||||
progress.ok += 1;
|
||||
addUnique(progress.chats, EngineHelper.ChatID(message));
|
||||
if (info.conversationId) {
|
||||
addUnique(progress.conversations, info.conversationId);
|
||||
}
|
||||
} else {
|
||||
progress.exists += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
const renderer = new ErrorRenderer();
|
||||
const text = renderer.text(error);
|
||||
this.logger.error(`Error: ${text}`);
|
||||
this.logger.error(`Message:\n${JSON.stringify(message, null, 2)}`);
|
||||
try {
|
||||
const data = renderer.data(error);
|
||||
this.logger.error(JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error occurred while login details for error: ${err}`,
|
||||
);
|
||||
}
|
||||
progress.errors += 1;
|
||||
}
|
||||
// update progress before signal fails
|
||||
await job.updateProgress(progress);
|
||||
}
|
||||
job.progress = progress;
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskActivity {
|
||||
constructor(
|
||||
private l: Locale,
|
||||
private conversation: Conversation,
|
||||
) {}
|
||||
|
||||
public async queue(paused: boolean) {
|
||||
let msg: string;
|
||||
if (paused) {
|
||||
msg = this.l.r('cli.cmd.queue.paused');
|
||||
} else {
|
||||
msg = this.l.r('cli.cmd.queue.resumed');
|
||||
}
|
||||
await this.conversation.activity(msg);
|
||||
}
|
||||
|
||||
public async details(data) {
|
||||
await this.conversation.activity(
|
||||
this.l.r('task.messages.details', {
|
||||
prefix: IsCommandsChat(data.body) ? '' : CommandPrefix,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async started(job: Job, options: MessagesPullOptions) {
|
||||
await this.conversation.activity(
|
||||
this.l.r('task.messages.started', {
|
||||
chat: options.chat,
|
||||
period: period(options),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async progress(progress: Progress, options: MessagesPullOptions) {
|
||||
const format: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
};
|
||||
await this.conversation.activity(
|
||||
this.l.r('task.messages.progress', {
|
||||
progress: progress,
|
||||
chat: options.chat,
|
||||
messages: {
|
||||
first: this.l.FormatTimestampOpts(
|
||||
progress.messages.first,
|
||||
format,
|
||||
false,
|
||||
),
|
||||
last: this.l.FormatTimestampOpts(
|
||||
progress.messages.last,
|
||||
format,
|
||||
false,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final report
|
||||
*/
|
||||
public async completed(progress, options) {
|
||||
const msg = this.l.r('task.messages.completed', {
|
||||
progress: progress,
|
||||
period: period(options),
|
||||
chat: options.chat,
|
||||
});
|
||||
await this.conversation.activity(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageAnyHistoryHandler extends MessageAnyHandler {
|
||||
public force: boolean = false;
|
||||
public shouldLogUnsupported = true;
|
||||
|
||||
protected get historyMessage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected get shouldAddFromTag() {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected get delayFromMeAPI() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async ShouldProcessMessage(payload: WAMessage) {
|
||||
if (this.force) {
|
||||
return true;
|
||||
}
|
||||
return await super.ShouldProcessMessage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function period(options: MessagesPullOptions) {
|
||||
const start = options.period.start;
|
||||
const end = options.period.end;
|
||||
if (end == 0) {
|
||||
return ms(start);
|
||||
}
|
||||
return `${ms(start)}-${ms(end)}`;
|
||||
}
|
||||
|
||||
export function MessagesPullStatusMessage(
|
||||
l: Locale,
|
||||
job: Job,
|
||||
state: JobState | 'unknown',
|
||||
) {
|
||||
const progress = lodash.merge({}, NullProgress, job.progress);
|
||||
const details = JobLink(job);
|
||||
const options = job.data.options as MessagesPullOptions;
|
||||
const payload = {
|
||||
options: options,
|
||||
chat: options.chat,
|
||||
chats: progress.chats.length,
|
||||
period: period(options),
|
||||
error: state === 'failed' || state === 'unknown' || progress.errors > 0,
|
||||
state: lodash.capitalize(state ?? 'unknown'),
|
||||
progress: progress,
|
||||
details: details,
|
||||
messages: {
|
||||
first: l.FormatTimestamp(progress.messages.first, false),
|
||||
last: l.FormatTimestamp(progress.messages.last, false),
|
||||
},
|
||||
};
|
||||
|
||||
return l.r('task.messages.status', payload);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isJidGroup, isJidStatusBroadcast } from '@adiwajshing/baileys';
|
||||
import { conversation_message_create } from '@figuro/chatwoot-sdk';
|
||||
import type { generic_id } from '@figuro/chatwoot-sdk/dist/models/generic_id';
|
||||
import type { message } from '@figuro/chatwoot-sdk/dist/models/message';
|
||||
@@ -13,8 +12,8 @@ import { WhatsAppChatIdKey } from '@waha/apps/chatwoot/consumers/mutex';
|
||||
import { EventData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { Locale, TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { WAHASelf, WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import {
|
||||
AppRepository,
|
||||
ChatwootMessage,
|
||||
@@ -22,20 +21,19 @@ import {
|
||||
WhatsAppMessage,
|
||||
} from '@waha/apps/chatwoot/storage';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { toCusFormat } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { WAHAEvents } from '@waha/structures/enums.dto';
|
||||
import { MessageSource, WAMessageBase } from '@waha/structures/responses.dto';
|
||||
import {
|
||||
WAHAWebhook,
|
||||
WAHAWebhookMessageRevoked,
|
||||
} from '@waha/structures/webhooks.dto';
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { ChatWootAppConfig } from '../../dto/config.dto';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { isJidBroadcast, isJidGroup, toCusFormat } from '@waha/core/utils/jids';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/waha';
|
||||
import { EnsureSeconds } from '@waha/utils/timehelper';
|
||||
import { CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS } from '@waha/apps/chatwoot/env';
|
||||
|
||||
export function ListenEventsForChatWoot() {
|
||||
return [
|
||||
@@ -43,6 +41,7 @@ export function ListenEventsForChatWoot() {
|
||||
WAHAEvents.MESSAGE_REACTION,
|
||||
WAHAEvents.MESSAGE_EDITED,
|
||||
WAHAEvents.MESSAGE_REVOKED,
|
||||
WAHAEvents.MESSAGE_ACK,
|
||||
WAHAEvents.SESSION_STATUS,
|
||||
];
|
||||
}
|
||||
@@ -64,7 +63,7 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey | null {
|
||||
return TKey.WHATSAPP_ERROR_RECEIVING_MESSAGE;
|
||||
return TKey.WHATSAPP_MESSAGE_RECEIVING_ERROR;
|
||||
}
|
||||
|
||||
private async ProcessAndReportErrors(job) {
|
||||
@@ -97,8 +96,15 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
|
||||
messageInfo: IMessageInfo,
|
||||
): Promise<any>;
|
||||
|
||||
ShouldProcess(event: any): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async processJob(job: Job<EventData, any, WAHAEvents>): Promise<any> {
|
||||
const event: WAHAWebhookMessageRevoked = job.data.event as any;
|
||||
const event = job.data.event as any;
|
||||
if (!this.ShouldProcess(event)) {
|
||||
return;
|
||||
}
|
||||
const key = WhatsAppChatIdKey(job.data.app, this.GetChatId(event));
|
||||
return await this.withMutex(job, key, () =>
|
||||
this.ProcessAndReportErrors(job),
|
||||
@@ -151,7 +157,7 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
|
||||
.ContactConversationService()
|
||||
.ConversationById(info.conversationId);
|
||||
} else {
|
||||
const conversation = await container
|
||||
conversation = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
}
|
||||
@@ -170,7 +176,7 @@ export interface IMessageInfo {
|
||||
onMessageType(type: MessageType): void;
|
||||
}
|
||||
|
||||
class MessageReportInfo implements IMessageInfo {
|
||||
export class MessageReportInfo implements IMessageInfo {
|
||||
public conversationId: number | null = null;
|
||||
public type: MessageType | null = null;
|
||||
|
||||
@@ -183,8 +189,15 @@ class MessageReportInfo implements IMessageInfo {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatWootMessagePartial {
|
||||
content: string;
|
||||
attachments: SendAttachment[];
|
||||
private?: boolean;
|
||||
}
|
||||
|
||||
export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
|
||||
constructor(
|
||||
protected job: Job,
|
||||
protected mappingService: MessageMappingService,
|
||||
protected repo: ContactConversationService,
|
||||
protected logger: ILogger,
|
||||
@@ -194,14 +207,69 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
|
||||
protected waha: WAHASelf,
|
||||
) {}
|
||||
|
||||
abstract getContent(payload: Payload): string;
|
||||
protected abstract getMessage(
|
||||
payload: Payload,
|
||||
): Promise<ChatWootMessagePartial>;
|
||||
|
||||
protected get historyMessage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected finalizeContent(content: string, payload: Payload): string {
|
||||
if (!content) {
|
||||
return content;
|
||||
}
|
||||
if (!payload.timestamp) {
|
||||
return content;
|
||||
}
|
||||
if (!this.historyMessage) {
|
||||
// Check if the message is recent enough to be considered current
|
||||
const now = EnsureSeconds(Date.now() / 1000);
|
||||
const messageAge = now - EnsureSeconds(payload.timestamp);
|
||||
const recent = messageAge <= CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS;
|
||||
if (recent) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
const date = this.l.ParseTimestamp(payload.timestamp);
|
||||
if (!date) {
|
||||
return content;
|
||||
}
|
||||
const timestamp = this.l.FormatHumanDate(date);
|
||||
return this.l.r('whatsapp.history.message.wrapper', {
|
||||
content: content,
|
||||
payload: payload,
|
||||
history: this.historyMessage,
|
||||
timestamp: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
abstract getReplyToWhatsAppID(payload: Payload): string | undefined;
|
||||
|
||||
abstract getAttachments(payload: Payload): Promise<SendAttachment[]>;
|
||||
protected get delayFromMeAPI() {
|
||||
return 3_000;
|
||||
}
|
||||
|
||||
async handle(data: WAHAWebhook<Payload>) {
|
||||
const payload = data.payload;
|
||||
/**
|
||||
* Weather to add a tag to messages sent from me like via API or WhatsApp
|
||||
*/
|
||||
protected get shouldAddFromTag() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async ShouldProcessMessage(payload: Payload): Promise<boolean> {
|
||||
const key = parseMessageIdSerialized(payload.id);
|
||||
const chatwoot = await this.mappingService.getChatWootMessage({
|
||||
chat_id: toCusFormat(key.remoteJid),
|
||||
message_id: key.id,
|
||||
});
|
||||
if (chatwoot) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async handle(payload: Payload): Promise<conversation_message_create | null> {
|
||||
// Find the type as soon as possible for error reporting
|
||||
const type = payload.fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
|
||||
this.info.onMessageType(type);
|
||||
@@ -210,28 +278,25 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
|
||||
// It also handles messages fromMe and ChatWoot (but does not if send via API)
|
||||
if (payload.fromMe && payload.source === MessageSource.API) {
|
||||
// Sleep a few seconds to save it to a database
|
||||
await sleep(3_000);
|
||||
await sleep(this.delayFromMeAPI);
|
||||
}
|
||||
|
||||
const key = parseMessageIdSerialized(payload.id);
|
||||
const chatwoot = await this.mappingService.getChatWootMessage({
|
||||
chat_id: toCusFormat(key.remoteJid),
|
||||
message_id: key.id,
|
||||
});
|
||||
if (chatwoot) {
|
||||
this.logger.info(
|
||||
`Message '${payload.id}' already in ChatWoot: conversation.id=${chatwoot.conversation_id}, message.id=${chatwoot.message_id}`,
|
||||
);
|
||||
return;
|
||||
if (!(await this.ShouldProcessMessage(payload))) {
|
||||
const log = `Skipping existing message '${payload.id}' from WhatsApp`;
|
||||
this.logger.debug(log);
|
||||
return null;
|
||||
}
|
||||
|
||||
const contactInfo = WhatsAppContactInfo(this.session, payload.from, this.l);
|
||||
const contactInfo = WhatsAppContactInfo(
|
||||
this.session,
|
||||
EngineHelper.ChatID(payload as any),
|
||||
this.l,
|
||||
);
|
||||
const conversation = await this.repo.ConversationByContact(contactInfo);
|
||||
this.info.onConversationId(conversation.conversationId);
|
||||
|
||||
const message = await this.buildChatWootMessage(payload);
|
||||
const response = await conversation.send(message);
|
||||
this.logger.info(
|
||||
this.logger.debug(
|
||||
`Created message as '${message.message_type}' from WhatsApp: ${response.id}`,
|
||||
);
|
||||
await this.saveMapping(response, payload);
|
||||
@@ -262,25 +327,30 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
|
||||
private async buildChatWootMessage(
|
||||
payload: Payload,
|
||||
): Promise<conversation_message_create> {
|
||||
let content: string = this.getContent(payload);
|
||||
const message = await this.getMessage(payload);
|
||||
let content = message.content;
|
||||
|
||||
// Format the content if the message from me
|
||||
if (payload.fromMe && payload.source === MessageSource.APP) {
|
||||
const key = TKey.MESSAGE_FROM_WHATSAPP;
|
||||
content = this.l.key(key).render({ text: content });
|
||||
} else if (payload.fromMe && payload.source === MessageSource.API) {
|
||||
const key = TKey.MESSAGE_FROM_API;
|
||||
content = this.l.key(key).render({ text: content });
|
||||
if (payload.fromMe && this.shouldAddFromTag) {
|
||||
if (payload.source === MessageSource.APP) {
|
||||
const key = TKey.MESSAGE_FROM_WHATSAPP;
|
||||
content = this.l.key(key).render({ text: content });
|
||||
} else if (payload.source === MessageSource.API) {
|
||||
const key = TKey.MESSAGE_FROM_API;
|
||||
content = this.l.key(key).render({ text: content });
|
||||
}
|
||||
}
|
||||
|
||||
const chatId = payload.from;
|
||||
const chatId = EngineHelper.ChatID(payload);
|
||||
|
||||
// Add participant name to group messages
|
||||
const manyParticipants = isJidGroup(chatId) || isJidStatusBroadcast(chatId);
|
||||
const manyParticipants = isJidGroup(chatId) || isJidBroadcast(chatId);
|
||||
if (!payload.fromMe && manyParticipants) {
|
||||
const key = parseMessageIdSerialized(payload.id, true);
|
||||
let participant = toCusFormat(key.participant);
|
||||
const contact: any = await this.session.getContact(key.participant);
|
||||
let participant = toCusFormat(
|
||||
key.participant || (payload as any)._data.participant,
|
||||
);
|
||||
const contact: any = await this.session.getContact(participant);
|
||||
const name = contact?.name || contact?.pushName || contact?.pushname;
|
||||
if (name) {
|
||||
participant = `${name} (${participant})`;
|
||||
@@ -290,24 +360,22 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
|
||||
participant: participant,
|
||||
});
|
||||
}
|
||||
const attachments: SendAttachment[] = await this.getAttachments(payload);
|
||||
if (!content && attachments.length > 0) {
|
||||
// With no content ChatWoot render the message as an ugly big attachment
|
||||
content = ' ';
|
||||
}
|
||||
const replyTo = await this.getReplyToChatWootMessageID(payload).catch(
|
||||
(err) => {
|
||||
this.logger.error(`Error getting reply to message ID: ${err}`);
|
||||
this.logger.error(
|
||||
`WhatsApp => ChatWoot - error getting reply to message ID: ${err}`,
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const type = payload.fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
|
||||
content = this.finalizeContent(content, payload);
|
||||
return {
|
||||
content: content,
|
||||
message_type: type,
|
||||
private: payload.fromMe,
|
||||
attachments: attachments as any,
|
||||
private: message.private ?? payload.fromMe,
|
||||
attachments: message.attachments as any,
|
||||
content_attributes: {
|
||||
in_reply_to: replyTo,
|
||||
},
|
||||
@@ -322,9 +390,9 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
|
||||
return;
|
||||
}
|
||||
const chatwoot = await this.mappingService.getChatWootMessage({
|
||||
chat_id: payload.from,
|
||||
chat_id: EngineHelper.ChatID(payload),
|
||||
message_id: replyToWhatsAppID,
|
||||
});
|
||||
return chatwoot.message_id;
|
||||
return chatwoot?.message_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { EventData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import {
|
||||
ChatWootWAHABaseConsumer,
|
||||
IMessageInfo,
|
||||
} from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { WAHAEvents } from '@waha/structures/enums.dto';
|
||||
import { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
|
||||
import { toCusFormat } from '@waha/core/utils/jids';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import { MessageMappingService } from '@waha/apps/chatwoot/storage';
|
||||
|
||||
@Processor(QueueName.WAHA_MESSAGE_ACK, { concurrency: JOB_CONCURRENCY })
|
||||
export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'WAHAMessageAckConsumer');
|
||||
}
|
||||
|
||||
ShouldProcess(event: any): boolean {
|
||||
return ShouldMarkAsReadInChatWoot(event);
|
||||
}
|
||||
|
||||
GetChatId(event: WAHAWebhookMessageAck): string {
|
||||
return event.payload.from;
|
||||
}
|
||||
|
||||
async Process(
|
||||
job: Job<EventData, any, WAHAEvents>,
|
||||
info: IMessageInfo,
|
||||
): Promise<void> {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const event = job.data.event as WAHAWebhookMessageAck;
|
||||
const session = new WAHASessionAPI(event.session, container.WAHASelf());
|
||||
const handler = new MessageAckHandler(
|
||||
container.ContactConversationService(),
|
||||
container.MessageMappingService(),
|
||||
container.Logger(),
|
||||
info,
|
||||
session,
|
||||
container.Locale(),
|
||||
);
|
||||
try {
|
||||
await handler.handle(event);
|
||||
} catch (e) {
|
||||
// TODO: Investigate errors
|
||||
// https://github.com/devlikeapro/waha/issues/1492
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MessageAckHandler {
|
||||
constructor(
|
||||
private readonly contactConversationService: ContactConversationService,
|
||||
protected mappingService: MessageMappingService,
|
||||
private readonly logger: ILogger,
|
||||
private readonly info: IMessageInfo,
|
||||
private readonly session: WAHASessionAPI,
|
||||
private readonly locale: Locale,
|
||||
) {}
|
||||
|
||||
async handle(event: WAHAWebhookMessageAck): Promise<void> {
|
||||
const payload = event.payload;
|
||||
|
||||
const key = parseMessageIdSerialized(payload.id);
|
||||
const chatwoot = await this.mappingService.getChatWootMessage({
|
||||
chat_id: null,
|
||||
message_id: key.id,
|
||||
});
|
||||
// No chatwoot message found, so we don't mark it as read
|
||||
// Filters out old messages and some service ack messages
|
||||
if (!chatwoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = WhatsAppContactInfo(
|
||||
this.session,
|
||||
payload.from,
|
||||
this.locale,
|
||||
);
|
||||
const conversation =
|
||||
await this.contactConversationService.ConversationByContact(contactInfo);
|
||||
this.info.onConversationId(conversation.conversationId);
|
||||
|
||||
const sourceId = conversation.sourceId;
|
||||
if (!sourceId) {
|
||||
this.logger.warn(
|
||||
`Contact not found for chat.id: ${payload.from}. Skipping read update for message ${payload.id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.contactConversationService.markConversationAsRead(
|
||||
conversation.conversationId,
|
||||
sourceId,
|
||||
);
|
||||
this.logger.info(
|
||||
`Marked conversation ${conversation.conversationId} as read for chat.id: ${payload.from} (message: ${payload.id}, sourceId: ${sourceId})`,
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`Error marking conversation ${conversation.conversationId} as read for chat.id: ${payload.from}. Reason: ${reason}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import type { WAHAEngine, WAHAEvents } from '@waha/structures/enums.dto';
|
||||
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
|
||||
|
||||
interface TestParam {
|
||||
name: string;
|
||||
expected: boolean;
|
||||
GOWS: any;
|
||||
NOWEB: any;
|
||||
WEBJS: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Me - 444444444444 (lid - 144444444444444)
|
||||
* Participant - 11111111111 (lid - 011111111111111)
|
||||
* Group - 222222222222222222
|
||||
*/
|
||||
const ShouldMarkAsReadCases: TestParam[] = [
|
||||
{
|
||||
name: 'Participant read My message (READ ack)',
|
||||
expected: true,
|
||||
GOWS: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack' as WAHAEvents,
|
||||
payload: {
|
||||
id: 'true_11111111111@c.us_3333333333333333333333',
|
||||
from: '11111111111@c.us',
|
||||
to: null,
|
||||
participant: null,
|
||||
fromMe: true,
|
||||
ack: 3,
|
||||
ackName: 'READ',
|
||||
_data: {
|
||||
Chat: '11111111111@s.whatsapp.net',
|
||||
Sender: '11111111111@s.whatsapp.net',
|
||||
IsFromMe: false,
|
||||
IsGroup: false,
|
||||
AddressingMode: '',
|
||||
SenderAlt: '',
|
||||
RecipientAlt: '',
|
||||
BroadcastListOwner: '',
|
||||
BroadcastRecipients: null,
|
||||
MessageIDs: ['3333333333333333333333'],
|
||||
Type: 'read',
|
||||
MessageSender: '',
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
lid: '144444444444444@lid',
|
||||
jid: '444444444444:42@s.whatsapp.net',
|
||||
pushName: '',
|
||||
},
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'GOWS',
|
||||
tier: 'PLUS',
|
||||
browser: null,
|
||||
},
|
||||
engine: 'GOWS' as WAHAEngine,
|
||||
},
|
||||
NOWEB: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack',
|
||||
payload: {
|
||||
id: 'true_11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
from: '11111111111@c.us',
|
||||
fromMe: true,
|
||||
ack: 3,
|
||||
ackName: 'READ',
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
pushName: '',
|
||||
lid: '144444444444444@lid',
|
||||
},
|
||||
engine: 'NOWEB',
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'NOWEB',
|
||||
tier: 'PLUS',
|
||||
browser: null,
|
||||
},
|
||||
},
|
||||
WEBJS: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack',
|
||||
payload: {
|
||||
id: 'true_11111111111@c.us_AAA',
|
||||
from: '444444444444@c.us',
|
||||
fromMe: true,
|
||||
source: 'app',
|
||||
to: '11111111111@c.us',
|
||||
body: 'Nn',
|
||||
hasMedia: false,
|
||||
media: null,
|
||||
ack: 3,
|
||||
ackName: 'READ',
|
||||
vCards: [],
|
||||
_data: {
|
||||
id: {
|
||||
fromMe: true,
|
||||
remote: '11111111111@c.us',
|
||||
id: 'AAA',
|
||||
_serialized: 'true_11111111111@c.us_AAA',
|
||||
},
|
||||
viewed: false,
|
||||
from: '444444444444@c.us',
|
||||
to: '11111111111@c.us',
|
||||
author: '444444444444@c.us',
|
||||
ack: 3,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
pushName: '',
|
||||
},
|
||||
engine: 'WEBJS',
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'WEBJS',
|
||||
tier: 'PLUS',
|
||||
browser: '/usr/bin/google-chrome-stable',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Participant received My message (DEVICE ack)',
|
||||
expected: false,
|
||||
GOWS: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack' as WAHAEvents,
|
||||
payload: {
|
||||
id: 'true_11111111111@c.us_3333333333333333333333',
|
||||
from: '11111111111@c.us',
|
||||
to: null,
|
||||
participant: null,
|
||||
fromMe: true,
|
||||
ack: 2,
|
||||
ackName: 'DEVICE',
|
||||
_data: {
|
||||
Chat: '11111111111@s.whatsapp.net',
|
||||
Sender: '11111111111:3@s.whatsapp.net',
|
||||
IsFromMe: false,
|
||||
IsGroup: false,
|
||||
AddressingMode: '',
|
||||
SenderAlt: '',
|
||||
RecipientAlt: '',
|
||||
BroadcastListOwner: '',
|
||||
BroadcastRecipients: null,
|
||||
MessageIDs: ['3333333333333333333333'],
|
||||
Type: '',
|
||||
MessageSender: '',
|
||||
},
|
||||
},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
lid: '144444444444444@lid',
|
||||
jid: '444444444444:42@s.whatsapp.net',
|
||||
pushName: '',
|
||||
},
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'GOWS',
|
||||
tier: 'PLUS',
|
||||
browser: null,
|
||||
},
|
||||
engine: 'GOWS' as WAHAEngine,
|
||||
},
|
||||
NOWEB: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack',
|
||||
payload: {
|
||||
id: 'true_11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
from: '11111111111@c.us',
|
||||
fromMe: true,
|
||||
ack: 2,
|
||||
ackName: 'DEVICE',
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
pushName: '',
|
||||
lid: '1144444444444444@lid',
|
||||
},
|
||||
engine: 'NOWEB',
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'NOWEB',
|
||||
tier: 'PLUS',
|
||||
browser: null,
|
||||
},
|
||||
},
|
||||
WEBJS: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack',
|
||||
payload: {
|
||||
id: 'true_11111111111@c.us_AAA',
|
||||
from: '444444444444@c.us',
|
||||
fromMe: true,
|
||||
source: 'app',
|
||||
to: '11111111111@c.us',
|
||||
body: 'Nn',
|
||||
hasMedia: false,
|
||||
media: null,
|
||||
ack: 2,
|
||||
ackName: 'DEVICE',
|
||||
vCards: [],
|
||||
_data: {
|
||||
id: {
|
||||
fromMe: true,
|
||||
remote: '11111111111@c.us',
|
||||
id: 'AAA',
|
||||
_serialized: 'true_11111111111@c.us_AAA',
|
||||
},
|
||||
from: '444444444444@c.us',
|
||||
to: '11111111111@c.us',
|
||||
author: '444444444444@c.us',
|
||||
ack: 2,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
pushName: '',
|
||||
},
|
||||
engine: 'WEBJS',
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'WEBJS',
|
||||
tier: 'PLUS',
|
||||
browser: '/usr/bin/google-chrome-stable',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Me read Participant message on another device (READ ack)',
|
||||
expected: false,
|
||||
GOWS: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack' as WAHAEvents,
|
||||
payload: {
|
||||
id: 'false_11111111111@c.us_3333333333333333333333',
|
||||
from: '11111111111@c.us',
|
||||
to: null,
|
||||
participant: null,
|
||||
fromMe: false,
|
||||
ack: 3,
|
||||
ackName: 'READ',
|
||||
_data: {
|
||||
Chat: '11111111111@s.whatsapp.net',
|
||||
Sender: '444444444444@s.whatsapp.net',
|
||||
IsFromMe: true,
|
||||
IsGroup: false,
|
||||
AddressingMode: '',
|
||||
SenderAlt: '',
|
||||
RecipientAlt: '',
|
||||
BroadcastListOwner: '',
|
||||
BroadcastRecipients: null,
|
||||
MessageIDs: ['3333333333333333333333'],
|
||||
Type: 'read',
|
||||
MessageSender: '11111111111@s.whatsapp.net',
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
lid: '144444444444444@lid',
|
||||
jid: '444444444444:42@s.whatsapp.net',
|
||||
pushName: '',
|
||||
},
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'GOWS',
|
||||
tier: 'PLUS',
|
||||
browser: null,
|
||||
},
|
||||
engine: 'GOWS' as WAHAEngine,
|
||||
},
|
||||
NOWEB: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack',
|
||||
payload: {
|
||||
id: '11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
from: '11111111111@c.us',
|
||||
fromMe: false,
|
||||
ack: 3,
|
||||
ackName: 'READ',
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
pushName: '',
|
||||
lid: '1144444444444444@lid',
|
||||
},
|
||||
engine: 'NOWEB',
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'NOWEB',
|
||||
tier: 'PLUS',
|
||||
browser: null,
|
||||
},
|
||||
},
|
||||
WEBJS: null,
|
||||
},
|
||||
{
|
||||
name: 'Participant read My Group Message (READ ack)',
|
||||
expected: false,
|
||||
GOWS: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
payload: {
|
||||
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
|
||||
from: '222222222222222222@g.us',
|
||||
to: '011111111111111@lid',
|
||||
participant: '011111111111111@lid',
|
||||
fromMe: true,
|
||||
ack: 3,
|
||||
ackName: 'READ',
|
||||
_data: {
|
||||
Chat: '222222222222222222@g.us',
|
||||
Sender: '011111111111111@lid',
|
||||
IsFromMe: false,
|
||||
IsGroup: true,
|
||||
AddressingMode: '',
|
||||
SenderAlt: '',
|
||||
RecipientAlt: '',
|
||||
BroadcastListOwner: '',
|
||||
BroadcastRecipients: null,
|
||||
MessageIDs: ['AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'],
|
||||
Type: 'read',
|
||||
MessageSender: '',
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
lid: '144444444444444@lid',
|
||||
jid: '444444444444:42@s.whatsapp.net',
|
||||
pushName: '',
|
||||
},
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'GOWS',
|
||||
tier: 'PLUS',
|
||||
browser: null,
|
||||
},
|
||||
},
|
||||
NOWEB: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack',
|
||||
payload: {
|
||||
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
|
||||
from: '222222222222222222@g.us',
|
||||
to: '222222222222222222@g.us',
|
||||
participant: '011111111111111@lid',
|
||||
fromMe: true,
|
||||
ack: 3,
|
||||
ackName: 'READ',
|
||||
_data: {
|
||||
key: {
|
||||
remoteJid: '222222222222222222@g.us',
|
||||
id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
fromMe: true,
|
||||
participant: '011111111111111@lid',
|
||||
},
|
||||
receipt: {
|
||||
userJid: '011111111111111@lid',
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
pushName: '',
|
||||
lid: '1144444444444444@lid',
|
||||
},
|
||||
engine: 'NOWEB',
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'NOWEB',
|
||||
tier: 'PLUS',
|
||||
browser: null,
|
||||
},
|
||||
},
|
||||
WEBJS: {
|
||||
id: 'evt_00000000000000000000000000',
|
||||
session: 'default',
|
||||
event: 'message.ack',
|
||||
payload: {
|
||||
id: 'true_222222222222222222@g.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_444444444444@c.us',
|
||||
from: '222222222222222222@g.us',
|
||||
to: '222222222222222222@g.us',
|
||||
participant: '011111111111111@lid',
|
||||
fromMe: false,
|
||||
ack: 3,
|
||||
ackName: 'READ',
|
||||
_data: {
|
||||
tag: 'receipt',
|
||||
attrs: {
|
||||
from: {
|
||||
$1: {
|
||||
type: 0,
|
||||
user: '222222222222222222',
|
||||
server: 'g.us',
|
||||
},
|
||||
},
|
||||
type: 'read',
|
||||
id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
participant: {
|
||||
$1: {
|
||||
type: 1,
|
||||
user: '011111111111111',
|
||||
device: 0,
|
||||
domainType: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
content: null,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
me: {
|
||||
id: '444444444444@c.us',
|
||||
pushName: '',
|
||||
},
|
||||
engine: 'WEBJS',
|
||||
environment: {
|
||||
version: '2025.10.4',
|
||||
engine: 'WEBJS',
|
||||
tier: 'PLUS',
|
||||
browser: '/usr/bin/google-chrome-stable',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('ShouldMarkAsReadInChatWoot', () => {
|
||||
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
|
||||
for (const engine of engines) {
|
||||
describe(engine, () => {
|
||||
for (const param of ShouldMarkAsReadCases) {
|
||||
const name = `[${param.expected}] ${param.name}`;
|
||||
const event = param[engine];
|
||||
const testfn = event ? test : test.skip;
|
||||
const expected = param.expected;
|
||||
testfn(name, () => {
|
||||
// Test
|
||||
const result = ShouldMarkAsReadInChatWoot(event);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
|
||||
import { isJidCusFormat } from '@waha/utils/wa';
|
||||
import { WAMessageAck } from '@waha/structures/enums.dto';
|
||||
import { isLidUser, toCusFormat } from '@waha/core/utils/jids';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/waha';
|
||||
|
||||
export function ShouldMarkAsReadInChatWoot(
|
||||
event: WAHAWebhookMessageAck,
|
||||
): boolean {
|
||||
// Mark as seen only if it's DM
|
||||
// Ignore groups and other multiple participants chats
|
||||
const chatId = EngineHelper.ChatID(event.payload);
|
||||
if (!isJidCusFormat(chatId) && !isLidUser(chatId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = event.payload;
|
||||
// Only READ and PLAYED
|
||||
const read = payload.ack === WAMessageAck.READ;
|
||||
const played = payload.ack == WAMessageAck.PLAYED;
|
||||
if (!read && !played) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only process when OUR message (fromMe: true) was read by recipient
|
||||
if (!payload.fromMe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark ChatWoot conversation as read when recipient reads our message
|
||||
return true;
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { SendAttachment } from '@waha/apps/chatwoot/client/types';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { EventData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import {
|
||||
ChatWootMessagePartial,
|
||||
ChatWootWAHABaseConsumer,
|
||||
IMessageInfo,
|
||||
MessageBaseHandler,
|
||||
} from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
import { MessageBaseHandler } from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/text';
|
||||
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
@@ -18,9 +17,22 @@ import { WAMessage } from '@waha/structures/responses.dto';
|
||||
import { WAHAWebhookMessageAny } from '@waha/structures/webhooks.dto';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime-types');
|
||||
import {
|
||||
FacebookAdMessage,
|
||||
EventMessage,
|
||||
PixMessage,
|
||||
LocationMessage,
|
||||
MessageToChatWootConverter,
|
||||
PollMessage,
|
||||
ListMessage,
|
||||
ShareContactMessage,
|
||||
TextMessage,
|
||||
UnsupportedMessage,
|
||||
AlbumMessage,
|
||||
resolveProtoMessage,
|
||||
} from '@waha/apps/chatwoot/messages/to/chatwoot';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/waha';
|
||||
import { HasMediaWithNoMediaMessage } from '@waha/apps/chatwoot/messages/to/chatwoot/HasMediaWithNoMediaMessage';
|
||||
|
||||
@Processor(QueueName.WAHA_MESSAGE_ANY, { concurrency: JOB_CONCURRENCY })
|
||||
export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
|
||||
@@ -33,7 +45,7 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
|
||||
}
|
||||
|
||||
GetChatId(event: WAHAWebhookMessageAny): string {
|
||||
return event.payload.from;
|
||||
return EngineHelper.ChatID(event.payload);
|
||||
}
|
||||
|
||||
async Process(
|
||||
@@ -44,6 +56,7 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
|
||||
const event: WAHAWebhookMessageAny = job.data.event as any;
|
||||
const session = new WAHASessionAPI(event.session, container.WAHASelf());
|
||||
const handler = new MessageAnyHandler(
|
||||
job,
|
||||
container.MessageMappingService(),
|
||||
container.ContactConversationService(),
|
||||
container.Logger(),
|
||||
@@ -52,14 +65,89 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
);
|
||||
return await handler.handle(event);
|
||||
return await handler.handle(event.payload);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
|
||||
getContent(payload: WAMessage): string {
|
||||
const body = payload.body;
|
||||
return WhatsappToMarkdown(body);
|
||||
export class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
|
||||
public shouldLogUnsupported: boolean = false;
|
||||
|
||||
protected async getMessage(
|
||||
payload: WAMessage,
|
||||
): Promise<ChatWootMessagePartial> {
|
||||
let converter: MessageToChatWootConverter;
|
||||
let msg: ChatWootMessagePartial;
|
||||
const protoMessage = resolveProtoMessage(payload);
|
||||
|
||||
// Check for Facebook Ad first - but let it use the normal flow later
|
||||
converter = new FacebookAdMessage(this.l, this.logger);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new TextMessage(this.l, this.logger, this.waha, this.job);
|
||||
msg = await converter.convert(payload, null);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new LocationMessage(this.l);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new ShareContactMessage(this.l, this.logger);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new PollMessage(this.l);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new ListMessage(this.l);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new EventMessage(this.l);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new PixMessage(this.l, this.logger);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new AlbumMessage(this.l);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new HasMediaWithNoMediaMessage(this.l, this.job);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new UnsupportedMessage(this.l, this.job);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (this.shouldLogUnsupported) {
|
||||
this.logger.warn(
|
||||
`UnsupportedMessage:\n${JSON.stringify(payload, null, 2)}`,
|
||||
);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
getReplyToWhatsAppID(payload: WAMessage): string {
|
||||
@@ -67,34 +155,10 @@ class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
|
||||
if (!replyTo) {
|
||||
return undefined;
|
||||
}
|
||||
if (!replyTo.id) {
|
||||
return undefined;
|
||||
}
|
||||
const key = parseMessageIdSerialized(replyTo.id, true);
|
||||
return key.id;
|
||||
}
|
||||
|
||||
async getAttachments(payload: WAMessage): Promise<SendAttachment[]> {
|
||||
const hasMedia = payload.media?.url;
|
||||
if (!hasMedia) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachments: SendAttachment[] = [];
|
||||
const media = payload.media;
|
||||
this.logger.info(`Downloading media from '${media.url}'...`);
|
||||
const buffer = await this.waha.fetch(media.url);
|
||||
const fileContent = buffer.toString('base64');
|
||||
let filename = media.filename;
|
||||
if (!filename) {
|
||||
const extension = mime.extension(media.mimetype);
|
||||
filename = `no-filename.${extension}`;
|
||||
}
|
||||
|
||||
const attachment: SendAttachment = {
|
||||
content: fileContent,
|
||||
filename: filename,
|
||||
encoding: 'base64',
|
||||
};
|
||||
attachments.push(attachment);
|
||||
this.logger.info(`Downloaded media from '${media.url}' as '${filename}'`);
|
||||
return attachments;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,11 @@ import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { EventData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import {
|
||||
ChatWootMessagePartial,
|
||||
ChatWootWAHABaseConsumer,
|
||||
IMessageInfo,
|
||||
MessageBaseHandler,
|
||||
} from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
import { MessageBaseHandler } from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
import { TKey } from '@waha/apps/chatwoot/locale';
|
||||
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/text';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { WAHAEvents } from '@waha/structures/enums.dto';
|
||||
@@ -19,8 +18,13 @@ import {
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { SendAttachment } from '../../client/types';
|
||||
import { WAHASessionAPI } from '../../session/WAHASelf';
|
||||
import { WAHASessionAPI } from '../../../app_sdk/waha/WAHASelf';
|
||||
import {
|
||||
MessageEdited,
|
||||
MessageToChatWootConverter,
|
||||
resolveProtoMessage,
|
||||
} from '@waha/apps/chatwoot/messages/to/chatwoot';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/waha';
|
||||
|
||||
@Processor(QueueName.WAHA_MESSAGE_EDITED, { concurrency: JOB_CONCURRENCY })
|
||||
export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
|
||||
@@ -33,7 +37,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
|
||||
}
|
||||
|
||||
GetChatId(event: WAHAWebhookMessageEdited): string {
|
||||
return event.payload.from;
|
||||
return EngineHelper.ChatID(event.payload);
|
||||
}
|
||||
|
||||
async Process(
|
||||
@@ -44,6 +48,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
|
||||
const event: WAHAWebhookMessageEdited = job.data.event as any;
|
||||
const session = new WAHASessionAPI(event.session, container.WAHASelf());
|
||||
const handler = new MessageEditedHandler(
|
||||
job,
|
||||
container.MessageMappingService(),
|
||||
container.ContactConversationService(),
|
||||
container.Logger(),
|
||||
@@ -52,26 +57,20 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
);
|
||||
return await handler.handle(event);
|
||||
return await handler.handle(event.payload);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageEditedHandler extends MessageBaseHandler<WAMessageEditedBody> {
|
||||
getContent(payload: WAMessageEditedBody): string {
|
||||
const body = payload.body;
|
||||
const formatted = WhatsappToMarkdown(body);
|
||||
return this.l
|
||||
.key(TKey.MESSAGE_EDITED_IN_WHATSAPP)
|
||||
.render({ text: formatted });
|
||||
protected async getMessage(
|
||||
payload: WAMessageEditedBody,
|
||||
): Promise<ChatWootMessagePartial> {
|
||||
const protoMessage = resolveProtoMessage(payload);
|
||||
const converter: MessageToChatWootConverter = new MessageEdited(this.l);
|
||||
return converter.convert(payload, protoMessage);
|
||||
}
|
||||
|
||||
getReplyToWhatsAppID(payload: WAMessageEditedBody): string {
|
||||
return payload.editedMessageId;
|
||||
}
|
||||
|
||||
async getAttachments(
|
||||
payload: WAMessageEditedBody,
|
||||
): Promise<SendAttachment[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user