Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d5f399f7d5 | ||
|
|
dbd3bd024c | ||
|
|
9ba7e19da3 | ||
|
|
5f6d1412cf | ||
|
|
0b5af3145e | ||
|
|
ad10ea4e96 | ||
|
|
9de53a972a | ||
|
|
4043efbb9a | ||
|
|
42019644f0 | ||
|
|
bcc25ef42f | ||
|
|
602bbf3b60 | ||
|
|
585ac6074f | ||
|
|
5bd1a43df6 | ||
|
|
c9279a4b59 | ||
|
|
9ff8eed6ba | ||
|
|
96ffdd251f | ||
|
|
35b8b7d01c | ||
|
|
4e2e2b2b3c | ||
|
|
bfff0c8cf4 | ||
|
|
e253f445b9 | ||
|
|
73ed663c9c | ||
|
|
2236150b83 | ||
|
|
da24feb390 | ||
|
|
44a5ada162 | ||
|
|
e57cc2587c | ||
|
|
38bfb9baa5 | ||
|
|
540636d59b | ||
|
|
3cc784353e | ||
|
|
44946ee784 | ||
|
|
a9d77487c6 | ||
|
|
70d4f27abd | ||
|
|
9fca09661d | ||
|
|
2f0efd0223 | ||
|
|
5c31ed53df | ||
|
|
87640ea238 | ||
|
|
eb8f771b06 | ||
|
|
b080b5862e | ||
|
|
df50e7527e | ||
|
|
e2b43575c0 | ||
|
|
2059793562 | ||
|
|
e5df21e165 | ||
|
|
792029b13f | ||
|
|
0ef49d7116 | ||
|
|
600d3291eb | ||
|
|
b78413190c | ||
|
|
c9729af139 | ||
|
|
697f267fc3 | ||
|
|
ac49ce3cc5 | ||
|
|
555daf6888 | ||
|
|
8f0c2994a6 | ||
|
|
873e66db19 | ||
|
|
38b542def4 | ||
|
|
a646e5efc0 | ||
|
|
31590c9229 | ||
|
|
98c2172908 | ||
|
|
314b02daf1 | ||
|
|
d2ab78b65c | ||
|
|
db2c352fd1 | ||
|
|
6e79ad2793 | ||
|
|
77eaa287fe | ||
|
|
a64820048c | ||
|
|
4ff4a5a893 | ||
|
|
5ea5681d2b | ||
|
|
2bb327b747 | ||
|
|
4dcdf60309 | ||
|
|
009fd5b2ec | ||
|
|
091e7e5bc1 | ||
|
|
ea1202bb65 | ||
|
|
cb413febaa | ||
|
|
364e55d477 | ||
|
|
680a35a26d | ||
|
|
08ff59e463 | ||
|
|
9db4c8c333 | ||
|
|
2489c2da46 | ||
|
|
fbc58b0707 | ||
|
|
d5a525789e | ||
|
|
34066921e1 | ||
|
|
901c594311 | ||
|
|
5f619e608b | ||
|
|
49295090ad | ||
|
|
a6e43adb0a | ||
|
|
1f165bb6cb | ||
|
|
8fc9d88d2e | ||
|
|
d7e5a5ce05 | ||
|
|
09d446fdeb | ||
|
|
c0bbd60e15 | ||
|
|
af3fc137be | ||
|
|
0ee5e1bd88 | ||
|
|
f92ea18651 | ||
|
|
3cce2bb35f | ||
|
|
e080ce42ee | ||
|
|
5dbe41da78 | ||
|
|
eef3957d4f | ||
|
|
680f4596b9 | ||
|
|
8de344f220 | ||
|
|
8005846955 | ||
|
|
3edd60448b | ||
|
|
4a2fa5c076 | ||
|
|
96e9c2cc4e | ||
|
|
2089a1bb35 | ||
|
|
023c2718e2 | ||
|
|
f7aacb193a | ||
|
|
e710040904 | ||
|
|
fb12826801 | ||
|
|
833a70c6e0 | ||
|
|
c807f9021c | ||
|
|
46b66bcdf0 | ||
|
|
2ebade817e | ||
|
|
080342e619 | ||
|
|
75b06d3ba1 | ||
|
|
3b6fa3343d | ||
|
|
469cdc9b1d | ||
|
|
8a179e7194 | ||
|
|
c133795753 | ||
|
|
941ace7461 | ||
|
|
8589420f5d | ||
|
|
ece24e182f | ||
|
|
38e5bb7bdf | ||
|
|
d09e931827 | ||
|
|
3f3f1e1c20 | ||
|
|
32f2468ba8 | ||
|
|
1d6540e0d9 | ||
|
|
31da4bfca1 | ||
|
|
41d49894e2 | ||
|
|
6dcdd15b1c | ||
|
|
c2b275fd90 | ||
|
|
e4838ef3ef | ||
|
|
55ca4660d7 | ||
|
|
c16c903ec4 | ||
|
|
937cf38ff6 | ||
|
|
07f76e854d | ||
|
|
cde1d084c6 | ||
|
|
9362c5d6ae | ||
|
|
117a9108c0 | ||
|
|
5214e461f2 | ||
|
|
9bdb178b7d | ||
|
|
9c526ffdba | ||
|
|
f80a74edd1 | ||
|
|
7d8c6c0a62 | ||
|
|
17b26b869b | ||
|
|
dfbe54e92f | ||
|
|
2321de409b | ||
|
|
502287a258 | ||
|
|
052ff0ea6e | ||
|
|
2b76789cdf | ||
|
|
7bbf5df231 | ||
|
|
794a04e7d3 | ||
|
|
645467f13a | ||
|
|
7e87637341 | ||
|
|
b7abeed54a | ||
|
|
d53646eef2 | ||
|
|
c54743de6c | ||
|
|
e1fdc41132 | ||
|
|
060aa82294 | ||
|
|
2df08eac3f | ||
|
|
0ecdb9d3d0 | ||
|
|
6a7fe1bd1f | ||
|
|
87c69cb3d0 | ||
|
|
c654855c22 | ||
|
|
11bb006801 | ||
|
|
e5551f3f64 | ||
|
|
0de148005f | ||
|
|
27e4ce628e | ||
|
|
19b752bf15 | ||
|
|
6528e5bba9 | ||
|
|
37e1183e87 | ||
|
|
824512cf8e | ||
|
|
82f296b17d | ||
|
|
8302530954 | ||
|
|
6c271829aa | ||
|
|
3b30d339ee | ||
|
|
c5a268bdbd | ||
|
|
8457ea8e47 | ||
|
|
72d69eeab5 | ||
|
|
31885e41af | ||
|
|
e63726d18b | ||
|
|
3d2ab45d25 | ||
|
|
246478e1d8 | ||
|
|
9418de3b23 | ||
|
|
c6b68cad37 | ||
|
|
ef36e4d3ac | ||
|
|
ba2ddd62ad | ||
|
|
6a03e1488c | ||
|
|
cabd2ed562 | ||
|
|
4a9296276d | ||
|
|
1fd84d824c | ||
|
|
faa55ffe2d | ||
|
|
d45af4191f | ||
|
|
9f8dead8b8 | ||
|
|
564037cd53 | ||
|
|
434de6292c | ||
|
|
a12e6990e5 | ||
|
|
ce0c4a094b | ||
|
|
e36728ec08 | ||
|
|
2c1f3090a0 | ||
|
|
0c6d4b3581 | ||
|
|
1d5b9eb241 | ||
|
|
b0567dafe3 | ||
|
|
518f78f067 | ||
|
|
47a2c21824 | ||
|
|
aca5547965 | ||
|
|
0e07876630 | ||
|
|
a43901b4a4 | ||
|
|
6515fac16d | ||
|
|
fdaa7e9f48 | ||
|
|
cd779850de | ||
|
|
ef939f4357 | ||
|
|
bfb9dd7009 | ||
|
|
f282f14eff | ||
|
|
992b289500 | ||
|
|
9dcdebcab2 | ||
|
|
c0b8bacab0 | ||
|
|
f96e6420fc | ||
|
|
d7f97480cc | ||
|
|
26df41b571 | ||
|
|
4ca336017d | ||
|
|
94252ea54c | ||
|
|
f8220fec1f | ||
|
|
a5ded396c9 | ||
|
|
c457cf8064 | ||
|
|
3079f90118 | ||
|
|
4fbb331015 | ||
|
|
640c0d305c | ||
|
|
0b044c67bb | ||
|
|
7a37ba8145 | ||
|
|
b447863d9f | ||
|
|
6dd7e14f6f | ||
|
|
b09ef98dce | ||
|
|
ff1cf8a288 | ||
|
|
603793662a | ||
|
|
4cdab0dbc7 | ||
|
|
d745279fec | ||
|
|
df34c9f2fd | ||
|
|
37da9aabe3 | ||
|
|
3a2205147c | ||
|
|
5c6acaf10a | ||
|
|
d916f2dea5 | ||
|
|
254a1aedcb | ||
|
|
dce3175388 | ||
|
|
e2b6b45418 | ||
|
|
e210d1e9fc | ||
|
|
df0f449e13 | ||
|
|
895105c900 | ||
|
|
209538709b | ||
|
|
ef7c35286b | ||
|
|
70af0828cb |
No files matched your search
@@ -1,4 +1,5 @@
|
||||
Dockerfile
|
||||
docker-compose
|
||||
src/core/engines/gows/proto
|
||||
.*sessions
|
||||
sessions
|
||||
|
||||
+99
-170
@@ -1,191 +1,120 @@
|
||||
# WAHA Configuration
|
||||
# WAHA - WhatsApp HTTP API
|
||||
#
|
||||
# https://waha.devlike.pro/docs/how-to/config/
|
||||
#
|
||||
# Common
|
||||
#
|
||||
# If you know the right URL, you can use it here.
|
||||
# Use any port in WHATSAPP_API_PORT and domain in WHATSAPP_API_HOSTNAME
|
||||
# https://waha.devlike.pro/docs/how-to/config/#common
|
||||
|
||||
# ==================
|
||||
# ===== COMMON =====
|
||||
# ==================
|
||||
# Base URL for the API (used for webhooks, file URLs, etc.)
|
||||
WAHA_BASE_URL=http://localhost:3000
|
||||
|
||||
#WHATSAPP_API_SCHEMA=http
|
||||
#WHATSAPP_API_PORT=3000
|
||||
#WHATSAPP_API_HOSTNAME=localhost
|
||||
# Server configuration (if you need to customize hostname/port)
|
||||
# WHATSAPP_API_SCHEMA=http
|
||||
# WHATSAPP_API_PORT=3000
|
||||
# WHATSAPP_API_HOSTNAME=localhost
|
||||
|
||||
#
|
||||
# Set your timezone to see the right time on screenshots
|
||||
# Find your timezone name in the list
|
||||
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
#TZ=Europe/Dublin
|
||||
|
||||
#
|
||||
# Security
|
||||
#
|
||||
# https://waha.devlike.pro/docs/how-to/security/
|
||||
#
|
||||
WHATSAPP_API_KEY=321
|
||||
WAHA_DASHBOARD_USERNAME=waha
|
||||
WAHA_DASHBOARD_PASSWORD=waha
|
||||
#WHATSAPP_SWAGGER_USERNAME=admin
|
||||
#WHATSAPP_SWAGGER_PASSWORD=admin
|
||||
|
||||
#
|
||||
# Dashboard
|
||||
#
|
||||
# https://waha.devlike.pro/docs/how-to/waha-dashboard/#configuration
|
||||
# Enabled by default
|
||||
#WAHA_DASHBOARD_ENABLED=true
|
||||
#WAHA_DASHBOARD_USERNAME=waha
|
||||
#WAHA_DASHBOARD_PASSWORD=waha
|
||||
|
||||
#
|
||||
# Logging
|
||||
# https://waha.devlike.pro/docs/how-to/observability/#logging
|
||||
#
|
||||
# Set log format to JSON if you consume logs with log management systems
|
||||
WAHA_LOG_FORMAT=JSON
|
||||
WAHA_LOG_LEVEL=info
|
||||
# WAHA_LOG_FORMAT=PRETTY
|
||||
# If you want to see more logs, you can set the log level to debug
|
||||
#WAHA_LOG_LEVEL=debug
|
||||
# DEBUG=1 # shortcut for setting log level to debug
|
||||
|
||||
#
|
||||
# Engine
|
||||
# https://waha.devlike.pro/docs/how-to/engines/
|
||||
#
|
||||
# Choose the right engine for your needs (WEBJS by default)
|
||||
WHATSAPP_DEFAULT_ENGINE=WEBJS
|
||||
# Use NOWEB engine for the fastest performance
|
||||
#WHATSAPP_DEFAULT_ENGINE=NOWEB
|
||||
|
||||
# Worker
|
||||
# WAHA_WORKER_ID=waha1
|
||||
|
||||
#
|
||||
# Sessions
|
||||
# https://waha.devlike.pro/docs/how-to/config/#sessions
|
||||
#
|
||||
# Do not print QR codes in logs
|
||||
WAHA_PRINT_QR=False
|
||||
#
|
||||
# OR you can specify sessions by name, if you have handful amount of sessions
|
||||
#WHATSAPP_START_SESSION=session1,session2
|
||||
#
|
||||
# Restart all sessions when container starts
|
||||
# !!! Do not use it with multiple workers against one database !!!
|
||||
# WHATSAPP_RESTART_ALL_SESSIONS=True
|
||||
|
||||
#
|
||||
# Swagger
|
||||
# https://waha.devlike.pro/docs/how-to/swagger/
|
||||
#
|
||||
# ====================
|
||||
# ===== 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
|
||||
# Disable Swagger
|
||||
#WHATSAPP_SWAGGER_ENABLED=false
|
||||
# Enable advanced Swagger configuration
|
||||
#WHATSAPP_SWAGGER_CONFIG_ADVANCED=true
|
||||
#
|
||||
# Swagger White Label customization
|
||||
# https://waha.devlike.pro/docs/how-to/swagger/#white-label
|
||||
#WHATSAPP_SWAGGER_TITLE=WAHA API
|
||||
#
|
||||
|
||||
#
|
||||
# PostgresSQL Configuration
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#sessions---postgresql
|
||||
#WHATSAPP_SESSIONS_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
#
|
||||
# Media - PostgresSQL Storage
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#media---postgresql
|
||||
#WAHA_MEDIA_STORAGE=POSTGRESQL
|
||||
#WAHA_MEDIA_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
#
|
||||
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
|
||||
WHATSAPP_DEFAULT_ENGINE=WEBJS
|
||||
|
||||
#
|
||||
# Global Proxy Settings
|
||||
# https://waha.devlike.pro/docs/how-to/config/#global-proxy-configuration
|
||||
#
|
||||
# You can also set proxy settings for each session later when starting it
|
||||
# https://waha.devlike.pro/docs/how-to/sessions/#configure-proxy
|
||||
#WHATSAPP_PROXY_SERVER=proxy.example.com:3128
|
||||
#WHATSAPP_PROXY_SERVER_USERNAME=user
|
||||
#WHATSAPP_PROXY_SERVER_PASSWORD=pass
|
||||
#
|
||||
# ===================
|
||||
# ===== LOGGING =====
|
||||
# ===================
|
||||
# Log format: JSON (for log management systems) or PRETTY (for development)
|
||||
WAHA_LOG_FORMAT=JSON
|
||||
|
||||
#
|
||||
# Media Configuration
|
||||
#
|
||||
# https://waha.devlike.pro/docs/how-to/config/#files
|
||||
#
|
||||
# Disable media (images, videos, files) download for incoming messages
|
||||
#WHATSAPP_DOWNLOAD_MEDIA=false
|
||||
#
|
||||
# Download only specific media types
|
||||
#WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
||||
#
|
||||
# Log level: info, debug, error, warn
|
||||
WAHA_LOG_LEVEL=info
|
||||
|
||||
# Media - Local Storage
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#media---local
|
||||
#
|
||||
# Set to 0 to keep media files forever
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#save-media-files-between-the-container-restarts
|
||||
# Don't print QR codes in logs
|
||||
WAHA_PRINT_QR=False
|
||||
|
||||
# =========================
|
||||
# ===== MEDIA STORAGE =====
|
||||
# =========================
|
||||
# Local storage (default)
|
||||
WAHA_MEDIA_STORAGE=LOCAL
|
||||
WHATSAPP_FILES_LIFETIME=0
|
||||
WHATSAPP_FILES_FOLDER=/app/.media
|
||||
# Keep media files for 180 seconds (3 minutes)
|
||||
#WHATSAPP_FILES_LIFETIME=180
|
||||
#
|
||||
|
||||
# Media - S3 Storage
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#media---s3
|
||||
#WAHA_MEDIA_STORAGE=S3
|
||||
#WAHA_S3_REGION=eu-west-2
|
||||
#WAHA_S3_BUCKET=waha
|
||||
#WAHA_S3_ACCESS_KEY_ID=minioadmin
|
||||
#WAHA_S3_SECRET_ACCESS_KEY=minioadmin
|
||||
#WAHA_S3_ENDPOINT=http://minio:9000 # Not required if you're using AWS S3
|
||||
#WAHA_S3_FORCE_PATH_STYLE=True # Required for Minio
|
||||
#WAHA_S3_PROXY_FILES=True # Required for docker-compose setup
|
||||
# Media download settings
|
||||
# WHATSAPP_DOWNLOAD_MEDIA=true
|
||||
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
||||
|
||||
#
|
||||
# Global Webhooks
|
||||
#
|
||||
# https://waha.devlike.pro/docs/how-to/config/#webhooks
|
||||
# https://waha.devlike.pro/docs/how-to/webhooks/
|
||||
#WHATSAPP_HOOK_URL=https://webhook.site/11111111-1111-1111-1111-11111111
|
||||
#WHATSAPP_HOOK_EVENTS=session.status,message,message.reaction
|
||||
#
|
||||
# S3 storage (uncomment to use)
|
||||
# WAHA_MEDIA_STORAGE=S3
|
||||
# WAHA_S3_REGION=eu-west-2
|
||||
# WAHA_S3_BUCKET=waha
|
||||
# WAHA_S3_ACCESS_KEY_ID=minioadmin
|
||||
# WAHA_S3_SECRET_ACCESS_KEY=minioadmin
|
||||
# WAHA_S3_ENDPOINT=http://minio:9000
|
||||
# WAHA_S3_FORCE_PATH_STYLE=True
|
||||
# WAHA_S3_PROXY_FILES=True
|
||||
|
||||
#
|
||||
# Local Configuration
|
||||
#
|
||||
# WAHA_LOCAL_STORE_BASE_DIR=/app/sessions
|
||||
# Remember to map the volume to the host machine to the right direction in "volumes" field in docker-compose.yml
|
||||
# volumes:
|
||||
# - './.sessions:/app/sessions'
|
||||
# PostgreSQL storage (uncomment to use)
|
||||
# WAHA_MEDIA_STORAGE=POSTGRESQL
|
||||
# WAHA_MEDIA_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
|
||||
# ===========================
|
||||
# ===== SESSION STORAGE =====
|
||||
# ===========================
|
||||
# PostgreSQL for sessions (uncomment to use)
|
||||
# WHATSAPP_SESSIONS_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
|
||||
# MongoDB for sessions (uncomment to use)
|
||||
# WHATSAPP_SESSIONS_MONGO_URL=mongodb://mongouser:mongopassword@mongodb:27017
|
||||
|
||||
# =================
|
||||
# ===== APPS ======
|
||||
# =================
|
||||
# WAHA_APPS_ENABLED=True
|
||||
# REDIS_URL=redis://:redis@redis:6379
|
||||
|
||||
# Apps Jobs configuration - *_AGE in seconds
|
||||
# WAHA_APPS_JOBS_REMOVE_ON_COMPLETE_AGE=
|
||||
# WAHA_APPS_JOBS_REMOVE_ON_COMPLETE_COUNT=
|
||||
# WAHA_APPS_JOBS_REMOVE_ON_FAIL_AGE=
|
||||
# WAHA_APPS_JOBS_REMOVE_ON_FAIL_COUNT
|
||||
|
||||
|
||||
#
|
||||
# MongoDB Configuration
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#sessions---mongodb
|
||||
#WHATSAPP_SESSIONS_MONGO_URL=mongodb://mongouser:mongopassword@mongodb:27017
|
||||
#
|
||||
# ==================================
|
||||
# ===== ADVANCED CONFIGURATION =====
|
||||
# ==================================
|
||||
|
||||
#
|
||||
# HTTPS Configuration
|
||||
# https://waha.devlike.pro/docs/how-to/config/#https
|
||||
#
|
||||
# Consider using certbot for HTTPS
|
||||
# https://waha.devlike.pro/blog/setting-up-https-for-waha/#lets-encrypt-certbot--waha
|
||||
#
|
||||
#WAHA_HTTPS_ENABLED=true
|
||||
#WAHA_HTTPS_PATH_KEY=/etc/letsencrypt/live/waha.example.pro/privkey.pem
|
||||
#WAHA_HTTPS_PATH_CERT=/etc/letsencrypt/live/waha.example.pro/cert.pem \
|
||||
#WAHA_HTTPS_PATH_CA=/etc/letsencrypt/live/waha.example.pro/chain.pem
|
||||
#WHATSAPP_API_SCHEMA=https
|
||||
#WHATSAPP_API_PORT=3000
|
||||
#WHATSAPP_API_HOSTNAME=waha.example.pro
|
||||
#
|
||||
# Timezone for screenshots and logs
|
||||
# TZ=Europe/Dublin
|
||||
|
||||
# Session management
|
||||
# WHATSAPP_START_SESSION=session1,session2
|
||||
# WHATSAPP_RESTART_ALL_SESSIONS=False
|
||||
|
||||
# Webhooks
|
||||
# WHATSAPP_HOOK_URL=https://webhook.site/11111111-1111-1111-1111-11111111
|
||||
# WHATSAPP_HOOK_CUSTOM_HEADERS=custom_header_key:custom_header_value
|
||||
# WHATSAPP_HOOK_EVENTS=session.status,message,message.reaction
|
||||
|
||||
# Proxy configuration
|
||||
# WHATSAPP_PROXY_SERVER=proxy.example.com:3128
|
||||
# WHATSAPP_PROXY_SERVER_USERNAME=user
|
||||
# WHATSAPP_PROXY_SERVER_PASSWORD=pass
|
||||
|
||||
# HTTPS configuration
|
||||
# !DEPRECATED!
|
||||
# Setup nginx reverse proxy to handle TLS connection
|
||||
# using Let's encrypt or self-issued certificate
|
||||
# WAHA_HTTPS_ENABLED=true
|
||||
# WAHA_HTTPS_PATH_KEY=/etc/letsencrypt/live/waha.example.pro/privkey.pem
|
||||
# WAHA_HTTPS_PATH_CERT=/etc/letsencrypt/live/waha.example.pro/cert.pem
|
||||
# WAHA_HTTPS_PATH_CA=/etc/letsencrypt/live/waha.example.pro/chain.pem
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -126,7 +126,11 @@ jobs:
|
||||
working-directory: tests/smoke
|
||||
timeout-minutes: 2
|
||||
run: |
|
||||
docker run -d --name smoke --rm -p3000:3000 ${{ vars.DOCKER_IMAGE }}:${{ matrix.tag }}
|
||||
docker run -d \
|
||||
--name smoke \
|
||||
--rm -p3000:3000 \
|
||||
${{ vars.DOCKER_IMAGE }}:${{ matrix.tag }}
|
||||
|
||||
sleep 3
|
||||
docker logs smoke
|
||||
goss validate --retry-timeout 30s --sleep 1s
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+6
-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'
|
||||
@@ -39,6 +33,7 @@ repos:
|
||||
(?x)^(
|
||||
docs/.*|
|
||||
README.md|
|
||||
tests/smoke/goss.yaml|
|
||||
)$
|
||||
- repo: https://github.com/Lucas-C/pre-commit-hooks-nodejs
|
||||
rev: v1.1.2
|
||||
|
||||
+49
-8
@@ -1,8 +1,10 @@
|
||||
ARG NODE_IMAGE_TAG=22.16-bookworm-slim
|
||||
ARG GOLANG_IMAGE_TAG=1.23-bookworm
|
||||
|
||||
#
|
||||
# Build
|
||||
#
|
||||
ARG NODE_VERSION=22.8-bullseye
|
||||
FROM node:${NODE_VERSION} AS build
|
||||
FROM node:${NODE_IMAGE_TAG} AS build
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=True
|
||||
|
||||
# npm packages
|
||||
@@ -10,6 +12,10 @@ WORKDIR /git
|
||||
COPY package.json .
|
||||
COPY yarn.lock .
|
||||
ENV YARN_CHECKSUM_BEHAVIOR=update
|
||||
|
||||
# git
|
||||
RUN apt-get update && apt-get install -y git
|
||||
|
||||
RUN npm install -g corepack && corepack enable
|
||||
RUN yarn set version 3.6.3
|
||||
RUN yarn install
|
||||
@@ -23,11 +29,14 @@ RUN yarn build && find ./dist -name "*.d.ts" -delete
|
||||
#
|
||||
# Dashboard
|
||||
#
|
||||
FROM node:${NODE_VERSION} AS dashboard
|
||||
FROM node:${NODE_IMAGE_TAG} AS dashboard
|
||||
|
||||
# jq to parse json
|
||||
RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# wget, unzip
|
||||
RUN apt-get update && apt-get install -y wget unzip && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY waha.config.json /tmp/waha.config.json
|
||||
RUN \
|
||||
WAHA_DASHBOARD_GITHUB_REPO=$(jq -r '.waha.dashboard.repo' /tmp/waha.config.json) && \
|
||||
@@ -42,7 +51,7 @@ RUN \
|
||||
#
|
||||
# GOWS
|
||||
#
|
||||
FROM golang:1.23-bullseye AS gows
|
||||
FROM golang:${GOLANG_IMAGE_TAG} AS gows
|
||||
|
||||
# jq to parse json
|
||||
RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*
|
||||
@@ -73,7 +82,7 @@ RUN \
|
||||
#
|
||||
# Final
|
||||
#
|
||||
FROM node:${NODE_VERSION} AS release
|
||||
FROM node:${NODE_IMAGE_TAG} AS release
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=True
|
||||
# Quick fix for memory potential memory leaks
|
||||
# https://github.com/devlikeapro/waha/issues/347
|
||||
@@ -98,6 +107,13 @@ RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# Install wget - either for chromium or chrome
|
||||
RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# Install fonts if using either chromium or chrome
|
||||
RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
apt-get update \
|
||||
@@ -119,6 +135,21 @@ RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# Install xvfb, xauth
|
||||
RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
xvfb \
|
||||
xauth \
|
||||
libnss3 \
|
||||
libxss1 \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libdrm2 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# Install Chromium
|
||||
RUN if [ "$USE_BROWSER" = "chromium" ]; then \
|
||||
apt-get update \
|
||||
@@ -131,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="130.0.6723.69-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 \
|
||||
@@ -140,12 +171,20 @@ RUN if [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# curl
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# GOWS requirements
|
||||
# libc6
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libc6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install tini for proper init process
|
||||
RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the ENV for docker image
|
||||
ENV WHATSAPP_DEFAULT_ENGINE=$WHATSAPP_DEFAULT_ENGINE
|
||||
|
||||
@@ -156,8 +195,8 @@ 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
|
||||
ENV WAHA_GOWS_PATH /app/gows
|
||||
ENV WAHA_GOWS_SOCKET /tmp/gows.sock
|
||||
ENV WAHA_GOWS_PATH=/app/gows
|
||||
ENV WAHA_GOWS_SOCKET=/tmp/gows.sock
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
@@ -170,4 +209,6 @@ ENV WAHA_ZIPPER=ZIPUNZIP
|
||||
|
||||
# Run command, etc
|
||||
EXPOSE 3000
|
||||
# Use tini as init system to handle zombie processes properly
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
@@ -1,20 +1,20 @@
|
||||
build-all: build-plus build-chrome build-gows build-noweb
|
||||
|
||||
build:
|
||||
docker build . -t devlikeapro/waha
|
||||
|
||||
build-plus:
|
||||
docker build . -t devlikeapro/waha-plus
|
||||
|
||||
build-chrome:
|
||||
docker build . -t devlikeapro/waha-plus:chrome --build-arg USE_BROWSER=chrome
|
||||
|
||||
build-noweb:
|
||||
docker build . -t devlikeapro/waha:noweb --build-arg USE_BROWSER=none --build-arg WHATSAPP_DEFAULT_ENGINE=NOWEB
|
||||
docker build . -t devlikeapro/waha-plus:noweb --build-arg USE_BROWSER=none --build-arg WHATSAPP_DEFAULT_ENGINE=NOWEB
|
||||
|
||||
build-plus-gows:
|
||||
build-gows:
|
||||
docker build . -t devlikeapro/waha-plus:gows --build-arg USE_BROWSER=none --build-arg WHATSAPP_DEFAULT_ENGINE=GOWS
|
||||
|
||||
build-all: build build-chrome build-noweb
|
||||
|
||||
build-plus:
|
||||
docker build . -t devlikeapro/waha-plus
|
||||
|
||||
build-ssh:
|
||||
# check IMAGE provided
|
||||
@[ "${IMAGE}" ] || ( echo "Add APP: make build-ssh image=devlikeapro/waha"; exit 1 );
|
||||
@@ -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-channels-6712
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-07-04
|
||||
|
||||
up-noweb-libsignal:
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec 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-channels
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-06-09
|
||||
|
||||
start-proxy:
|
||||
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
|
||||
|
||||
@@ -146,7 +146,7 @@ curl -d "{\"chatId\": \"${PHONE}@c.us\", \"text\": \"Hello from WhatsApp HTTP AP
|
||||
|
||||
## Start the project
|
||||
1. Clone the repository
|
||||
2. Make sure you're using node>=22
|
||||
2. Make sure you're using node>=22 (check [.nvmrc](/.nvmrc) to get the version)
|
||||
3. Run the following commands:
|
||||
```bash
|
||||
# Install dependencies
|
||||
|
||||
+28
-2
@@ -8,6 +8,17 @@ services:
|
||||
# WAHA Core
|
||||
# image: devlikeapro/waha:latest
|
||||
|
||||
# Add "dns" if you have a problem with resolving "web.whatsapp.com"
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
|
||||
logging:
|
||||
driver: 'json-file'
|
||||
options:
|
||||
max-size: '100m'
|
||||
max-file: '10'
|
||||
|
||||
ports:
|
||||
- '127.0.0.1:3000:3000/tcp'
|
||||
|
||||
@@ -17,7 +28,7 @@ services:
|
||||
|
||||
# Save media files
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#save-media-files-between-the-container-restarts
|
||||
- './.media:/app/.media'
|
||||
- './media:/app/.media'
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
@@ -32,13 +43,18 @@ services:
|
||||
# POSTGRES_PASSWORD: postgres
|
||||
# POSTGRES_DB: postgres
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
# - "127.0.0.1:5432:5432"
|
||||
# volumes:
|
||||
# - pg_data:/var/lib/postgresql/data
|
||||
# command:
|
||||
# - postgres
|
||||
# - "-c"
|
||||
# - "max_connections=3000"
|
||||
# logging:
|
||||
# driver: "json-file"
|
||||
# options:
|
||||
# max-size: "100m"
|
||||
# max-file: "10"
|
||||
|
||||
# NOTE: Only if you're using MongoDB
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#sessions---mongodb
|
||||
@@ -53,6 +69,11 @@ services:
|
||||
# environment:
|
||||
# - MONGO_INITDB_ROOT_USERNAME=mongouser
|
||||
# - MONGO_INITDB_ROOT_PASSWORD=mongopassword
|
||||
# logging:
|
||||
# driver: "json-file"
|
||||
# options:
|
||||
# max-size: "100m"
|
||||
# max-file: "10"
|
||||
|
||||
# NOTE: Only if you're using S3 to save media files
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#media---s3
|
||||
@@ -71,6 +92,11 @@ services:
|
||||
# volumes:
|
||||
# - minio_data:/data
|
||||
# command: server /data --console-address ":9001"
|
||||
# logging:
|
||||
# driver: "json-file"
|
||||
# options:
|
||||
# max-size: "100m"
|
||||
# max-file: "10"
|
||||
|
||||
volumes:
|
||||
mongodb_data: {}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# Learn about the various environment variables at
|
||||
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
|
||||
|
||||
# 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
|
||||
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
|
||||
|
||||
# Replace with the URL you are planning to use for your app
|
||||
FRONTEND_URL=http://chatwoot:3009
|
||||
# To use a dedicated URL for help center pages
|
||||
# HELPCENTER_URL=http://0.0.0.0:3000
|
||||
|
||||
# If the variable is set, all non-authenticated pages would fallback to the default locale.
|
||||
# Whenever a new account is created, the default language will be DEFAULT_LOCALE instead of en
|
||||
# DEFAULT_LOCALE=en
|
||||
|
||||
# If you plan to use CDN for your assets, set Asset CDN Host
|
||||
ASSET_CDN_HOST=
|
||||
|
||||
# Force all access to the app over SSL, default is set to false
|
||||
FORCE_SSL=false
|
||||
|
||||
# This lets you control new sign ups on your chatwoot installation
|
||||
# true : default option, allows sign ups
|
||||
# false : disables all the end points related to sign ups
|
||||
# api_only: disables the UI for signup, but you can create sign ups via the account apis
|
||||
ENABLE_ACCOUNT_SIGNUP=false
|
||||
|
||||
# Redis config
|
||||
# specify the configs via single URL or individual variables
|
||||
# ref: https://www.iana.org/assignments/uri-schemes/prov/redis
|
||||
# You can also use the following format for the URL: redis://:password@host:port/db_number
|
||||
REDIS_URL=redis://redis:6379
|
||||
# If you are using docker-compose, set this variable's value to be any string,
|
||||
# which will be the password for the redis service running inside the docker-compose
|
||||
# to make it secure
|
||||
REDIS_PASSWORD=redis
|
||||
# Redis Sentinel can be used by passing list of sentinel host and ports e,g. sentinel_host1:port1,sentinel_host2:port2
|
||||
REDIS_SENTINELS=
|
||||
# Redis sentinel master name is required when using sentinel, default value is "mymaster".
|
||||
# You can find list of master using "SENTINEL masters" command
|
||||
REDIS_SENTINEL_MASTER_NAME=
|
||||
|
||||
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
|
||||
# Use the following environment variable to customize passwords for sentinels.
|
||||
# Use empty string if sentinels are configured with out passwords
|
||||
# REDIS_SENTINEL_PASSWORD=
|
||||
|
||||
# Redis premium breakage in heroku fix
|
||||
# enable the following configuration
|
||||
# ref: https://github.com/chatwoot/chatwoot/issues/2420
|
||||
# REDIS_OPENSSL_VERIFY_MODE=none
|
||||
|
||||
# Postgres Database config variables
|
||||
# You can leave POSTGRES_DATABASE blank. The default name of
|
||||
# the database in the production environment is chatwoot_production
|
||||
POSTGRES_DATABASE=chatwoot
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_USERNAME=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
RAILS_ENV=development
|
||||
# Changes the Postgres query timeout limit. The default is 14 seconds. Modify only when required.
|
||||
# POSTGRES_STATEMENT_TIMEOUT=14s
|
||||
RAILS_MAX_THREADS=5
|
||||
|
||||
# The email from which all outgoing emails are sent
|
||||
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
|
||||
MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
|
||||
|
||||
#SMTP domain key is set up for HELO checking
|
||||
SMTP_DOMAIN=chatwoot.com
|
||||
# Set the value to "mailhog" if using docker-compose for development environments,
|
||||
# Set the value as "localhost" or your SMTP address in other environments
|
||||
# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix)
|
||||
SMTP_ADDRESS=
|
||||
SMTP_PORT=1025
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
# plain,login,cram_md5
|
||||
SMTP_AUTHENTICATION=
|
||||
SMTP_ENABLE_STARTTLS_AUTO=true
|
||||
# Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html
|
||||
SMTP_OPENSSL_VERIFY_MODE=peer
|
||||
# Comment out the following environment variables if required by your SMTP server
|
||||
# SMTP_TLS=
|
||||
# SMTP_SSL=
|
||||
# SMTP_OPEN_TIMEOUT
|
||||
# SMTP_READ_TIMEOUT
|
||||
|
||||
# Mail Incoming
|
||||
# This is the domain set for the reply emails when conversation continuity is enabled
|
||||
MAILER_INBOUND_EMAIL_DOMAIN=
|
||||
# Set this to the appropriate ingress channel with regards to incoming emails
|
||||
# Possible values are :
|
||||
# relay for Exim, Postfix, Qmail
|
||||
# mailgun for Mailgun
|
||||
# mandrill for Mandrill
|
||||
# postmark for Postmark
|
||||
# sendgrid for Sendgrid
|
||||
RAILS_INBOUND_EMAIL_SERVICE=
|
||||
# Use one of the following based on the email ingress service
|
||||
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
||||
# Set this to a password of your choice and use it in the Inbound webhook
|
||||
RAILS_INBOUND_EMAIL_PASSWORD=
|
||||
|
||||
MAILGUN_INGRESS_SIGNING_KEY=
|
||||
MANDRILL_INGRESS_API_KEY=
|
||||
|
||||
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
||||
# Inbound webhook URL format:
|
||||
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
||||
# Note: Replace the values inside the brackets; do not include the brackets themselves.
|
||||
# Example: https://actionmailbox:mYRandomPassword3@chatwoot.example.com/rails/action_mailbox/postmark/inbound_emails
|
||||
# For Postmark
|
||||
# Ensure the 'Include raw email content in JSON payload' checkbox is selected in the inbound webhook section.
|
||||
|
||||
# Storage
|
||||
ACTIVE_STORAGE_SERVICE=local
|
||||
|
||||
# Amazon S3
|
||||
# documentation: https://www.chatwoot.com/docs/configuring-s3-bucket-as-cloud-storage
|
||||
S3_BUCKET_NAME=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_REGION=
|
||||
|
||||
# Log settings
|
||||
# Disable if you want to write logs to a file
|
||||
RAILS_LOG_TO_STDOUT=true
|
||||
LOG_LEVEL=info
|
||||
LOG_SIZE=500
|
||||
# Configure this environment variable if you want to use lograge instead of rails logger
|
||||
#LOGRAGE_ENABLED=true
|
||||
|
||||
### This environment variables are only required if you are setting up social media channels
|
||||
|
||||
# Facebook
|
||||
# documentation: https://www.chatwoot.com/docs/facebook-setup
|
||||
FB_VERIFY_TOKEN=
|
||||
FB_APP_SECRET=
|
||||
FB_APP_ID=
|
||||
|
||||
# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard
|
||||
IG_VERIFY_TOKEN=
|
||||
|
||||
# Twitter
|
||||
# documentation: https://www.chatwoot.com/docs/twitter-app-setup
|
||||
TWITTER_APP_ID=
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
TWITTER_ENVIRONMENT=
|
||||
|
||||
#slack integration
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
GOOGLE_OAUTH_CALLBACK_URL=
|
||||
|
||||
### Change this env variable only if you are using a custom build mobile app
|
||||
## Mobile app env variables
|
||||
IOS_APP_ID=L7YLMN4634.com.chatwoot.app
|
||||
ANDROID_BUNDLE_ID=com.chatwoot.app
|
||||
|
||||
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
|
||||
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
|
||||
|
||||
### Smart App Banner
|
||||
# https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
|
||||
# You can find your app-id in https://itunesconnect.apple.com
|
||||
#IOS_APP_IDENTIFIER=1495796682
|
||||
|
||||
## Push Notification
|
||||
## generate a new key value here : https://d3v.one/vapid-key-generator/
|
||||
# VAPID_PUBLIC_KEY=
|
||||
# VAPID_PRIVATE_KEY=
|
||||
#
|
||||
# for mobile apps
|
||||
# FCM_SERVER_KEY=
|
||||
|
||||
### APM and Error Monitoring configurations
|
||||
## Elastic APM
|
||||
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
|
||||
# ELASTIC_APM_SERVER_URL=
|
||||
# ELASTIC_APM_SECRET_TOKEN=
|
||||
|
||||
## Sentry
|
||||
# SENTRY_DSN=
|
||||
|
||||
|
||||
## Scout
|
||||
## https://scoutapm.com/docs/ruby/configuration
|
||||
# SCOUT_KEY=YOURKEY
|
||||
# SCOUT_NAME=YOURAPPNAME (Production)
|
||||
# SCOUT_MONITOR=true
|
||||
|
||||
## NewRelic
|
||||
# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/
|
||||
# NEW_RELIC_LICENSE_KEY=
|
||||
# Set this to true to allow newrelic apm to send logs.
|
||||
# This is turned off by default.
|
||||
# NEW_RELIC_APPLICATION_LOGGING_ENABLED=
|
||||
|
||||
## Datadog
|
||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||
# DD_TRACE_AGENT_URL=
|
||||
|
||||
# MaxMindDB API key to download GeoLite2 City database
|
||||
# IP_LOOKUP_API_KEY=
|
||||
|
||||
## Rack Attack configuration
|
||||
## To prevent and throttle abusive requests
|
||||
# ENABLE_RACK_ATTACK=true
|
||||
# RACK_ATTACK_LIMIT=300
|
||||
# ENABLE_RACK_ATTACK_WIDGET_API=true
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
# CW_API_ONLY_SERVER=false
|
||||
|
||||
## Development Only Config
|
||||
# if you want to use letter_opener for local emails
|
||||
# LETTER_OPENER=true
|
||||
# meant to be used in github codespaces
|
||||
# WEBPACKER_DEV_SERVER_PUBLIC=
|
||||
|
||||
# If you want to use official mobile app,
|
||||
# the notifications would be relayed via a Chatwoot server
|
||||
ENABLE_PUSH_RELAY_SERVER=true
|
||||
|
||||
# Stripe API key
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# Set to true if you want to upload files to cloud storage using the signed url
|
||||
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
|
||||
DIRECT_UPLOADS_ENABLED=
|
||||
|
||||
#MS OAUTH creds
|
||||
AZURE_APP_ID=
|
||||
AZURE_APP_SECRET=
|
||||
|
||||
## Advanced configurations
|
||||
## Change these values to fine tune performance
|
||||
# control the concurrency setting of sidekiq
|
||||
# SIDEKIQ_CONCURRENCY=10
|
||||
|
||||
|
||||
# AI powered features
|
||||
## OpenAI key
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Housekeeping/Performance related configurations
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
# contact_inboxes with no conversation older than 90 days will be removed
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# WAHA - WhatsApp HTTP API
|
||||
#
|
||||
# https://waha.devlike.pro/docs/how-to/config/
|
||||
#
|
||||
|
||||
# ==================
|
||||
# ===== COMMON =====
|
||||
# ==================
|
||||
# Base URL for the API (used for webhooks, file URLs, etc.)
|
||||
WAHA_BASE_URL=http://waha: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
|
||||
|
||||
# =================
|
||||
# ===== APPS ======
|
||||
# =================
|
||||
WAHA_APPS_ENABLED=True
|
||||
REDIS_URL=redis://:redis@redis:6379
|
||||
|
||||
# ===================
|
||||
# ===== LOGGING =====
|
||||
# ===================
|
||||
# Log format: JSON (for log management systems) or PRETTY (for development)
|
||||
WAHA_LOG_FORMAT=JSON
|
||||
|
||||
# Log level: info, debug, error, warn
|
||||
WAHA_LOG_LEVEL=info
|
||||
|
||||
# Don't print QR codes in logs
|
||||
WAHA_PRINT_QR=False
|
||||
|
||||
# =========================
|
||||
# ===== MEDIA STORAGE =====
|
||||
# =========================
|
||||
# Local storage (default)
|
||||
WAHA_MEDIA_STORAGE=LOCAL
|
||||
WHATSAPP_FILES_LIFETIME=1800
|
||||
WHATSAPP_FILES_FOLDER=/app/.media
|
||||
|
||||
# Media download settings
|
||||
# WHATSAPP_DOWNLOAD_MEDIA=true
|
||||
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
||||
|
||||
# S3 storage (uncomment to use)
|
||||
# WAHA_MEDIA_STORAGE=S3
|
||||
# WAHA_S3_REGION=eu-west-2
|
||||
# WAHA_S3_BUCKET=waha
|
||||
# WAHA_S3_ACCESS_KEY_ID=minioadmin
|
||||
# WAHA_S3_SECRET_ACCESS_KEY=minioadmin
|
||||
# WAHA_S3_ENDPOINT=http://minio:9000
|
||||
# WAHA_S3_FORCE_PATH_STYLE=True
|
||||
# WAHA_S3_PROXY_FILES=True
|
||||
|
||||
# PostgreSQL storage (uncomment to use)
|
||||
# WAHA_MEDIA_STORAGE=POSTGRESQL
|
||||
# WAHA_MEDIA_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
|
||||
# ===========================
|
||||
# ===== SESSION STORAGE =====
|
||||
# ===========================
|
||||
# PostgreSQL for sessions (uncomment to use)
|
||||
WHATSAPP_SESSIONS_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
|
||||
# MongoDB for sessions (uncomment to use)
|
||||
# WHATSAPP_SESSIONS_MONGO_URL=mongodb://mongouser:mongopassword@mongodb:27017
|
||||
|
||||
|
||||
# ==================================
|
||||
# ===== ADVANCED CONFIGURATION =====
|
||||
# ==================================
|
||||
|
||||
# Timezone for screenshots and logs
|
||||
# TZ=Europe/Dublin
|
||||
|
||||
# Session management
|
||||
# WHATSAPP_START_SESSION=session1,session2
|
||||
# WHATSAPP_RESTART_ALL_SESSIONS=False
|
||||
|
||||
# Webhooks
|
||||
# WHATSAPP_HOOK_URL=https://webhook.site/11111111-1111-1111-1111-11111111
|
||||
# WHATSAPP_HOOK_EVENTS=session.status,message,message.reaction
|
||||
|
||||
# Proxy configuration
|
||||
# WHATSAPP_PROXY_SERVER=proxy.example.com:3128
|
||||
# WHATSAPP_PROXY_SERVER_USERNAME=user
|
||||
# WHATSAPP_PROXY_SERVER_PASSWORD=pass
|
||||
|
||||
# HTTPS configuration
|
||||
# !DEPRECATED!
|
||||
# Setup nginx reverse proxy to handle TLS connection
|
||||
# using Let's encrypt or self-issued certificate
|
||||
# WAHA_HTTPS_ENABLED=true
|
||||
# WAHA_HTTPS_PATH_KEY=/etc/letsencrypt/live/waha.example.pro/privkey.pem
|
||||
# WAHA_HTTPS_PATH_CERT=/etc/letsencrypt/live/waha.example.pro/cert.pem
|
||||
# WAHA_HTTPS_PATH_CA=/etc/letsencrypt/live/waha.example.pro/chain.pem
|
||||
@@ -0,0 +1,99 @@
|
||||
services:
|
||||
waha:
|
||||
restart: always
|
||||
image: devlikeapro/waha-plus
|
||||
ports:
|
||||
- '127.0.0.1:3000:3000/tcp'
|
||||
volumes:
|
||||
- './sessions:/app/.sessions' # required for WAHA Core only
|
||||
- './media:/app/.media'
|
||||
env_file:
|
||||
- .waha.env
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- chatwoot
|
||||
- chatwoot-sidekiq
|
||||
logging:
|
||||
driver: 'json-file'
|
||||
options:
|
||||
max-size: '100m'
|
||||
max-file: '10'
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
|
||||
base: &base
|
||||
image: chatwoot/chatwoot:v4.5.2
|
||||
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:
|
||||
max-size: '100m'
|
||||
max-file: '10'
|
||||
|
||||
chatwoot:
|
||||
<<: *base
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
ports:
|
||||
- '127.0.0.1:3009:3009'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- RAILS_ENV=production
|
||||
- INSTALLATION_ENV=docker
|
||||
entrypoint: docker/entrypoints/rails.sh
|
||||
command: ['bundle', 'exec', 'rails', 's', '-p', '3009', '-b', '0.0.0.0']
|
||||
restart: always
|
||||
|
||||
chatwoot-sidekiq:
|
||||
<<: *base
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- RAILS_ENV=production
|
||||
- INSTALLATION_ENV=docker
|
||||
command: ['bundle', 'exec', 'sidekiq', '-C', 'config/sidekiq.yml']
|
||||
restart: always
|
||||
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
restart: always
|
||||
ports:
|
||||
- '127.0.0.1:5432:5432'
|
||||
volumes:
|
||||
- chatwoot_pg_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=chatwoot
|
||||
- POSTGRES_USER=postgres
|
||||
# Please provide your own password.
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: always
|
||||
command:
|
||||
- 'sh'
|
||||
- '-c'
|
||||
- >
|
||||
redis-server --bind 0.0.0.0 --port 6379 --requirepass
|
||||
${REDIS_PASSWORD:-redis} --timeout 300 --tcp-keepalive 60 --save 900 1
|
||||
--save 300 10 --save 60 10000 --appendonly yes --appendfsync everysec
|
||||
env_file: .chatwoot.env
|
||||
volumes:
|
||||
- chatwoot_redis:/data
|
||||
ports:
|
||||
- '127.0.0.1:6379:6379'
|
||||
|
||||
volumes:
|
||||
chatwoot_pg_data: {}
|
||||
chatwoot_redis: {}
|
||||
chatwoot_storage: {}
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
ports:
|
||||
- '127.0.0.1:3001:3000/tcp'
|
||||
volumes:
|
||||
- './.media:/app/.media'
|
||||
- './media:/app/.media'
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
ports:
|
||||
- '127.0.0.1:3002:3000/tcp'
|
||||
volumes:
|
||||
- './.media:/app/.media'
|
||||
- './media:/app/.media'
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -7,12 +7,12 @@ services:
|
||||
ports:
|
||||
- '3000:3000/tcp'
|
||||
volumes:
|
||||
- './.sessions:/app/.sessions'
|
||||
- './.media:/app/.media'
|
||||
- './sessions:/app/.sessions'
|
||||
- './media:/app/.media'
|
||||
environment:
|
||||
- WHATSAPP_API_KEY=321
|
||||
- WAHA_DASHBOARD_USERNAME=waha
|
||||
- WAHA_DASHBOARD_PASSWORD=waha
|
||||
- WAHA_API_KEY=321
|
||||
- WAHA_DASHBOARD_USERNAME=admin
|
||||
- WAHA_DASHBOARD_PASSWORD=admin
|
||||
- WHATSAPP_DEFAULT_ENGINE=WEBJS
|
||||
- WAHA_PRINT_QR=False
|
||||
- WAHA_MEDIA_STORAGE=LOCAL
|
||||
|
||||
@@ -29,15 +29,15 @@ services:
|
||||
# - WHATSAPP_HOOK_EVENTS=message
|
||||
# - WHATSAPP_API_HOSTNAME=localhost
|
||||
- WHATSAPP_DEFAULT_ENGINE=WEBJS
|
||||
- WHATSAPP_API_KEY=321
|
||||
- WAHA_API_KEY=321
|
||||
# Username and password for Swagger
|
||||
- WHATSAPP_SWAGGER_USERNAME=swagger
|
||||
- WHATSAPP_SWAGGER_PASSWORD=admin
|
||||
# Username and password for WAHA Dashboard
|
||||
# you can use the same username and password for both Swagger and Dashboard
|
||||
# Default - waha / waha
|
||||
- WAHA_DASHBOARD_USERNAME=waha
|
||||
- WAHA_DASHBOARD_PASSWORD=waha
|
||||
- WAHA_DASHBOARD_USERNAME=admin
|
||||
- WAHA_DASHBOARD_PASSWORD=admin
|
||||
# Engine specific environment variables
|
||||
# - WAHA_WEBJS_WEB_VERSION=2.2412.54-videofix
|
||||
restart: always
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
# Environment variables from https://waha.devlike.pro/docs/how-to/config/
|
||||
# - WHATSAPP_HOOK_URL=https://webhook.site/11111111-1111-1111-1111-11111111
|
||||
# - WHATSAPP_HOOK_EVENTS=message
|
||||
# - WHATSAPP_API_KEY=321
|
||||
# - WAHA_API_KEY=321
|
||||
# - WHATSAPP_SWAGGER_USERNAME=admin
|
||||
# - WHATSAPP_SWAGGER_PASSWORD=123
|
||||
# - WAHA_DASHBOARD_USERNAME=admin
|
||||
|
||||
+80
-4
@@ -1,11 +1,87 @@
|
||||
#!/bin/sh
|
||||
|
||||
|
||||
#
|
||||
# Calculate UV_THREADPOOL_SIZE based on number of CPUs
|
||||
cpus=$(node -e "const os = require('os'); console.log(os.cpus().length);")
|
||||
uv_threadpool_size=$(($cpus * 1))
|
||||
#
|
||||
# Try to get CPU count using Node.js, fallback to 1 if it fails
|
||||
cpus=$(node -e "const os = require('os'); console.log(os.cpus().length);" 2>/dev/null) || cpus=1
|
||||
# Make sure cpus is a number, default to 1 if not
|
||||
case $cpus in
|
||||
''|*[!0-9]*) cpus=1 ;;
|
||||
esac
|
||||
uv_threadpool_size=$(($cpus * 2))
|
||||
|
||||
# Check if uv_threadpool_size is less than 4 (default), set it to 4 if it is
|
||||
if [ "$uv_threadpool_size" -lt 4 ]; then
|
||||
uv_threadpool_size=4
|
||||
fi
|
||||
|
||||
# Set UV_THREADPOOL_SIZE as an environment variable
|
||||
export UV_THREADPOOL_SIZE="${UV_THREADPOOL_SIZE:-$uv_threadpool_size}"
|
||||
|
||||
# Start your application using yarn
|
||||
exec node dist/main
|
||||
#
|
||||
# Handle API key hashing
|
||||
#
|
||||
# Save WHATSAPP_API_KEY or WAHA_API_KEY in a variable (WHATSAPP_API_KEY has priority)
|
||||
if [ -n "$WHATSAPP_API_KEY" ]; then
|
||||
key="$WHATSAPP_API_KEY"
|
||||
elif [ -n "$WAHA_API_KEY" ]; then
|
||||
key="$WAHA_API_KEY"
|
||||
fi
|
||||
|
||||
# Unset both environment variables
|
||||
unset WHATSAPP_API_KEY
|
||||
unset WAHA_API_KEY
|
||||
|
||||
# Process the key if it exists
|
||||
if [ -n "$key" ]; then
|
||||
# Check if key is already hashed
|
||||
if echo "$key" | grep -q "^sha512:"; 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"
|
||||
fi
|
||||
fi
|
||||
|
||||
#
|
||||
# xvfb-run
|
||||
#
|
||||
USE_XVFB=false
|
||||
|
||||
# Check WAHA_RUN_XVFB parameter - only test for "false" case, treat all others as True
|
||||
if [ "$WAHA_RUN_XVFB" = "false" ] || [ "$WAHA_RUN_XVFB" = "False" ] || [ "$WAHA_RUN_XVFB" = "0" ]; then
|
||||
# Explicitly disabled by user
|
||||
echo "WAHA_RUN_XVFB value: $WAHA_RUN_XVFB - xvfb is disabled"
|
||||
USE_XVFB=false
|
||||
else
|
||||
# Check engine and run test if it's WEBJS or not specified
|
||||
if [ -z "$WHATSAPP_DEFAULT_ENGINE" ] || [ "$WHATSAPP_DEFAULT_ENGINE" = "WEBJS" ]; then
|
||||
# Try to run xvfb-run with a test command
|
||||
if xvfb-run --auto-servernum echo "xvfb-run is working!"; then
|
||||
USE_XVFB=true
|
||||
else
|
||||
echo "xvfb-run test failed, do not run it"
|
||||
USE_XVFB=false
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
#
|
||||
# Start your application using node with exec to ensure proper signal handling
|
||||
#
|
||||
if [ "$USE_XVFB" = "true" ]; then
|
||||
echo "Executing node with xvfb-run..."
|
||||
exec xvfb-run --auto-servernum node dist/main
|
||||
else
|
||||
echo "Executing node without xvfb-run..."
|
||||
exec node dist/main
|
||||
fi
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
-1
@@ -3,7 +3,13 @@
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/swagger"],
|
||||
"assets": ["dashboard/**", "core/engines/webjs/*", "plus/engines/webjs/*"],
|
||||
"assets": [
|
||||
"dashboard/**",
|
||||
"core/engines/webjs/*",
|
||||
"plus/engines/webjs/*",
|
||||
"apps/chatwoot/i18n/locales/*.yaml",
|
||||
"apps/chatwoot/i18n/locales/*.yml"
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
+47
-32
@@ -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,40 +26,53 @@
|
||||
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-channels-6712",
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-07-04",
|
||||
"@adiwajshing/keyed-db": "^0.2.4",
|
||||
"@aws-sdk/client-s3": "^3.633.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.633.0",
|
||||
"@bull-board/api": "^6.9.1",
|
||||
"@bull-board/express": "^6.9.1",
|
||||
"@bull-board/nestjs": "^6.9.1",
|
||||
"@figuro/chatwoot-sdk": "^1.1.17",
|
||||
"@liaoliaots/nestjs-redis": "^9",
|
||||
"@nestjs/axios": "^3.0.2",
|
||||
"@nestjs/common": "^9.0.9",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^9.0.9",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.0.9",
|
||||
"@nestjs/platform-ws": "^9.0.9",
|
||||
"@nestjs/serve-static": "^2.1.3",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.1.5",
|
||||
"@nestjs/platform-ws": "^11.0.0",
|
||||
"@nestjs/serve-static": "^5.0.0",
|
||||
"@nestjs/swagger": "^7.1.11",
|
||||
"@nestjs/terminus": "^10.2.3",
|
||||
"@nestjs/websockets": "^9.0.9",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/lodash": "^4.14.194",
|
||||
"@types/mustache": "^4.2.5",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/sqlite3": "^5.1.0",
|
||||
"@types/ws": "^8.5.4",
|
||||
"adm-zip": "0.5.10",
|
||||
"agentkeepalive": "^4.5.0",
|
||||
"async-lock": "^1.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"audio-decode": "^2.2.2",
|
||||
"axios": "^1.9.0",
|
||||
"axios-retry": "^4.5.0",
|
||||
"better-sqlite3": "11.3.0",
|
||||
"bullmq": "^5.48.1",
|
||||
"check-disk-space": "^3.4.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "0.14.0",
|
||||
"del": "^6.0.0",
|
||||
"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",
|
||||
@@ -68,25 +81,29 @@
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.27",
|
||||
"mongodb": "6.9.0",
|
||||
"mustache": "^4.2.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"passport": "^0.6.0",
|
||||
"node-cache": "5.1.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"pg": "^8.13.1",
|
||||
"pino-http": "^10.2.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"pretty-bytes": "5.6.0",
|
||||
"promise-retry": "^2.0.1",
|
||||
"puppeteer": "^23.6.0",
|
||||
"puppeteer": "^24.19.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rimraf": "^4.3.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.4",
|
||||
"sqlite3": "^5.1.7",
|
||||
"swagger-ui-express": "^4.1.4",
|
||||
"ulid": "^2.3.0",
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-channels",
|
||||
"write-file-atomic": "^6.0.0"
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-06-09",
|
||||
"write-file-atomic": "^6.0.0",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
@@ -94,36 +111,34 @@
|
||||
"resolutions": {
|
||||
"bufferutil": "^4.0.8",
|
||||
"ws": "^8.18.0",
|
||||
"puppeteer": "^23.6.0",
|
||||
"puppeteer": "^24.19.0",
|
||||
"whatwg-url": "13.0.0",
|
||||
"libsignal": "github:devlikeapro/libsignal-node#fork-master"
|
||||
"libsignal": "github:devlikeapro/libsignal-node#fork-master",
|
||||
"axios": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@grpc/grpc-js": "^1.12.4",
|
||||
"@grpc/grpc-js": "^1.13.4",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.1",
|
||||
"@nestjs/testing": "^9.0.9",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@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",
|
||||
"grpc-tools": "^1.12.4",
|
||||
"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",
|
||||
"typescript": "5.9.2",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Set source and output file
|
||||
SOURCE_DIR="src"
|
||||
OUTPUT_DIR="$HOME/Downloads/chatgpt"
|
||||
OUTPUT_FILE="$OUTPUT_DIR/waha.ts"
|
||||
|
||||
# Create or clear the output file
|
||||
rm -f "$OUTPUT_FILE"
|
||||
touch "$OUTPUT_FILE"
|
||||
|
||||
# Find all .md files in the source directory and combine them
|
||||
find "$SOURCE_DIR" -type f -name "*.ts" | while read -r file; do
|
||||
# Add file name to OUTPUT_FILE
|
||||
echo "\n-------" >> "$OUTPUT_FILE"
|
||||
echo "File: $file" >> "$OUTPUT_FILE"
|
||||
echo "-------\n" >> "$OUTPUT_FILE"
|
||||
# Append the content of the file to the output file
|
||||
cat "$file" >> "$OUTPUT_FILE"
|
||||
done
|
||||
|
||||
echo "All .ts files combined into $OUTPUT_FILE with separators."
|
||||
@@ -36,8 +36,8 @@ class AuthController {
|
||||
summary: 'Get QR code for pairing WhatsApp API.',
|
||||
})
|
||||
@SessionApiParam
|
||||
@ApiFileAcceptHeader(Base64File, QRCodeValue)
|
||||
@UseInterceptors(new BufferResponseInterceptor())
|
||||
@ApiFileAcceptHeader('image/png', Base64File, QRCodeValue)
|
||||
@UseInterceptors(new BufferResponseInterceptor('image/png'))
|
||||
async getQR(
|
||||
@QRCodeSessionParam session: WhatsappSession,
|
||||
@Query() query: QRCodeQuery,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
|
||||
import { isJidNewsletter } from '@waha/core/utils/jids';
|
||||
import {
|
||||
SessionApiParam,
|
||||
WorkingSessionParam,
|
||||
@@ -35,7 +36,6 @@ import {
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import {
|
||||
isNewsletter,
|
||||
parseChannelInviteLink,
|
||||
WhatsappSession,
|
||||
} from '../core/abc/session.abc';
|
||||
@@ -93,7 +93,7 @@ export class ChannelsController {
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('id') id: string,
|
||||
): Promise<Channel> {
|
||||
if (isNewsletter(id)) {
|
||||
if (isJidNewsletter(id)) {
|
||||
return session.channelsGetChannel(id);
|
||||
} else {
|
||||
const inviteCode = parseChannelInviteLink(id);
|
||||
@@ -126,7 +126,7 @@ export class ChannelsController {
|
||||
@Param('id') code: string,
|
||||
@Query() query: PreviewChannelMessages,
|
||||
): Promise<ChannelMessage[]> {
|
||||
if (isNewsletter(code)) {
|
||||
if (isJidNewsletter(code)) {
|
||||
const channel = await session.channelsGetChannel(code);
|
||||
code = parseChannelInviteLink(channel.invite);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isJidGroup } from '@adiwajshing/baileys';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -29,8 +30,13 @@ import {
|
||||
GetChatMessageQuery,
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
OverviewBodyRequest,
|
||||
OverviewFilter,
|
||||
OverviewPaginationParams,
|
||||
PinMessageRequest,
|
||||
ReadChatMessagesQuery,
|
||||
ReadChatMessagesResponse,
|
||||
transformAck,
|
||||
} from '../structures/chats.dto';
|
||||
import { EditMessageRequest } from '../structures/chatting.dto';
|
||||
|
||||
@@ -61,8 +67,23 @@ class ChatsController {
|
||||
getChatsOverview(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Query() pagination: OverviewPaginationParams,
|
||||
@Query() filter: OverviewFilter,
|
||||
): Promise<ChatSummary[]> {
|
||||
return session.getChatsOverview(pagination);
|
||||
return session.getChatsOverview(pagination, filter);
|
||||
}
|
||||
|
||||
@Post('overview')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Get chats overview. Use POST if you have too many "ids" params - GET can limit it',
|
||||
})
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
postChatsOverview(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Body() body: OverviewBodyRequest,
|
||||
): Promise<ChatSummary[]> {
|
||||
return session.getChatsOverview(body.pagination, body.filter);
|
||||
}
|
||||
|
||||
@Delete(':chatId')
|
||||
@@ -100,9 +121,23 @@ class ChatsController {
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('chatId') chatId: string,
|
||||
) {
|
||||
filter = transformAck(filter);
|
||||
return session.getChatMessages(chatId, query, filter);
|
||||
}
|
||||
|
||||
@Post(':chatId/messages/read')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Read unread messages in the chat' })
|
||||
@ChatIdApiParam
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
readChatMessages(
|
||||
@Query() query: ReadChatMessagesQuery,
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('chatId') chatId: string,
|
||||
): Promise<ReadChatMessagesResponse> {
|
||||
return session.readChatMessages(chatId, query);
|
||||
}
|
||||
|
||||
@Get(':chatId/messages/:messageId')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Gets message by id' })
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import {
|
||||
GetChatMessagesFilter,
|
||||
ReadChatMessagesQuery,
|
||||
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 {
|
||||
@@ -23,9 +28,11 @@ import {
|
||||
MessageFileRequest,
|
||||
MessageForwardRequest,
|
||||
MessageImageRequest,
|
||||
MessageLinkCustomPreviewRequest,
|
||||
MessageLinkPreviewRequest,
|
||||
MessageLocationRequest,
|
||||
MessagePollRequest,
|
||||
MessagePollVoteRequest,
|
||||
MessageReactionRequest,
|
||||
MessageReplyRequest,
|
||||
MessageStarRequest,
|
||||
@@ -95,10 +102,30 @@ export class ChattingController {
|
||||
return whatsapp.sendVideo(request);
|
||||
}
|
||||
|
||||
@Post('/send/link-custom-preview')
|
||||
@ApiOperation({
|
||||
summary: 'Send a text message with a CUSTOM link preview.',
|
||||
description:
|
||||
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
|
||||
})
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendLinkCustomPreview(
|
||||
@Body() request: MessageLinkCustomPreviewRequest,
|
||||
): Promise<any> {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (!request.text.includes(request.preview.url)) {
|
||||
throw new Error(
|
||||
'"text" must include the URL provided in the "preview.url"',
|
||||
);
|
||||
}
|
||||
return whatsapp.sendLinkCustomPreview(request);
|
||||
}
|
||||
|
||||
@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) {
|
||||
@@ -106,6 +133,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,
|
||||
@@ -116,6 +154,15 @@ export class ChattingController {
|
||||
|
||||
@Post('/sendSeen')
|
||||
async sendSeen(@Body() chat: SendSeenRequest) {
|
||||
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
|
||||
if (!hasMessageId) {
|
||||
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
||||
const query: ReadChatMessagesQuery = {
|
||||
messages: null,
|
||||
days: 7,
|
||||
};
|
||||
return whatsapp.readChatMessages(chat.chatId, query);
|
||||
}
|
||||
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
||||
return whatsapp.sendSeen(chat);
|
||||
}
|
||||
@@ -160,18 +207,23 @@ 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);
|
||||
return whatsapp.sendLocation(request);
|
||||
}
|
||||
|
||||
@Post('/sendLinkPreview')
|
||||
async sendLinkPreview(@Body() request: MessageLinkPreviewRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendLinkPreview(request);
|
||||
}
|
||||
|
||||
@Post('/sendContactVcard')
|
||||
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -209,6 +261,7 @@ export class ChattingController {
|
||||
@Query() query: GetMessageQuery,
|
||||
@Query() filter: GetChatMessagesFilter,
|
||||
) {
|
||||
filter = transformAck(filter);
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
return whatsapp.getChatMessages(query.chatId, query, filter);
|
||||
}
|
||||
@@ -236,4 +289,11 @@ export class ChattingController {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.reply(request);
|
||||
}
|
||||
|
||||
@Post('/sendLinkPreview')
|
||||
@ApiOperation({ deprecated: true })
|
||||
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendLinkPreview(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Param,
|
||||
Post,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import {
|
||||
SessionApiParam,
|
||||
WorkingSessionParam,
|
||||
} from '@waha/nestjs/params/SessionApiParam';
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import {
|
||||
EventCancelRequest,
|
||||
EventMessageRequest,
|
||||
} from '../structures/events.dto';
|
||||
import { WAMessage } from '../structures/responses.dto';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/events')
|
||||
@ApiTags('📅 Events')
|
||||
export class EventsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Send an event message' })
|
||||
@UsePipes(new ValidationPipe())
|
||||
@SessionApiParam
|
||||
async sendEvent(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Body() request: EventMessageRequest,
|
||||
): Promise<WAMessage> {
|
||||
return session.sendEvent(request);
|
||||
}
|
||||
|
||||
// @Post(':id/cancel')
|
||||
// @ApiOperation({ summary: 'Cancel an event by ID' })
|
||||
// @UsePipes(new ValidationPipe())
|
||||
// @SessionApiParam
|
||||
// async cancelEvent(
|
||||
// @WorkingSessionParam session: WhatsappSession,
|
||||
// @Param('id') id: string,
|
||||
// ): Promise<WAMessage> {
|
||||
// return session.cancelEvent(id);
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UnprocessableEntityException,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import {
|
||||
SessionApiParam,
|
||||
WorkingSessionParam,
|
||||
} from '@waha/nestjs/params/SessionApiParam';
|
||||
import { CountResponse } from '@waha/structures/base.dto';
|
||||
import {
|
||||
LidsListQueryParams,
|
||||
LidToPhoneNumber,
|
||||
} from '@waha/structures/lids.dto';
|
||||
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/lids')
|
||||
@ApiTags('👤 Contacts')
|
||||
export class LidsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('/')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get all known lids to phone number mapping' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getAll(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Query() params: LidsListQueryParams,
|
||||
): Promise<Array<LidToPhoneNumber>> {
|
||||
// Always lid
|
||||
const pagination: PaginationParams = params;
|
||||
pagination.sortBy = 'lid';
|
||||
pagination.sortOrder = SortOrder.ASC;
|
||||
const lids = await session.getAllLids(pagination);
|
||||
return lids;
|
||||
}
|
||||
|
||||
@Get('/count')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get the number of known lids' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getLidsCount(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
): Promise<CountResponse> {
|
||||
const count = await session.getLidsCount();
|
||||
return {
|
||||
count: count,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/:lid')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get phone number by lid' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async findPNByLid(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('lid') lid: string,
|
||||
): Promise<LidToPhoneNumber> {
|
||||
if (!lid.includes('@')) {
|
||||
lid = lid + '@lid';
|
||||
}
|
||||
|
||||
if (!isLidUser(lid)) {
|
||||
throw new UnprocessableEntityException(
|
||||
'Invalid LID - it must end with @lid',
|
||||
);
|
||||
}
|
||||
const result = await session.findPNByLid(lid);
|
||||
result.pn = result.pn || null;
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('/pn/:phoneNumber')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get lid by phone number (chat id)' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async findLIDByPhoneNumber(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('phoneNumber') phoneNumber: string,
|
||||
): Promise<LidToPhoneNumber> {
|
||||
if (isLidUser(phoneNumber)) {
|
||||
return {
|
||||
lid: phoneNumber,
|
||||
pn: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await session.findLIDByPhoneNumber(phoneNumber);
|
||||
result.lid = result.lid || null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UnprocessableEntityException,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WAMimeType } from '@waha/core/media/WAMimeType';
|
||||
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
||||
import {
|
||||
SessionApiParam,
|
||||
WorkingSessionParam,
|
||||
} from '@waha/nestjs/params/SessionApiParam';
|
||||
import {
|
||||
FileDTO,
|
||||
VideoFileDTO,
|
||||
VoiceFileDTO,
|
||||
} from '@waha/structures/media.dto';
|
||||
|
||||
import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/media')
|
||||
@ApiTags('🖼️ Media')
|
||||
class MediaController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Post('convert/voice')
|
||||
@ApiOperation({
|
||||
summary: 'Convert voice to WhatsApp format (opus)',
|
||||
})
|
||||
@SessionApiParam
|
||||
@UseInterceptors(
|
||||
new BufferResponseInterceptor(WAMimeType.VOICE, 'output.opus'),
|
||||
)
|
||||
@ApiFileAcceptHeader(WAMimeType.VOICE)
|
||||
async convertVoice(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Body() file: VoiceFileDTO,
|
||||
): Promise<Buffer> {
|
||||
const data = await this.buffer(session, file);
|
||||
const content = await session.mediaConverter.voice(data);
|
||||
return content;
|
||||
}
|
||||
|
||||
@Post('convert/video')
|
||||
@ApiOperation({
|
||||
summary: 'Convert video to WhatsApp format (mp4)',
|
||||
})
|
||||
@SessionApiParam
|
||||
@UseInterceptors(
|
||||
new BufferResponseInterceptor(WAMimeType.VIDEO, 'output.mp4'),
|
||||
)
|
||||
@ApiFileAcceptHeader(WAMimeType.VIDEO)
|
||||
async convertVideo(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Body() file: VideoFileDTO,
|
||||
): Promise<Buffer> {
|
||||
const data = await this.buffer(session, file);
|
||||
const content = await session.mediaConverter.video(data);
|
||||
return content;
|
||||
}
|
||||
|
||||
private async buffer(
|
||||
session: WhatsappSession,
|
||||
file: FileDTO,
|
||||
): Promise<Buffer> {
|
||||
if ('url' in file) {
|
||||
return session.fetch(file.url);
|
||||
} else if ('data' in file) {
|
||||
return Buffer.from(file.data, 'base64');
|
||||
} else {
|
||||
throw new UnprocessableEntityException(
|
||||
'Either "url" or "data" must be specified.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { MediaController };
|
||||
@@ -21,8 +21,8 @@ export class ScreenshotController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('/screenshot')
|
||||
@UseInterceptors(new BufferResponseInterceptor())
|
||||
@ApiFileAcceptHeader()
|
||||
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
|
||||
@ApiFileAcceptHeader('image/jpeg')
|
||||
async screenshot(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Query() sessionQuery: SessionQuery,
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import * as process from 'node:process';
|
||||
import * as v8 from 'node:v8';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -112,34 +109,3 @@ export class ServerController {
|
||||
return { stopping: true };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/server/debug')
|
||||
@ApiTags('🔍 Observability')
|
||||
export class ServerDebugController {
|
||||
private logger: Logger;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(protected config: WhatsappConfigService) {
|
||||
this.logger = new Logger('ServerDebugController');
|
||||
this.enabled = this.config.debugModeEnabled;
|
||||
}
|
||||
|
||||
@Get('heapsnapshot')
|
||||
@ApiOperation({
|
||||
summary: 'Return a heapsnapshot',
|
||||
description: "Return a heapsnapshot of the server's memory",
|
||||
})
|
||||
async heapsnapshot() {
|
||||
if (!this.enabled) {
|
||||
throw new NotFoundException('WAHA_DEBUG_MODE is disabled');
|
||||
}
|
||||
this.logger.log('Creating a heap snapshot...');
|
||||
const heap = v8.getHeapSnapshot();
|
||||
const fileName = `${Date.now()}.heapsnapshot`;
|
||||
return new StreamableFile(heap, {
|
||||
type: 'application/octet-stream',
|
||||
disposition: `attachment; filename=${fileName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as v8 from 'node:v8';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { WhatsappConfigService } from '@waha/config.service';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import {
|
||||
SessionApiParam,
|
||||
WorkingSessionParam,
|
||||
} from '@waha/nestjs/params/SessionApiParam';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
import { BrowserTraceQuery } from '@waha/structures/server.debug.dto';
|
||||
import { createReadStream } from 'fs';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/server/debug')
|
||||
@ApiTags('🔍 Observability')
|
||||
export class ServerDebugController {
|
||||
private logger: Logger;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
private config: WhatsappConfigService,
|
||||
private manager: SessionManager,
|
||||
) {
|
||||
this.logger = new Logger('ServerDebugController');
|
||||
this.enabled = this.config.debugModeEnabled;
|
||||
}
|
||||
|
||||
@Get('heapsnapshot')
|
||||
@ApiOperation({
|
||||
summary: 'Return a heapsnapshot for the current nodejs process',
|
||||
description: "Return a heapsnapshot of the server's memory",
|
||||
})
|
||||
async heapsnapshot() {
|
||||
if (!this.enabled) {
|
||||
throw new NotFoundException('WAHA_DEBUG_MODE is disabled');
|
||||
}
|
||||
this.logger.log('Creating a heap snapshot...');
|
||||
const heap = v8.getHeapSnapshot();
|
||||
const fileName = `${Date.now()}.heapsnapshot`;
|
||||
return new StreamableFile(heap, {
|
||||
type: 'application/octet-stream',
|
||||
disposition: `attachment; filename=${fileName}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('browser/trace/:session')
|
||||
@ApiOperation({
|
||||
summary: 'Collect and get a trace.json for Chrome DevTools ',
|
||||
description: 'Uses https://pptr.dev/api/puppeteer.tracing',
|
||||
})
|
||||
@SessionApiParam
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async browserTrace(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Query() query: BrowserTraceQuery,
|
||||
) {
|
||||
if (!this.enabled) {
|
||||
throw new NotFoundException('WAHA_DEBUG_MODE is disabled');
|
||||
}
|
||||
const filepath = await session.browserTrace(query);
|
||||
const stream = createReadStream(filepath);
|
||||
const filename = `trace - ${session.name} - ${new Date()}.json`;
|
||||
return new StreamableFile(stream, {
|
||||
type: 'application/octet-stream',
|
||||
disposition: `attachment; filename=${filename}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -209,16 +209,7 @@ class SessionsController {
|
||||
})
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async restart(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.withLock(name, async () => {
|
||||
const exists = await this.manager.exists(name);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
await this.manager.assign(name);
|
||||
await this.manager.stop(name, true);
|
||||
await this.manager.start(name);
|
||||
});
|
||||
|
||||
await this.manager.restart(name);
|
||||
return await this.manager.getSessionInfo(name);
|
||||
}
|
||||
|
||||
@@ -233,6 +224,9 @@ class SessionsController {
|
||||
@Body() request: SessionStartDeprecatedRequest,
|
||||
): Promise<SessionDTO> {
|
||||
const name = request.name;
|
||||
if (!request.name) {
|
||||
throw new UnprocessableEntityException('Session name is required');
|
||||
}
|
||||
if (this.manager.isRunning(name)) {
|
||||
const msg = `Session '${name}' is already started.`;
|
||||
throw new UnprocessableEntityException(msg);
|
||||
@@ -255,6 +249,9 @@ class SessionsController {
|
||||
async DEPRECATED_stop(
|
||||
@Body() request: SessionStopDeprecatedRequest,
|
||||
): Promise<void> {
|
||||
if (!request.name) {
|
||||
throw new UnprocessableEntityException('Session name is required');
|
||||
}
|
||||
const name = request.name;
|
||||
if (request.logout) {
|
||||
// Old API did remove the session complete
|
||||
@@ -283,6 +280,9 @@ class SessionsController {
|
||||
async DEPRECATED_logout(
|
||||
@Body() request: SessionLogoutDeprecatedRequest,
|
||||
): Promise<void> {
|
||||
if (!request.name) {
|
||||
throw new UnprocessableEntityException('Session name is required');
|
||||
}
|
||||
const name = request.name;
|
||||
await this.withLock(name, async () => {
|
||||
await this.manager.unassign(name);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
|
||||
import { WebsocketHeartbeatJob } from '@waha/nestjs/ws/WebsocketHeartbeatJob';
|
||||
import { WebSocket } from '@waha/nestjs/ws/ws';
|
||||
import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
|
||||
@@ -21,6 +22,15 @@ import { IncomingMessage } from 'http';
|
||||
import * as url from 'url';
|
||||
import { Server } from 'ws';
|
||||
|
||||
export enum WebSocketCloseCode {
|
||||
NORMAL = 1000,
|
||||
GOING_AWAY = 1001,
|
||||
PROTOCOL_ERROR = 1002,
|
||||
UNSUPPORTED_DATA = 1003,
|
||||
POLICY_VIOLATION = 1008,
|
||||
INTERNAL_ERROR = 1011,
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
path: '/ws',
|
||||
cors: true,
|
||||
@@ -41,7 +51,10 @@ export class WebsocketGatewayCore
|
||||
private heartbeat: WebsocketHeartbeatJob;
|
||||
private eventUnmask = new EventWildUnmask(WAHAEvents, WAHAEventsWild);
|
||||
|
||||
constructor(private manager: SessionManager) {
|
||||
constructor(
|
||||
private manager: SessionManager,
|
||||
private auth: WebSocketAuth,
|
||||
) {
|
||||
this.logger = new Logger('WebsocketGateway');
|
||||
this.heartbeat = new WebsocketHeartbeatJob(
|
||||
this.logger,
|
||||
@@ -51,14 +64,23 @@ export class WebsocketGatewayCore
|
||||
|
||||
handleConnection(socket: WebSocket, request: IncomingMessage, ...args): any {
|
||||
// wsc - websocket client
|
||||
const id = generatePrefixedId('wsc');
|
||||
socket.id = id;
|
||||
this.logger.debug(`New client connected: ${request.url}`);
|
||||
socket.id = generatePrefixedId('wsc');
|
||||
|
||||
if (!this.auth.validateRequest(request)) {
|
||||
// Not authorized - close connection
|
||||
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
|
||||
this.logger.debug(
|
||||
`Unauthorized websocket connection attempt: ${request.url} - ${socket.id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
|
||||
const params = this.getParams(request);
|
||||
const session: string = params.session;
|
||||
const events: WAHAEvents[] = params.events;
|
||||
this.logger.debug(
|
||||
`Client connected to session: '${session}', events: ${events}, ${id}`,
|
||||
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
|
||||
);
|
||||
|
||||
const sub = this.manager
|
||||
@@ -97,26 +119,11 @@ export class WebsocketGatewayCore
|
||||
async beforeApplicationShutdown(signal?: string) {
|
||||
this.logger.log('Shutting down websocket server');
|
||||
this.heartbeat?.stop();
|
||||
|
||||
// Allow pending messages to be sent, it can be even 1ms, just to release the event loop
|
||||
await sleep(100);
|
||||
// Close clients and server
|
||||
await this.close(this.server);
|
||||
this.logger.log('Websocket server is down');
|
||||
}
|
||||
|
||||
// Cherry-pick from nestjs new version
|
||||
// https://github.com/nestjs/nest/pull/13531/files
|
||||
private async close(server: any) {
|
||||
const closeEventSignal = new Promise((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve(undefined))),
|
||||
);
|
||||
for (const ws of server.clients) {
|
||||
ws.terminate();
|
||||
}
|
||||
await closeEventSignal;
|
||||
}
|
||||
|
||||
afterInit(server: Server) {
|
||||
this.logger.debug('Websocket server initialized');
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { JOB_DELAY, JOB_LOCK_TTL } from '@waha/apps/app_sdk/constants';
|
||||
import { JobLoggerWrapper } from '@waha/apps/app_sdk/JobLoggerWrapper';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { BaseWorkerHost } from '@waha/utils/bull/BaseWorkerHost';
|
||||
import { Job } from 'bullmq';
|
||||
import { DelayedError } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
/**
|
||||
* Base class for app consumers that provides common functionality
|
||||
* like mutex locking, logging setup, and error handling.
|
||||
*/
|
||||
export abstract class AppConsumer extends BaseWorkerHost {
|
||||
protected readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
appName: string,
|
||||
componentName: string,
|
||||
log: PinoLogger,
|
||||
protected readonly rmutex: RMutexService,
|
||||
) {
|
||||
super();
|
||||
this.logger = log.logger.child({
|
||||
app: appName,
|
||||
component: componentName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a job with mutex locking to ensure only one job processes a specific resource at a time.
|
||||
* @param job The job to process
|
||||
* @param mutexKey The key to use for mutex locking
|
||||
* @param processor The function that processes the job
|
||||
* @returns The result of the processor function
|
||||
*/
|
||||
protected async withMutex<T, R>(
|
||||
job: Job<T, R, any>,
|
||||
mutexKey: string,
|
||||
processor: () => Promise<R>,
|
||||
): Promise<R> {
|
||||
const mutex = this.rmutex.get(mutexKey, JOB_LOCK_TTL);
|
||||
const lock = await mutex.lock();
|
||||
|
||||
if (!lock) {
|
||||
const logger = new JobLoggerWrapper(job, this.logger);
|
||||
logger.debug(
|
||||
`Postponing job '${job.id}' for ${JOB_DELAY}ms, another job is already running the mutex.key='${mutexKey}'`,
|
||||
);
|
||||
await job.moveToDelayed(Date.now() + JOB_DELAY);
|
||||
throw new DelayedError();
|
||||
}
|
||||
|
||||
try {
|
||||
return await processor();
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
import { ILogger } from './ILogger';
|
||||
|
||||
export class AxiosLogging {
|
||||
constructor(private readonly logger: ILogger) {}
|
||||
|
||||
applyTo(instance: AxiosInstance) {
|
||||
instance.interceptors.request.use(this.onRequest.bind(this));
|
||||
instance.interceptors.response.use(
|
||||
this.onResponse.bind(this),
|
||||
this.onError.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
private onRequest(config: AxiosRequestConfig) {
|
||||
const method = config.method?.toUpperCase() ?? 'GET';
|
||||
const url = config.url ?? '';
|
||||
const query = config.params
|
||||
? `?${new URLSearchParams(config.params).toString()}`
|
||||
: '';
|
||||
const body = config.data ? JSON.stringify(config.data) : '';
|
||||
|
||||
this.logger.info(`${method} ${url}${query} ${body}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
private onResponse(response: AxiosResponse) {
|
||||
const { method, url } = response.config;
|
||||
const status = response.status;
|
||||
const methodStr = method?.toUpperCase() ?? 'GET';
|
||||
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}`);
|
||||
} else {
|
||||
this.logger.warn(`${methodStr} ${status} ${url} ${responseData}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private onError(error: any) {
|
||||
if (error.response) {
|
||||
const { method, url } = error.config;
|
||||
const status = error.response.status;
|
||||
const message = `${
|
||||
method?.toUpperCase() ?? 'GET'
|
||||
} ${status} ${url} ${JSON.stringify(error.response.data)}`;
|
||||
this.logger.error(message);
|
||||
} else {
|
||||
this.logger.error(`Axios error: ${error.message}`);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
import { BullBoardModule } from '@bull-board/nestjs';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { RegisterQueueOptions } from '@nestjs/bullmq/dist/interfaces/register-queue-options.interface';
|
||||
import { DynamicModule } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Registers a queue with both BullModule and BullBoardModule
|
||||
* This ensures that whenever a queue is registered, it's also added to the Bull Board
|
||||
*
|
||||
* @param options Queue options including name
|
||||
* @returns An array of dynamic modules for both BullModule and BullBoardModule
|
||||
*/
|
||||
export function RegisterAppQueue(
|
||||
options: RegisterQueueOptions,
|
||||
): DynamicModule[] {
|
||||
return [
|
||||
BullModule.registerQueue({
|
||||
...options,
|
||||
}),
|
||||
BullBoardModule.forFeature({
|
||||
name: options.name,
|
||||
adapter: BullMQAdapter,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface ILogger {
|
||||
fatal(log: string): void;
|
||||
error(log: string): void;
|
||||
warn(log: string): void;
|
||||
info(log: string): void;
|
||||
debug(log: string): void;
|
||||
trace(log: string): void;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
import { ILogger } from './ILogger';
|
||||
|
||||
/**
|
||||
* JobLoggerWrapper is a wrapper around the BullMQ Job class that provides
|
||||
* a logger with the job ID as a child context.
|
||||
* It allows logging messages to the job's log
|
||||
* and also to the logger itself.
|
||||
*/
|
||||
export class JobLoggerWrapper implements ILogger {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
private job: Job,
|
||||
logger: Logger,
|
||||
) {
|
||||
this.logger = logger.child({ jobId: job.id });
|
||||
}
|
||||
|
||||
fatal(log: string): void {
|
||||
this.log(log, 'fatal');
|
||||
}
|
||||
|
||||
error(log: string): void {
|
||||
this.log(log, 'error');
|
||||
}
|
||||
|
||||
warn(log: string): void {
|
||||
this.log(log, 'warn');
|
||||
}
|
||||
|
||||
info(log: string): void {
|
||||
this.log(log, 'info');
|
||||
}
|
||||
|
||||
debug(log: string): void {
|
||||
this.log(log, 'debug');
|
||||
}
|
||||
|
||||
trace(log: string): void {
|
||||
this.log(log, 'trace');
|
||||
}
|
||||
|
||||
private log(msg: string, level: string) {
|
||||
if (!this.logger.isLevelEnabled(level)) {
|
||||
return;
|
||||
}
|
||||
this.logger[level](msg);
|
||||
const timestamp = new Date().toISOString();
|
||||
this.job
|
||||
.log(`[${timestamp}] ${level.toUpperCase()}: ${msg}`)
|
||||
.catch((err) => {
|
||||
this.logger.error('Error logging message to job', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { Backoffs } from 'bullmq';
|
||||
|
||||
/**
|
||||
* Calculates the delay until the next attempt for a job.
|
||||
*
|
||||
* @param job The job to calculate the next attempt delay for
|
||||
* @returns The delay in milliseconds, or null if there is no next attempt or backoff is not set
|
||||
*/
|
||||
export function NextAttemptDelayInMs(job: Job): number | null {
|
||||
const attemptsMade = job.attemptsMade + 1;
|
||||
const maxAttempts = job.opts?.attempts || 1;
|
||||
// If this is the last attempt, return null
|
||||
if (attemptsMade >= maxAttempts) {
|
||||
return null;
|
||||
}
|
||||
// Normalize the backoff options
|
||||
const backoff = Backoffs.normalize(job.opts.backoff);
|
||||
if (!backoff) {
|
||||
return null;
|
||||
}
|
||||
// Calculate the delay using Backoffs.calculate
|
||||
const delay = Backoffs.calculate(backoff, attemptsMade, new Error(), job);
|
||||
// The delay can be a Promise, a number, or undefined
|
||||
if (delay === undefined) {
|
||||
return null;
|
||||
}
|
||||
// If it's a Promise, we can't handle it synchronously, so return null
|
||||
if (delay instanceof Promise) {
|
||||
return null;
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
export function NextAttemptDelayInWholeSeconds(job: Job) {
|
||||
const delay = NextAttemptDelayInMs(job);
|
||||
if (delay == null) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(delay / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a job has been retried (not on its first attempt).
|
||||
*
|
||||
* @param job The job to check
|
||||
* @returns True if the job has been retried (attemptsMade > 1), false otherwise
|
||||
*/
|
||||
export function HasBeenRetried(job: Job): boolean {
|
||||
const attemptsMade = job.attemptsMade + 1;
|
||||
return attemptsMade > 1;
|
||||
}
|
||||
|
||||
export function JobLink(job: Job): { text: string; url: string } {
|
||||
const text = `${job.queueName} => ${job.id}`;
|
||||
const url = `http://localhost:3000/jobs/queue/${encodeURIComponent(
|
||||
job.queueName,
|
||||
)}/${job.id}`;
|
||||
return { text: text, url: url };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
AppsService,
|
||||
IAppsService,
|
||||
} from '@waha/apps/app_sdk/services/IAppsService';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
|
||||
import { App } from '../dto/app.dto';
|
||||
import { ListAppsQuery } from '../dto/query.dto';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/apps')
|
||||
@ApiTags('🧩 Apps')
|
||||
export class AppsController {
|
||||
constructor(
|
||||
@Inject(AppsService)
|
||||
private appsService: IAppsService,
|
||||
private manager: SessionManager,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
@ApiOperation({ summary: 'List all apps for a session' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async list(
|
||||
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
|
||||
): Promise<App[]> {
|
||||
return this.appsService.list(this.manager, query.session);
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
@ApiOperation({ summary: 'Create a new app' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async create(@Body() app: App): Promise<App> {
|
||||
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<App> {
|
||||
if (!app.id) {
|
||||
app.id = id;
|
||||
} else if (app.id !== id) {
|
||||
throw new NotFoundException(
|
||||
`App ID in path (${id}) does not match ID in body (${app.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.appsService.update(this.manager, app);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@ApiOperation({ summary: 'Delete an app' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async delete(@Param('id') id: string): Promise<void> {
|
||||
await this.appsService.delete(this.manager, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as process from 'node:process';
|
||||
|
||||
import { parseBool } from '@waha/helpers';
|
||||
import * as basicAuth from 'express-basic-auth';
|
||||
|
||||
export function BullAuthMiddleware() {
|
||||
let username = process.env.WAHA_DASHBOARD_USERNAME;
|
||||
let password = process.env.WAHA_DASHBOARD_PASSWORD;
|
||||
if (process.env.WAHA_DASHBOARD_ENABLED) {
|
||||
const enabled = parseBool(process.env.WAHA_DASHBOARD_ENABLED);
|
||||
if (!enabled) {
|
||||
// Generate a random uuid4 username /password to prevent access
|
||||
username = 'admin';
|
||||
password = crypto.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
return basicAuth({
|
||||
challenge: true,
|
||||
users: {
|
||||
[username]: password,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { DefaultJobOptions } from 'bullmq';
|
||||
|
||||
export const JOB_DELAY = 1_000;
|
||||
export const JOB_LOCK_TTL = 20_000;
|
||||
export const JOB_CONCURRENCY =
|
||||
parseInt(process.env.WAHA_APPS_JOBS_CONCURRENCY) || 50;
|
||||
|
||||
function jobRemoveOptions() {
|
||||
// in seconds
|
||||
const HOUR = 60 * 60;
|
||||
const DAY = 24 * HOUR;
|
||||
return {
|
||||
removeOnComplete: {
|
||||
age:
|
||||
parseInt(process.env.WAHA_APPS_JOBS_REMOVE_ON_COMPLETE_AGE) || 3 * DAY,
|
||||
count:
|
||||
parseInt(process.env.WAHA_APPS_JOBS_REMOVE_ON_COMPLETE_COUNT) || 1000,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: parseInt(process.env.WAHA_APPS_JOBS_REMOVE_ON_FAIL_AGE) || 31 * DAY,
|
||||
count: parseInt(process.env.WAHA_APPS_JOBS_REMOVE_ON_FAIL_COUNT) || 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const JobRemoveOptions = jobRemoveOptions();
|
||||
|
||||
export const ExponentialRetriesJobOptions: DefaultJobOptions = {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoRetriesJobOptions: DefaultJobOptions = {
|
||||
attempts: 1,
|
||||
};
|
||||
|
||||
export function merge(...args: any[]) {
|
||||
return Object.assign({}, ...args);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export type AllowedAppConfig = ChatWootAppConfig;
|
||||
|
||||
export enum AppName {
|
||||
chatwoot = 'chatwoot',
|
||||
}
|
||||
|
||||
export class App<T extends AllowedAppConfig = any> {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
session: string;
|
||||
|
||||
// App name (aka type)
|
||||
@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) {
|
||||
switch (options.object.app) {
|
||||
case AppName.chatwoot:
|
||||
return ChatWootAppConfig;
|
||||
default:
|
||||
return Object;
|
||||
}
|
||||
}
|
||||
return Object;
|
||||
})
|
||||
config: T;
|
||||
}
|
||||
|
||||
export class ChatWootAppDto extends App<ChatWootAppConfig> {
|
||||
@Type(() => ChatWootAppConfig)
|
||||
config: ChatWootAppConfig;
|
||||
}
|
||||
|
||||
export type AppDto = ChatWootAppDto;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ListAppsQuery {
|
||||
@ApiProperty({
|
||||
example: 'default',
|
||||
required: true,
|
||||
description: 'Session name to list apps for',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
session: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { Knex } from 'knex';
|
||||
|
||||
function migrateSDK(knex: Knex): Promise<any> {
|
||||
const config = {
|
||||
directory: `${__dirname}/migrations`,
|
||||
tableName: 'apps_migrations',
|
||||
loadExtensions: ['.js'],
|
||||
};
|
||||
return knex.migrate.latest(config);
|
||||
}
|
||||
|
||||
function migrateApp(knex: Knex, app: string): Promise<any> {
|
||||
// one level upp
|
||||
const appsDirectory = path.join(__dirname, '..', '..', 'apps');
|
||||
const directory = path.join(appsDirectory, app, 'migrations');
|
||||
const config = {
|
||||
directory: directory,
|
||||
tableName: `app_${app}_migrations`,
|
||||
loadExtensions: ['.js'],
|
||||
};
|
||||
return knex.migrate.latest(config);
|
||||
}
|
||||
|
||||
export async function migrate(knex: Knex) {
|
||||
await migrateSDK(knex);
|
||||
await migrateApp(knex, 'chatwoot');
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
exports.up = function (knex: Knex) {
|
||||
return knex.schema
|
||||
.createTable('apps', function (table) {
|
||||
table.increments('pk');
|
||||
table.string('id', 64);
|
||||
table.string('session', 64);
|
||||
table.string('app', 64);
|
||||
table.json('config');
|
||||
})
|
||||
.table('apps', function (table) {
|
||||
table.unique(['pk']);
|
||||
table.index(['session']);
|
||||
table.index(['id']);
|
||||
table.unique(['id']);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex: Knex) {
|
||||
return knex.schema.dropTable('apps');
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import { App } from '../dto/app.dto';
|
||||
|
||||
class AppsIsDisabledError extends UnprocessableEntityException {
|
||||
constructor() {
|
||||
super(
|
||||
"Apps are disabled. Enable it by setting 'WAHA_APPS_ENABLED=True' environment variable.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AppsDisabledService implements IAppsService {
|
||||
async list(manager: SessionManager, session: string): Promise<App[]> {
|
||||
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): Promise<App> {
|
||||
throw new AppsIsDisabledError();
|
||||
}
|
||||
|
||||
async delete(manager: SessionManager, appId: string) {
|
||||
throw new AppsIsDisabledError();
|
||||
}
|
||||
|
||||
migrate(knex: Knex<any, any[]>): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async beforeSessionStart(session: WhatsappSession, store: DataStore) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { migrate } from '@waha/apps/app_sdk/migrations';
|
||||
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import { generatePrefixedId } from '@waha/utils/ids';
|
||||
import { Knex } from 'knex';
|
||||
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { App, AppName } from '../dto/app.dto';
|
||||
import { AppRepository } from '../storage/AppRepository';
|
||||
|
||||
@Injectable()
|
||||
export class AppsEnabledService implements IAppsService {
|
||||
constructor(
|
||||
protected readonly chatwootService: ChatWootAppService,
|
||||
@InjectPinoLogger('AppsService')
|
||||
protected logger: PinoLogger,
|
||||
) {}
|
||||
|
||||
async list(manager: SessionManager, session: string): Promise<App[]> {
|
||||
const knex = manager.store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
const apps = await repo.getAllBySession(session);
|
||||
apps.forEach((app) => {
|
||||
delete app.pk;
|
||||
});
|
||||
return apps;
|
||||
}
|
||||
|
||||
async create(manager: SessionManager, app: App): Promise<App> {
|
||||
await this.checkSessionExists(manager, app.session);
|
||||
app.id = app.id || generatePrefixedId('app');
|
||||
|
||||
const knex = manager.store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
|
||||
const existingApp = await repo.getById(app.id);
|
||||
if (existingApp) {
|
||||
throw new Error(`App with ID '${app.id}' already exists.`);
|
||||
}
|
||||
|
||||
// Validate only one Chatwoot app per session
|
||||
if (app.app === AppName.chatwoot) {
|
||||
const existingApps = await repo.getAllBySession(app.session);
|
||||
const existingChatwootApp = existingApps.find(
|
||||
(existingApp) => existingApp.app === AppName.chatwoot,
|
||||
);
|
||||
|
||||
if (existingChatwootApp) {
|
||||
throw new Error(
|
||||
`Only one Chatwoot app is allowed per session. Session '${app.session}' already has a Chatwoot app with ID '${existingChatwootApp.id}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const service = this.getAppService(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);
|
||||
if (app.enabled) {
|
||||
await this.restartIfRunning(manager, app.session);
|
||||
}
|
||||
delete result.pk;
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
const savedApp = await repo.getById(app.id);
|
||||
if (!savedApp) {
|
||||
throw new NotFoundException(`App '${app.id}' not found`);
|
||||
}
|
||||
if (savedApp.app != app.app) {
|
||||
throw new Error(
|
||||
`Can not change app type. Delete and create a new app. Before type: '${savedApp.app}' After type: '${app.app}'`,
|
||||
);
|
||||
}
|
||||
if (savedApp.session != app.session) {
|
||||
throw new Error(
|
||||
`Can not change session. Delete and create a new app. Before session: '${savedApp.session}' After session: '${app.session}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const service = this.getAppService(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);
|
||||
|
||||
const updated = await repo.getById(app.id);
|
||||
delete (updated as any)?.pk;
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async delete(manager: SessionManager, appId: string) {
|
||||
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`);
|
||||
}
|
||||
const service = this.getAppService(app);
|
||||
await service.beforeDeleted(app);
|
||||
await repo.delete(app.id);
|
||||
await this.restartIfRunning(manager, app.session);
|
||||
return;
|
||||
}
|
||||
|
||||
async beforeSessionStart(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);
|
||||
await service.beforeSessionStart(app, session);
|
||||
}
|
||||
}
|
||||
|
||||
async migrate(knex: Knex): Promise<void> {
|
||||
await migrate(knex);
|
||||
}
|
||||
|
||||
private restartIfRunning(manager: SessionManager, session: string) {
|
||||
const isRunning = manager.isRunning(session);
|
||||
if (!isRunning) {
|
||||
return;
|
||||
}
|
||||
return manager.restart(session);
|
||||
}
|
||||
|
||||
private getAppService(app: App): IAppService {
|
||||
switch (app.app) {
|
||||
case AppName.chatwoot:
|
||||
return this.chatwootService;
|
||||
default:
|
||||
throw new Error(`App '${app.app}' not supported`);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkSessionExists(
|
||||
manager: SessionManager,
|
||||
sessionName: string,
|
||||
) {
|
||||
const session = await manager.exists(sessionName);
|
||||
if (session === null) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
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<App>;
|
||||
|
||||
delete(manager: SessionManager, appId: string): Promise<void>;
|
||||
|
||||
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
|
||||
|
||||
migrate(knex: Knex): Promise<void>;
|
||||
}
|
||||
|
||||
export const AppsService = Symbol('AppsService');
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import { App } from '../dto/app.dto';
|
||||
import { AppDB } from './types';
|
||||
|
||||
export class AppRepository {
|
||||
static tableName = 'apps';
|
||||
|
||||
constructor(private readonly knex: Knex) {}
|
||||
|
||||
get tableName() {
|
||||
return AppRepository.tableName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an app to the database
|
||||
*/
|
||||
async save(app: Omit<App, 'pk'>): Promise<AppDB> {
|
||||
const appToSave: any = { ...app };
|
||||
if (appToSave.config && typeof appToSave.config === 'object') {
|
||||
appToSave.config = JSON.stringify(appToSave.config);
|
||||
}
|
||||
const [pk] = await this.knex(this.tableName)
|
||||
.insert(appToSave)
|
||||
.returning('pk');
|
||||
return { ...app, pk: pk };
|
||||
}
|
||||
|
||||
private deserialize(app: AppDB): AppDB {
|
||||
const parsedConfig =
|
||||
typeof app.config === 'string' ? JSON.parse(app.config) : app.config;
|
||||
|
||||
// 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 {
|
||||
const appCopy = { ...app };
|
||||
if (appCopy.config && typeof appCopy.config === 'object') {
|
||||
appCopy.config = JSON.stringify(appCopy.config);
|
||||
}
|
||||
return appCopy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an app by its id
|
||||
*/
|
||||
async getById(id: string): Promise<AppDB | null> {
|
||||
const app = await this.knex(this.tableName).where('id', id).first();
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
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
|
||||
*/
|
||||
async getAllBySession(session: string): Promise<AppDB[]> {
|
||||
return this.knex(this.tableName)
|
||||
.select('*')
|
||||
.where('session', session)
|
||||
.orderBy('id', 'asc')
|
||||
.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: any = this.serialize(app);
|
||||
if (appToUpdate.enabled === undefined) {
|
||||
delete appToUpdate.enabled; // keep current value
|
||||
}
|
||||
await this.knex(this.tableName).where('id', id).update(appToUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an app
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.knex(this.tableName).where('id', id).delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||
|
||||
export interface AppDB extends App {
|
||||
// Internal database number
|
||||
pk: number;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as process from 'node:process';
|
||||
|
||||
import { ExpressAdapter } from '@bull-board/express';
|
||||
import { BullBoardModule } from '@bull-board/nestjs';
|
||||
import { RedisModule } from '@liaoliaots/nestjs-redis';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
||||
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';
|
||||
|
||||
const IMPORTS = [
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
prefix: `waha-${process.env.WAHA_WORKER_ID}`,
|
||||
}),
|
||||
RedisModule.forRoot({
|
||||
closeClient: true,
|
||||
config: {
|
||||
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
|
||||
onClientCreated: async (client) => {
|
||||
try {
|
||||
await client.ping();
|
||||
} catch (err) {
|
||||
console.error('[Redis] Connection failed:', err);
|
||||
process.exit(1); // Stop the app if Redis is unavailable
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
RMutexModule,
|
||||
BullBoardModule.forRoot({
|
||||
route: '/jobs',
|
||||
adapter: ExpressAdapter,
|
||||
middleware: BullAuthMiddleware(),
|
||||
boardOptions: {
|
||||
uiConfig: {
|
||||
boardTitle: 'Jobs | WAHA',
|
||||
boardLogo: {
|
||||
path: '/dashboard/layout/images/logo-white.svg',
|
||||
width: 35,
|
||||
height: 35,
|
||||
},
|
||||
favIcon: {
|
||||
default: '/dashboard/favicon.ico',
|
||||
alternative: '/dashboard/favicon.ico',
|
||||
},
|
||||
miscLinks: [
|
||||
{
|
||||
text: '📊 Dashboard',
|
||||
url: '/dashboard',
|
||||
},
|
||||
{
|
||||
text: '📚 Swagger (OpenAPI)',
|
||||
url: '/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
...ChatWootExports.imports,
|
||||
];
|
||||
|
||||
const AppsEnabled = {
|
||||
imports: IMPORTS,
|
||||
controllers: [AppsController, ...ChatWootExports.controllers],
|
||||
providers: [
|
||||
{
|
||||
provide: AppsService,
|
||||
useClass: AppsEnabledService,
|
||||
},
|
||||
...ChatWootExports.providers,
|
||||
],
|
||||
};
|
||||
|
||||
const AppsDisabled = {
|
||||
providers: [
|
||||
{
|
||||
provide: AppsService,
|
||||
useClass: AppsDisabledService,
|
||||
},
|
||||
],
|
||||
imports: [],
|
||||
controllers: [AppsController, ChatwootLocalesController],
|
||||
};
|
||||
|
||||
function checkApiKey() {
|
||||
const key = process.env.WHATSAPP_API_KEY || process.env.WAHA_API_KEY;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const plain = process.env.WAHA_API_KEY_PLAIN;
|
||||
if (!plain) {
|
||||
throw Error(
|
||||
'WAHA_API_KEY set, please provide WAHA_API_KEY_PLAIN when WAHA_APPS_ENABLED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = parseBool(process.env.WAHA_APPS_ENABLED);
|
||||
if (enabled) {
|
||||
checkApiKey();
|
||||
}
|
||||
export const AppsModuleExports = enabled ? AppsEnabled : AppsDisabled;
|
||||
@@ -0,0 +1,18 @@
|
||||
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 '❓';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { sortBy } from 'lodash';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { i18n } from '@waha/apps/chatwoot/i18n';
|
||||
|
||||
interface LanguageResponse {
|
||||
name: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
@Controller('api/apps/chatwoot')
|
||||
@ApiSecurity('api_key')
|
||||
@ApiTags('🧩 Apps')
|
||||
export class ChatwootLocalesController {
|
||||
@Get('locales')
|
||||
@ApiOperation({
|
||||
summary: 'Get available languages for Chatwoot app',
|
||||
description: 'Get available languages for Chatwoot app',
|
||||
})
|
||||
getLanguages(): LanguageResponse[] {
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FindChatID } 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';
|
||||
|
||||
@Controller('webhooks/chatwoot/')
|
||||
@ApiTags('🧩 Apps')
|
||||
export class ChatwootWebhookController {
|
||||
constructor(
|
||||
private readonly chatWootQueueService: ChatWootQueueService,
|
||||
private readonly manager: SessionManager,
|
||||
) {}
|
||||
|
||||
@Post(':session/:id')
|
||||
@ApiOperation({
|
||||
summary: 'Chatwoot Webhook',
|
||||
description: 'Chatwoot Webhook',
|
||||
})
|
||||
async webhook(
|
||||
@Param('session') session: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: any,
|
||||
) {
|
||||
if (!body || !body?.event) {
|
||||
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 data: InboxData = {
|
||||
session: session,
|
||||
app: id,
|
||||
body: body,
|
||||
};
|
||||
|
||||
// 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)
|
||||
const sender = body?.conversation?.meta?.sender;
|
||||
const chatId = FindChatID(sender);
|
||||
const isCommandsChat = chatId === INBOX_CONTACT_CHAT_ID;
|
||||
|
||||
// Check if it's a deleted message
|
||||
if (body.content_attributes?.deleted && !isCommandsChat) {
|
||||
await this.chatWootQueueService.addMessageDeletedJob(data);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Route to specific queues based on an event type
|
||||
switch (body.event) {
|
||||
case EventName.MESSAGE_CREATED:
|
||||
if (!isCommandsChat) {
|
||||
await this.chatWootQueueService.addMessageCreatedJob(data);
|
||||
} else {
|
||||
await this.chatWootQueueService.addCommandsJob(body.event, data);
|
||||
}
|
||||
return { success: true };
|
||||
case EventName.MESSAGE_UPDATED:
|
||||
if (!isCommandsChat) {
|
||||
await this.chatWootQueueService.addMessageUpdatedJob(data);
|
||||
} else {
|
||||
await this.chatWootQueueService.addCommandsJob(body.event, data);
|
||||
}
|
||||
return { success: true };
|
||||
default:
|
||||
// Ignore other events
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
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';
|
||||
|
||||
const cache: NodeCache = new NodeCache({
|
||||
stdTTL: 24 * 60 * 60, // 1 day
|
||||
useClones: false,
|
||||
});
|
||||
|
||||
export function CacheForConfig(config: ChatWootAPIConfig): ConversationCache {
|
||||
return new ConversationCache(`${config.url}+${config.inboxId}`);
|
||||
}
|
||||
|
||||
class ConversationCache implements IConversationCache {
|
||||
constructor(private prefix: string) {}
|
||||
|
||||
fullKey(key: string) {
|
||||
return `${this.prefix}.${key}`;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
const fullKey = this.fullKey(key);
|
||||
cache.del(fullKey);
|
||||
}
|
||||
|
||||
get(key: string): public_conversation | null {
|
||||
const fullKey = this.fullKey(key);
|
||||
return cache.get(fullKey) || null;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
const fullKey = this.fullKey(key);
|
||||
return cache.has(fullKey);
|
||||
}
|
||||
|
||||
set(key: string, value: public_conversation): void {
|
||||
const fullKey = this.fullKey(key);
|
||||
cache.set(fullKey, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely clean the cache with the prefix
|
||||
*/
|
||||
clean() {
|
||||
cache.keys().forEach((key) => {
|
||||
if (key.startsWith(this.prefix)) {
|
||||
cache.del(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { public_conversation } from '@figuro/chatwoot-sdk';
|
||||
|
||||
export interface IConversationCache {
|
||||
get(key: string): public_conversation | null;
|
||||
set(key: string, value: public_conversation): void;
|
||||
has(key: string): boolean;
|
||||
delete(key: string): void;
|
||||
clean(): void;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { RegisterAppQueue } from '@waha/apps/app_sdk/BullUtils';
|
||||
import {
|
||||
ExponentialRetriesJobOptions,
|
||||
JobRemoveOptions,
|
||||
merge,
|
||||
NoRetriesJobOptions,
|
||||
} from '@waha/apps/app_sdk/constants';
|
||||
import { MessageCleanupConsumer } from '@waha/apps/chatwoot/consumers/scheduled/message.cleanup';
|
||||
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
|
||||
import * as lodash from 'lodash';
|
||||
|
||||
import { ChatwootLocalesController } from './api/chatwoot.locales.controller';
|
||||
import { ChatwootWebhookController } from './api/chatwoot.webhook.controller';
|
||||
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 { 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 { WAHASessionStatusConsumer } from './consumers/waha/session.status';
|
||||
import { ChatWootQueueService } from './services/ChatWootQueueService';
|
||||
import { ChatWootScheduleService } from './services/ChatWootScheduleService';
|
||||
import { ChatWootWAHAQueueService } from './services/ChatWootWAHAQueueService';
|
||||
|
||||
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
|
||||
|
||||
const IMPORTS = lodash.flatten([
|
||||
RegisterAppQueue({
|
||||
name: QueueName.SCHEDULED_MESSAGE_CLEANUP,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.SCHEDULED_CHECK_VERSION,
|
||||
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.WAHA_MESSAGE_ANY,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.WAHA_MESSAGE_REACTION,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.WAHA_MESSAGE_EDITED,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.WAHA_MESSAGE_REVOKED,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.WAHA_SESSION_STATUS,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.INBOX_MESSAGE_CREATED,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.INBOX_MESSAGE_UPDATED,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.INBOX_MESSAGE_DELETED,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.INBOX_COMMANDS,
|
||||
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
]);
|
||||
|
||||
const PROVIDERS = [
|
||||
ChatWootInboxMessageCreatedConsumer,
|
||||
ChatWootInboxMessageUpdatedConsumer,
|
||||
ChatWootInboxMessageDeletedConsumer,
|
||||
ChatWootInboxCommandsConsumer,
|
||||
WAHASessionStatusConsumer,
|
||||
WAHAMessageAnyConsumer,
|
||||
WAHAMessageReactionConsumer,
|
||||
WAHAMessageEditedConsumer,
|
||||
WAHAMessageRevokedConsumer,
|
||||
MessageCleanupConsumer,
|
||||
CheckVersionConsumer,
|
||||
ChatWootWAHAQueueService,
|
||||
ChatWootQueueService,
|
||||
ChatWootScheduleService,
|
||||
ChatWootAppService,
|
||||
];
|
||||
|
||||
export const ChatWootExports = {
|
||||
providers: PROVIDERS,
|
||||
imports: IMPORTS,
|
||||
controllers: CONTROLLERS,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import ChatwootClient, {
|
||||
contact_update,
|
||||
public_contact_create_update_payload,
|
||||
} from '@figuro/chatwoot-sdk';
|
||||
import type { contact } from '@figuro/chatwoot-sdk/dist/models/contact';
|
||||
import type { generic_id } from '@figuro/chatwoot-sdk/dist/models/generic_id';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import {
|
||||
ChatWootAPIConfig,
|
||||
ChatWootInboxAPI,
|
||||
} from '@waha/apps/chatwoot/client/interfaces';
|
||||
import { isJidCusFormat } from '@waha/utils/wa';
|
||||
import * as lodash from 'lodash';
|
||||
|
||||
import { AttributeKey } from '../const';
|
||||
|
||||
export interface ContactResponse {
|
||||
data: generic_id & contact;
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export class ContactAPI {
|
||||
constructor(
|
||||
private config: ChatWootAPIConfig,
|
||||
private accountAPI: ChatwootClient,
|
||||
protected inboxAPI: ChatWootInboxAPI,
|
||||
private logger: ILogger,
|
||||
) {}
|
||||
|
||||
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
|
||||
const payload: any[] = [
|
||||
{
|
||||
attribute_key: AttributeKey.WA_CHAT_ID,
|
||||
filter_operator: 'equal_to',
|
||||
values: [chatId],
|
||||
attribute_model: 'standard',
|
||||
custom_attribute_type: '',
|
||||
query_operator: 'OR',
|
||||
},
|
||||
{
|
||||
attribute_key: AttributeKey.WA_JID,
|
||||
filter_operator: 'equal_to',
|
||||
values: [chatId],
|
||||
attribute_model: 'standard',
|
||||
custom_attribute_type: '',
|
||||
query_operator: 'OR',
|
||||
},
|
||||
{
|
||||
attribute_key: AttributeKey.WA_LID,
|
||||
filter_operator: 'equal_to',
|
||||
values: [chatId],
|
||||
attribute_model: 'standard',
|
||||
custom_attribute_type: '',
|
||||
query_operator: 'OR',
|
||||
},
|
||||
{
|
||||
attribute_key: 'identifier',
|
||||
filter_operator: 'equal_to',
|
||||
values: [chatId],
|
||||
attribute_model: 'standard',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
|
||||
if (isJidCusFormat(chatId)) {
|
||||
// Search by phone
|
||||
let phone_number = chatId.split('@')[0];
|
||||
phone_number = phone_number.replace('+', '');
|
||||
payload[payload.length - 1].query_operator = 'OR';
|
||||
payload.push({
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'equal_to',
|
||||
values: [phone_number],
|
||||
});
|
||||
}
|
||||
|
||||
const response: any = await this.accountAPI.contacts.filter({
|
||||
accountId: this.config.accountId,
|
||||
payload: payload as any,
|
||||
});
|
||||
|
||||
const contacts = response.payload;
|
||||
if (contacts.length == 0) {
|
||||
return null;
|
||||
}
|
||||
const contact = contacts[0];
|
||||
const inboxes = lodash.filter(contact.contact_inboxes, {
|
||||
inbox: { id: this.config.inboxId },
|
||||
});
|
||||
if (inboxes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
data: contact,
|
||||
sourceId: inboxes[0].source_id,
|
||||
};
|
||||
}
|
||||
|
||||
public updateCustomAttributes(
|
||||
contact: generic_id & contact,
|
||||
attributes: any,
|
||||
) {
|
||||
const update: contact_update = {
|
||||
custom_attributes: { ...contact.custom_attributes, ...attributes },
|
||||
};
|
||||
return this.accountAPI.contacts.update({
|
||||
id: contact.id,
|
||||
accountId: this.config.accountId,
|
||||
data: update,
|
||||
});
|
||||
}
|
||||
|
||||
public async create(
|
||||
chatId: string,
|
||||
payload: public_contact_create_update_payload,
|
||||
): Promise<ContactResponse> {
|
||||
const contact = await this.inboxAPI.contacts.create({
|
||||
inboxIdentifier: this.config.inboxIdentifier,
|
||||
data: payload,
|
||||
});
|
||||
this.logger.info(
|
||||
`Created contact for chat.id: ${chatId}, contact.id: ${contact.source_id}`,
|
||||
);
|
||||
const response: any = await this.accountAPI.contacts.get({
|
||||
accountId: this.config.accountId,
|
||||
id: contact.id,
|
||||
});
|
||||
return {
|
||||
data: response.payload,
|
||||
sourceId: contact.source_id,
|
||||
};
|
||||
}
|
||||
|
||||
public updateAvatarUrlSafe(contactId, avatarUrl: string) {
|
||||
this.accountAPI.contacts
|
||||
.update({
|
||||
accountId: this.config.accountId,
|
||||
id: contactId,
|
||||
data: {
|
||||
avatar_url: avatarUrl,
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.warn(
|
||||
'Error updating avatar_url for contact.id: ' + contactId,
|
||||
);
|
||||
this.logger.warn(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import ChatwootClient, {
|
||||
ApiError,
|
||||
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 { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||
import { ConversationAPI } from '@waha/apps/chatwoot/client/ConversationAPI';
|
||||
import { ChatWootAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
|
||||
import { InboxContactInfo } from '@waha/apps/chatwoot/contacts/InboxContactInfo';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
|
||||
import { CacheForConfig } from '../cache/ConversationCache';
|
||||
import { IConversationCache } from '../cache/IConversationCache';
|
||||
|
||||
export interface ContactInfo {
|
||||
ChatId(): string;
|
||||
|
||||
AvatarUrl(): Promise<string | null>;
|
||||
|
||||
Attributes(): Promise<any>;
|
||||
|
||||
PublicContactCreate(): Promise<public_contact_create_update_payload>;
|
||||
}
|
||||
|
||||
export class ContactConversationService {
|
||||
private cache: IConversationCache;
|
||||
|
||||
constructor(
|
||||
private config: ChatWootAPIConfig,
|
||||
private contactAPI: ContactAPI,
|
||||
private conversationAPI: ConversationAPI,
|
||||
private accountAPI: ChatwootClient,
|
||||
private logger: ILogger,
|
||||
private l: Locale,
|
||||
) {
|
||||
this.cache = CacheForConfig(config);
|
||||
}
|
||||
|
||||
private async upsertByContactInfo(
|
||||
contactInfo: ContactInfo,
|
||||
): Promise<public_conversation> {
|
||||
const chatId = contactInfo.ChatId();
|
||||
|
||||
// Check cache for chat id
|
||||
if (this.cache.has(chatId)) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
);
|
||||
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}`,
|
||||
);
|
||||
|
||||
//
|
||||
// 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}`,
|
||||
);
|
||||
|
||||
// Save to cache
|
||||
this.cache.set(chatId, conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
public async ConversationByContact(
|
||||
contactInfo: ContactInfo,
|
||||
): Promise<Conversation> {
|
||||
const chatId = contactInfo.ChatId();
|
||||
const publicConversation = await this.upsertByContactInfo(contactInfo);
|
||||
const conversation = new Conversation(
|
||||
this.accountAPI,
|
||||
this.config.accountId,
|
||||
publicConversation.id,
|
||||
);
|
||||
conversation.onError = (err) => {
|
||||
if (err instanceof ApiError) {
|
||||
// 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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
return conversation;
|
||||
}
|
||||
|
||||
public ConversationById(conversationId: number): Conversation {
|
||||
return new Conversation(
|
||||
this.accountAPI,
|
||||
this.config.accountId,
|
||||
conversationId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build specific contact for inbox notifications
|
||||
* @constructor
|
||||
*/
|
||||
public async InboxNotifications() {
|
||||
return this.ConversationByContact(new InboxContactInfo(this.l));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import ChatwootClient from '@figuro/chatwoot-sdk';
|
||||
import type { conversation_message_create } from '@figuro/chatwoot-sdk/dist/models/conversation_message_create';
|
||||
import { MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
|
||||
export class Conversation {
|
||||
public onError: (e: any) => void;
|
||||
|
||||
constructor(
|
||||
private accountAPI: ChatwootClient,
|
||||
private accountId: number,
|
||||
public conversationId: number,
|
||||
) {}
|
||||
|
||||
public async send(data: conversation_message_create) {
|
||||
try {
|
||||
const message = await this.accountAPI.messages.create({
|
||||
accountId: this.accountId,
|
||||
conversationId: this.conversationId,
|
||||
data: data,
|
||||
});
|
||||
return message;
|
||||
} catch (err) {
|
||||
if (this.onError) {
|
||||
this.onError(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async incoming(text: string) {
|
||||
const data: conversation_message_create = {
|
||||
content: text,
|
||||
message_type: MessageType.INCOMING as any,
|
||||
};
|
||||
return this.send(data);
|
||||
}
|
||||
|
||||
public async activity(text: string) {
|
||||
const data: conversation_message_create = {
|
||||
content: text,
|
||||
message_type: MessageType.ACTIVITY as any,
|
||||
};
|
||||
return this.send(data);
|
||||
}
|
||||
|
||||
public async outgoing(text: string) {
|
||||
const data: conversation_message_create = {
|
||||
content: text,
|
||||
message_type: MessageType.OUTGOING as any,
|
||||
};
|
||||
return this.send(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,53 @@
|
||||
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,47 @@
|
||||
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
|
||||
import { AttributeKey } from '@waha/apps/chatwoot/const';
|
||||
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
|
||||
import { buildMessageId } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
|
||||
export function GetJID(contact: any): string | null {
|
||||
return contact?.custom_attributes?.[AttributeKey.WA_JID];
|
||||
}
|
||||
|
||||
export function GetLID(contact: any): string | null {
|
||||
return contact?.custom_attributes?.[AttributeKey.WA_LID];
|
||||
}
|
||||
|
||||
export function GetChatID(contact: any): string | null {
|
||||
return contact?.custom_attributes?.[AttributeKey.WA_CHAT_ID];
|
||||
}
|
||||
|
||||
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,
|
||||
remoteJid: message.chat_id,
|
||||
fromMe: Boolean(message.from_me),
|
||||
participant: message.participant,
|
||||
};
|
||||
return buildMessageId(key);
|
||||
}
|
||||
|
||||
export function ContactAttr(chatId: string): AttributeKey {
|
||||
if (isLidUser(chatId)) {
|
||||
return AttributeKey.WA_LID;
|
||||
} else {
|
||||
return AttributeKey.WA_JID;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Original '@figuro/chatwoot-sdk'
|
||||
// Doesn't have the right types for client.client
|
||||
// So we defined it here
|
||||
import { ContactsApi } from '@figuro/chatwoot-sdk/dist/services/client/Contacts';
|
||||
import { ConversationsApi } from '@figuro/chatwoot-sdk/dist/services/client/Conversations';
|
||||
import { MessagesApi } from '@figuro/chatwoot-sdk/dist/services/client/Messages';
|
||||
|
||||
export interface ChatWootInboxAPI {
|
||||
contacts: ContactsApi;
|
||||
conversations: ConversationsApi;
|
||||
messages: MessagesApi;
|
||||
}
|
||||
|
||||
export interface ChatWootAccountAPIConfig {
|
||||
url: string;
|
||||
accountId: number;
|
||||
accountToken: string;
|
||||
}
|
||||
|
||||
export interface ChatWootInboxAPIConfig {
|
||||
url: string;
|
||||
inboxId: number;
|
||||
inboxIdentifier: string;
|
||||
}
|
||||
|
||||
export interface ChatWootAPIConfig
|
||||
extends ChatWootAccountAPIConfig,
|
||||
ChatWootInboxAPIConfig {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { conversation_message_create } from '@figuro/chatwoot-sdk/dist/models/conversation_message_create';
|
||||
import { SendAttachment } from '@waha/apps/chatwoot/client/types';
|
||||
|
||||
export function AttachmentFromBuffer(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
): conversation_message_create {
|
||||
const content = buffer.toString('base64');
|
||||
const attachments: SendAttachment[] = [
|
||||
{
|
||||
content: content,
|
||||
filename: filename,
|
||||
encoding: 'base64',
|
||||
},
|
||||
];
|
||||
const message: conversation_message_create = {
|
||||
content: '',
|
||||
attachments: attachments as any,
|
||||
};
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export enum EventName {
|
||||
CONVERSATION_CREATED = 'conversation_created',
|
||||
CONVERSATION_STATUS_CHANGED = 'conversation_status_changed',
|
||||
CONVERSATION_UPDATED = 'conversation_updated',
|
||||
CONTACT_CREATED = 'contact_created',
|
||||
CONTACT_UPDATED = 'contact_updated',
|
||||
MESSAGE_CREATED = 'message_created',
|
||||
MESSAGE_UPDATED = 'message_updated',
|
||||
WEBWIDGET_TRIGGERED = 'webwidget_triggered',
|
||||
CONVERSATION_TYPING_ON = 'conversation_typing_on',
|
||||
CONVERSATION_TYPING_OFF = 'conversation_typing_off',
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
id: number;
|
||||
account_id: number;
|
||||
message_id: number;
|
||||
file_type: 'image' | 'video' | 'audio' | string;
|
||||
file_size: number;
|
||||
data_url?: string;
|
||||
thumb_url?: string;
|
||||
extension?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface SendAttachment {
|
||||
content: string;
|
||||
filename?: string;
|
||||
encoding: 'base64';
|
||||
}
|
||||
|
||||
export enum MessageType {
|
||||
OUTGOING = 'outgoing',
|
||||
INCOMING = 'incoming',
|
||||
ACTIVITY = 'activity',
|
||||
}
|
||||
|
||||
export enum CustomAttributeType {
|
||||
TEXT = 0,
|
||||
NUMBER = 1,
|
||||
CURRENCY = 2,
|
||||
PERCENT = 3,
|
||||
LINK = 4,
|
||||
DATE = 5,
|
||||
LIST = 6,
|
||||
CHECKBOX = 7,
|
||||
}
|
||||
|
||||
export enum CustomAttributeModel {
|
||||
CONVERSATION = 0,
|
||||
CONTACT = 1,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CustomAttribute } from '@waha/apps/chatwoot/client/CustomAttributesAPI';
|
||||
|
||||
import { CustomAttributeModel, CustomAttributeType } from './client/types';
|
||||
|
||||
export enum AttributeKey {
|
||||
WA_CHAT_ID = 'waha_whatsapp_chat_id',
|
||||
WA_JID = 'waha_whatsapp_jid',
|
||||
WA_LID = 'waha_whatsapp_lid',
|
||||
}
|
||||
|
||||
export const CHATWOOT_CUSTOM_ATTRIBUTES: CustomAttribute[] = [
|
||||
{
|
||||
key: AttributeKey.WA_CHAT_ID,
|
||||
name: 'WhatsApp Chat ID',
|
||||
description: 'WhatsApp Chat ID',
|
||||
type: CustomAttributeType.TEXT,
|
||||
model: CustomAttributeModel.CONTACT,
|
||||
},
|
||||
{
|
||||
key: AttributeKey.WA_JID,
|
||||
name: 'WhatsApp JID',
|
||||
description: 'WhatsApp Phone Number ID',
|
||||
type: CustomAttributeType.TEXT,
|
||||
model: CustomAttributeModel.CONTACT,
|
||||
},
|
||||
{
|
||||
key: AttributeKey.WA_LID,
|
||||
name: 'WhatsApp LID',
|
||||
description: 'WhatsApp Linked ID (Anonymous ID)',
|
||||
type: CustomAttributeType.TEXT,
|
||||
model: CustomAttributeModel.CONTACT,
|
||||
},
|
||||
];
|
||||
|
||||
export const INBOX_CONTACT_CHAT_ID = 'whatsapp.integration';
|
||||
@@ -0,0 +1,26 @@
|
||||
export enum QueueName {
|
||||
//
|
||||
// Scheduled
|
||||
//
|
||||
SCHEDULED_MESSAGE_CLEANUP = 'chatwoot.scheduled | message.cleanup',
|
||||
SCHEDULED_CHECK_VERSION = 'chatwoot.scheduled | check.version',
|
||||
|
||||
//
|
||||
// WAHA Events
|
||||
//
|
||||
WAHA_SESSION_STATUS = 'chatwoot.waha | session.status',
|
||||
WAHA_MESSAGE_ANY = 'chatwoot.waha | message.any',
|
||||
WAHA_MESSAGE_REACTION = 'chatwoot.waha | message.reaction',
|
||||
WAHA_MESSAGE_EDITED = 'chatwoot.waha | message.edited',
|
||||
WAHA_MESSAGE_REVOKED = 'chatwoot.waha | message.revoked',
|
||||
//
|
||||
// ChatWoot Events - Real
|
||||
//
|
||||
INBOX_MESSAGE_CREATED = 'chatwoot.inbox | message_created',
|
||||
INBOX_MESSAGE_UPDATED = 'chatwoot.inbox | message_updated',
|
||||
//
|
||||
// ChatWoot Events - Artificial
|
||||
//
|
||||
INBOX_MESSAGE_DELETED = 'chatwoot.inbox | message_deleted',
|
||||
INBOX_COMMANDS = 'chatwoot.inbox | commands',
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 { 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';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import {
|
||||
ChatIDNotFoundForContactError,
|
||||
PhoneNumberNotFoundInWhatsAppError,
|
||||
} from '@waha/apps/chatwoot/errors';
|
||||
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/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';
|
||||
|
||||
/**
|
||||
* Base class for ChatWoot inbox consumers
|
||||
* Contains common logic for all inbox consumers
|
||||
*/
|
||||
export abstract class ChatWootInboxMessageConsumer 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 message
|
||||
* This method must be implemented by subclasses
|
||||
*/
|
||||
protected abstract Process(
|
||||
container: DIContainer,
|
||||
body: any,
|
||||
job: Job,
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
return await this.withMutex(job, key, () =>
|
||||
this.ProcessAndReportStatus(job),
|
||||
);
|
||||
}
|
||||
|
||||
private async ProcessAndReportStatus(job) {
|
||||
const body = job.data.body;
|
||||
try {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const result = await this.Process(container, body, job);
|
||||
await this.ReportErrorRecovered(job, body);
|
||||
return result;
|
||||
} catch (err) {
|
||||
await this.ReportErrorForMessage(job, err, body);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error for a message
|
||||
*/
|
||||
protected async ReportErrorForMessage(job: Job, err: any, body: any) {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const header: string = this.ErrorHeaderKey()
|
||||
? container.Locale().key(this.ErrorHeaderKey()).render()
|
||||
: err.message || `${err}`;
|
||||
const conversation = container
|
||||
.ContactConversationService()
|
||||
.ConversationById(body.conversation.id);
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportError(
|
||||
conversation,
|
||||
header,
|
||||
body.message_type,
|
||||
err,
|
||||
body.id,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
protected async ReportErrorRecovered(job: Job, body: any) {
|
||||
if (!HasBeenRetried(job)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const conversation = container
|
||||
.ContactConversationService()
|
||||
.ConversationById(body.conversation.id);
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportSucceeded(conversation, body.message_type, body.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function LookupAndCheckChatId(
|
||||
session: WAHASessionAPI,
|
||||
body: any,
|
||||
): Promise<string> {
|
||||
const sender = body.conversation.meta.sender;
|
||||
let chatId = FindChatID(sender);
|
||||
if (!chatId && sender.phone_number) {
|
||||
const existResult = await session.contactCheckExists(sender.phone_number);
|
||||
if (!existResult.numberExists) {
|
||||
throw new PhoneNumberNotFoundInWhatsAppError(sender.phone_number);
|
||||
}
|
||||
chatId = existResult.chatId;
|
||||
}
|
||||
if (!chatId) {
|
||||
throw new ChatIDNotFoundForContactError(sender);
|
||||
}
|
||||
return chatId;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { 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 { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/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 { PinoLogger } from 'nestjs-pino';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { ChatWootCommandsConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
|
||||
@Processor(QueueName.INBOX_COMMANDS, { concurrency: JOB_CONCURRENCY })
|
||||
export class ChatWootInboxCommandsConsumer extends ChatWootInboxMessageConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'ChatWootInboxCommandsConsumer');
|
||||
}
|
||||
|
||||
ErrorHeaderKey(): TKey | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, body, job: Job) {
|
||||
const handler = new SessionCommandHandler(
|
||||
container.Logger(),
|
||||
container.ContactConversationService(),
|
||||
job.data.session,
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
container.ChatWootConfig().commands,
|
||||
);
|
||||
return await handler.handle(body);
|
||||
}
|
||||
}
|
||||
|
||||
const CommandServerPrefix = 'server ';
|
||||
|
||||
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,
|
||||
private commands: ChatWootCommandsConfig,
|
||||
) {}
|
||||
|
||||
async handle(message: any) {
|
||||
const cmd = message.content?.toLowerCase();
|
||||
const command = this.findCommand(cmd) || Command.HELP;
|
||||
this.logger.info(
|
||||
`Executing command ${command} for session ${this.session}`,
|
||||
);
|
||||
const warning = this.commandDisabledWarning(command);
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
if (warning) {
|
||||
const text = this.l.key(warning).render();
|
||||
await conversation.incoming(text);
|
||||
return;
|
||||
}
|
||||
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 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 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 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 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 text = this.l.key(TKey.APP_SERVER_REBOOT_FORCE).render();
|
||||
await conversation.incoming(text);
|
||||
await this.waha.serverReboot(true);
|
||||
break;
|
||||
}
|
||||
case Command.QR: {
|
||||
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 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 text = this.l
|
||||
.key(TKey.APP_COMMANDS_LIST)
|
||||
.render({ commands: this.commands });
|
||||
await conversation.incoming(text);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new CommandIsNotImplementedError(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the command allowed for this session
|
||||
* Returns `null` if allowed or the message key to show as an error
|
||||
*/
|
||||
commandDisabledWarning(command: Command): TKey | null {
|
||||
if (!this.commands.server && command.startsWith(CommandServerPrefix)) {
|
||||
return TKey.APP_COMMANDS_SERVER_DISABLED;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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,259 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { MessageAttachment } from '@waha/apps/chatwoot/client/types';
|
||||
import {
|
||||
ChatWootInboxMessageConsumer,
|
||||
LookupAndCheckChatId,
|
||||
} from '@waha/apps/chatwoot/consumers/inbox/base';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { EngineHelper } from '@waha/apps/chatwoot/session';
|
||||
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import {
|
||||
ChatwootMessage,
|
||||
MessageMappingService,
|
||||
} from '@waha/apps/chatwoot/storage';
|
||||
import { MarkdownToWhatsApp } from '@waha/apps/chatwoot/text';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import {
|
||||
MessageFileRequest,
|
||||
MessageImageRequest,
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
} from '@waha/structures/chatting.dto';
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
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');
|
||||
|
||||
@Processor(QueueName.INBOX_MESSAGE_CREATED, { concurrency: JOB_CONCURRENCY })
|
||||
export class ChatWootInboxMessageCreatedConsumer extends ChatWootInboxMessageConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'ChatWootInboxMessageCreatedConsumer');
|
||||
}
|
||||
|
||||
ErrorHeaderKey(): TKey | null {
|
||||
return TKey.WHATSAPP_MESSAGE_SENDING_ERROR;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, body, job: Job) {
|
||||
const waha = container.WAHASelf();
|
||||
const session = new WAHASessionAPI(job.data.session, waha);
|
||||
const handler = new MessageHandler(
|
||||
container.MessageMappingService(),
|
||||
container.Logger(),
|
||||
session,
|
||||
container.ChatWootConfig(),
|
||||
container.Locale(),
|
||||
);
|
||||
return await handler.handle(body);
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageHandler {
|
||||
constructor(
|
||||
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') {
|
||||
this.logger.info(
|
||||
`Message content type not supported. Content type: ${message.content_type}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = MarkdownToWhatsApp(message.content);
|
||||
const results = [];
|
||||
let part = 1;
|
||||
const replyTo = await this.getReplyTo(message).catch((err) => {
|
||||
this.logger.error(`Error getting reply to message ID: ${err}`);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Send text
|
||||
const attachments = message.attachments || [];
|
||||
if (content && attachments.length !== 1) {
|
||||
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);
|
||||
part = await this.saveMapping(message, msg, part);
|
||||
this.logger.info(`Text message sent: ${msg.id}`);
|
||||
}
|
||||
|
||||
// Send files
|
||||
const captionTemplate = this.l.key(TKey.CW_TO_WA_MESSAGE_MEDIA_CAPTION);
|
||||
for (const file of attachments) {
|
||||
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);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async saveMapping(
|
||||
chatwootMessage: any,
|
||||
whatsappMessage: any,
|
||||
part: number,
|
||||
): Promise<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
async getReplyTo(message): Promise<string | undefined> {
|
||||
const message_id = message.content_attributes.in_reply_to;
|
||||
if (!message_id) {
|
||||
return;
|
||||
}
|
||||
const messages = await this.mappingService.getWhatsAppMessage({
|
||||
conversation_id: message.conversation.id,
|
||||
message_id: message_id,
|
||||
});
|
||||
if (!messages || messages.length == 0) {
|
||||
return;
|
||||
}
|
||||
const whatsapp = messages[0];
|
||||
return SerializeWhatsAppKey(whatsapp);
|
||||
}
|
||||
|
||||
private async sendTextMessage(
|
||||
chatId: string,
|
||||
content: string,
|
||||
replyTo: string,
|
||||
) {
|
||||
const request: MessageTextRequest = {
|
||||
session: '',
|
||||
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);
|
||||
await session.startTyping({ chatId: chatId, session: '' });
|
||||
await sleep(2000);
|
||||
await session.stopTyping({ chatId: chatId, session: '' });
|
||||
const msg = await session.sendText(request);
|
||||
return msg;
|
||||
}
|
||||
|
||||
private async sendFile(
|
||||
chatId: string,
|
||||
content: string,
|
||||
file: MessageAttachment,
|
||||
replyTo: string,
|
||||
) {
|
||||
const url = file.data_url;
|
||||
let filename = url.split('/').pop();
|
||||
if (filename) {
|
||||
filename = decodeURIComponent(filename);
|
||||
}
|
||||
const mimetype = mime.lookup(filename);
|
||||
const session = this.session;
|
||||
|
||||
switch (file.file_type) {
|
||||
case 'image':
|
||||
if (mimetype != 'image/jpeg' && mimetype != 'image/png') {
|
||||
// Send it as a file
|
||||
break;
|
||||
}
|
||||
const imageRequest: MessageImageRequest = {
|
||||
session: '',
|
||||
caption: content,
|
||||
chatId: chatId,
|
||||
reply_to: replyTo,
|
||||
file: {
|
||||
url: url,
|
||||
mimetype: mimetype,
|
||||
},
|
||||
};
|
||||
return session.sendImage(imageRequest);
|
||||
case 'video':
|
||||
if (mimetype != 'video/mp4') {
|
||||
break;
|
||||
}
|
||||
const videoRequest: MessageVideoRequest = {
|
||||
session: '',
|
||||
caption: content,
|
||||
chatId: chatId,
|
||||
reply_to: replyTo,
|
||||
file: {
|
||||
url: url,
|
||||
mimetype: mimetype,
|
||||
filename: filename,
|
||||
},
|
||||
convert: true,
|
||||
};
|
||||
return session.sendVideo(videoRequest);
|
||||
case 'audio':
|
||||
const voiceRequest: MessageVoiceRequest = {
|
||||
session: '',
|
||||
chatId: chatId,
|
||||
file: {
|
||||
url: url,
|
||||
mimetype: mimetype,
|
||||
filename: filename,
|
||||
},
|
||||
convert: true,
|
||||
};
|
||||
return session.sendVoice(voiceRequest);
|
||||
}
|
||||
// Fallback and send as file (attachment)
|
||||
const fileRequest: MessageFileRequest = {
|
||||
session: '',
|
||||
caption: content,
|
||||
chatId: chatId,
|
||||
reply_to: replyTo,
|
||||
file: {
|
||||
filename: filename,
|
||||
url: url,
|
||||
mimetype: mimetype,
|
||||
},
|
||||
};
|
||||
return session.sendFile(fileRequest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { SerializeWhatsAppKey } from '@waha/apps/chatwoot/client/ids';
|
||||
import {
|
||||
ChatWootInboxMessageConsumer,
|
||||
LookupAndCheckChatId,
|
||||
} from '@waha/apps/chatwoot/consumers/inbox/base';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/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 {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'ChatWootInboxMessageDeletedConsumer');
|
||||
}
|
||||
|
||||
ErrorHeaderKey(): TKey | null {
|
||||
return TKey.WHATSAPP_MESSAGE_REMOVING_ERROR;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, body, job: Job) {
|
||||
const waha = container.WAHASelf();
|
||||
const session = new WAHASessionAPI(job.data.session, waha);
|
||||
const handler = new MessageDeletedHandler(
|
||||
container.MessageMappingService(),
|
||||
container.Logger(),
|
||||
session,
|
||||
);
|
||||
return await handler.handle(body);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageDeletedHandler {
|
||||
constructor(
|
||||
private mappingService: MessageMappingService,
|
||||
private logger: ILogger,
|
||||
private session: WAHASessionAPI,
|
||||
) {}
|
||||
|
||||
async handle(body: any) {
|
||||
const chatId = await LookupAndCheckChatId(this.session, body);
|
||||
const messages = await this.mappingService.getWhatsAppMessage({
|
||||
conversation_id: body.conversation.id,
|
||||
message_id: body.id,
|
||||
});
|
||||
if (!messages || messages.length == 0) {
|
||||
throw Error(
|
||||
`No WhatsApp message found for Chatwoot message '${body.id}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const session = this.session;
|
||||
for (const whatsapp of messages) {
|
||||
this.logger.debug(
|
||||
`Deleting message '${whatsapp.message_id}' from '${whatsapp.chat_id}'`,
|
||||
);
|
||||
const messageId = SerializeWhatsAppKey(whatsapp);
|
||||
await session.deleteMessage(chatId, messageId);
|
||||
this.logger.info(
|
||||
`Message '${whatsapp.message_id}' deleted from '${whatsapp.chat_id}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { ChatWootInboxMessageConsumer } from '@waha/apps/chatwoot/consumers/inbox/base';
|
||||
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 { 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 { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
@Processor(QueueName.INBOX_MESSAGE_UPDATED, { concurrency: JOB_CONCURRENCY })
|
||||
export class ChatWootInboxMessageUpdatedConsumer extends ChatWootInboxMessageConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'ChatWootInboxMessageUpdatedConsumer');
|
||||
}
|
||||
|
||||
ErrorHeaderKey(): TKey | null {
|
||||
return TKey.WHATSAPP_MESSAGE_SENDING_ERROR;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, body, job: Job) {
|
||||
const waha = container.WAHASelf();
|
||||
const session = new WAHASessionAPI(job.data.session, waha);
|
||||
const handler = new MessageHandler(
|
||||
container.MessageMappingService(),
|
||||
container.Logger(),
|
||||
session,
|
||||
container.ChatWootConfig(),
|
||||
container.Locale(),
|
||||
);
|
||||
return await handler.handle(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function WhatsAppChatIdKey(app: string, chatId: string) {
|
||||
return `chatwoot.${app}.whatsapp.chat-id-${chatId}`;
|
||||
}
|
||||
|
||||
export function ChatWootConversationKey(app: string, conversationId: string) {
|
||||
return `chatwoot.${app}.chatwoot.conversation-id-${conversationId}`;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
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
|
||||
* Contains common logic for all scheduled consumers
|
||||
*/
|
||||
export abstract class ChatWootScheduledConsumer 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): Promise<any>;
|
||||
|
||||
/**
|
||||
* Process the job
|
||||
* This method is called by the queue processor
|
||||
*/
|
||||
async processJob(job: Job<ScheduledData, any, string>): Promise<any> {
|
||||
return this.ProcessAndReportStatus(job);
|
||||
}
|
||||
|
||||
private async ProcessAndReportStatus(job: Job) {
|
||||
try {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const result = await this.Process(container, job);
|
||||
await this.ReportErrorRecovered(job);
|
||||
return result;
|
||||
} catch (err) {
|
||||
await this.ReportErrorForJob(job, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
|
||||
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 = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportSucceeded(conversation, MessageType.INCOMING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { 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';
|
||||
import { VERSION, WAHAVersion } from '@waha/version';
|
||||
import axios from 'axios';
|
||||
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
|
||||
* Periodically checks for new versions and notifies if updates are available
|
||||
*/
|
||||
@Processor(QueueName.SCHEDULED_CHECK_VERSION, { concurrency: JOB_CONCURRENCY })
|
||||
export class CheckVersionConsumer extends ChatWootScheduledConsumer {
|
||||
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
|
||||
super(manager, log, rmutex, CheckVersionConsumer.name);
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey {
|
||||
return TKey.JOB_SCHEDULED_ERROR_HEADER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the version check job
|
||||
* Checks for new versions and notifies if updates are available
|
||||
*/
|
||||
protected async Process(container: DIContainer, job: Job): Promise<any> {
|
||||
await this.CheckWAHACoreVersion(container);
|
||||
await this.CheckNewVersionAvailable(container);
|
||||
}
|
||||
|
||||
private async CheckWAHACoreVersion(container: DIContainer) {
|
||||
const logger = container.Logger();
|
||||
const locale = container.Locale();
|
||||
const conversation = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
// If using the CORE version, send a message encouraging support
|
||||
if (VERSION.tier !== WAHAVersion.CORE) {
|
||||
logger.info('WAHA is not using the CORE version');
|
||||
return;
|
||||
}
|
||||
logger.info('WAHA is using the CORE version');
|
||||
const supportMessage = locale.key(TKey.WAHA_CORE_VERSION_USED).render({
|
||||
supportUrl: SUPPORT_US_URL,
|
||||
});
|
||||
await conversation.incoming(supportMessage);
|
||||
}
|
||||
|
||||
private async CheckNewVersionAvailable(container: DIContainer) {
|
||||
const logger = container.Logger();
|
||||
logger.info('Processing version check job');
|
||||
const currentVersion = VERSION.version;
|
||||
logger.info(`Current WAHA version: ${currentVersion}`);
|
||||
const latestVersion = await this.fetchLatestVersion(logger);
|
||||
const isNewVersionAvailable = currentVersion !== latestVersion;
|
||||
if (!isNewVersionAvailable) {
|
||||
logger.info('WAHA is up to date');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`New version available: ${latestVersion} (current: ${currentVersion})`,
|
||||
);
|
||||
|
||||
// Send a message to Chatwoot conversation about a new version
|
||||
const locale = container.Locale();
|
||||
const message = locale.key(TKey.WAHA_NEW_VERSION_AVAILABLE).render({
|
||||
currentVersion: currentVersion,
|
||||
newVersion: latestVersion,
|
||||
changelogUrl: CHANGELOG_URL,
|
||||
});
|
||||
const conversation = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
await conversation.incoming(message);
|
||||
}
|
||||
|
||||
private async fetchLatestVersion(logger: ILogger): Promise<string> {
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/devlikeapro/waha/releases/latest',
|
||||
);
|
||||
const latestVersion = response.data.name;
|
||||
logger.info(`Latest WAHA version: ${latestVersion}`);
|
||||
return latestVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
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 { 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
|
||||
* Handles periodic cleanup of old messages and related data
|
||||
*/
|
||||
@Processor(QueueName.SCHEDULED_MESSAGE_CLEANUP, {
|
||||
concurrency: JOB_CONCURRENCY,
|
||||
})
|
||||
export class MessageCleanupConsumer extends ChatWootScheduledConsumer {
|
||||
REMOVE_AFTER_DAYS = 14;
|
||||
|
||||
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
|
||||
super(manager, log, rmutex, MessageCleanupConsumer.name);
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey {
|
||||
return TKey.JOB_SCHEDULED_ERROR_HEADER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the message cleanup job
|
||||
* Performs cleanup of old messages and related data
|
||||
*/
|
||||
protected async Process(container: DIContainer, job: Job): Promise<any> {
|
||||
const logger = container.Logger();
|
||||
logger.info('Processing message cleanup job');
|
||||
const removeAfter = new Date(
|
||||
Date.now() - this.REMOVE_AFTER_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
logger.info(`Removing mapping for messages older than ${removeAfter}`);
|
||||
const removed = await container
|
||||
.MessageMappingService()
|
||||
.cleanup(removeAfter);
|
||||
logger.info(`Removed ${removed} mappings for messages`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { WAHAWebhook } from '@waha/structures/webhooks.dto';
|
||||
|
||||
export interface InboxData {
|
||||
session: string;
|
||||
app: string;
|
||||
body: any;
|
||||
}
|
||||
|
||||
export interface EventData {
|
||||
app: string;
|
||||
event: WAHAWebhook;
|
||||
}
|
||||
|
||||
export interface ScheduledData {
|
||||
app: string;
|
||||
session: string;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
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';
|
||||
import { AppConsumer } from '@waha/apps/app_sdk/AppConsumer';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { JobLoggerWrapper } from '@waha/apps/app_sdk/JobLoggerWrapper';
|
||||
import { HasBeenRetried } from '@waha/apps/app_sdk/JobUtils';
|
||||
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||
import { MessageType, SendAttachment } from '@waha/apps/chatwoot/client/types';
|
||||
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 } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { WAHASelf, WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import {
|
||||
AppRepository,
|
||||
ChatwootMessage,
|
||||
MessageMappingService,
|
||||
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 { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { isJidBroadcast } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
|
||||
|
||||
export function ListenEventsForChatWoot() {
|
||||
return [
|
||||
WAHAEvents.MESSAGE_ANY,
|
||||
WAHAEvents.MESSAGE_REACTION,
|
||||
WAHAEvents.MESSAGE_EDITED,
|
||||
WAHAEvents.MESSAGE_REVOKED,
|
||||
WAHAEvents.SESSION_STATUS,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for ChatWoot WAHA consumers
|
||||
* Contains common logic for all WAHA consumers
|
||||
*/
|
||||
export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
|
||||
protected appRepository: AppRepository;
|
||||
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
protected readonly consumerName: string,
|
||||
) {
|
||||
super('ChatWoot', consumerName, log, rmutex);
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey | null {
|
||||
return TKey.WHATSAPP_MESSAGE_RECEIVING_ERROR;
|
||||
}
|
||||
|
||||
private async ProcessAndReportErrors(job) {
|
||||
const errorReportInfo = new MessageReportInfo();
|
||||
try {
|
||||
const result = await this.Process(job, errorReportInfo);
|
||||
await this.ReportErrorRecovered(errorReportInfo, job);
|
||||
return result;
|
||||
} catch (err) {
|
||||
await this.ReportErrorForMessage(errorReportInfo, job, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
abstract GetChatId(event: any): string;
|
||||
|
||||
abstract Process(
|
||||
job: Job<EventData, any, WAHAEvents>,
|
||||
messageInfo: IMessageInfo,
|
||||
): Promise<any>;
|
||||
|
||||
async processJob(job: Job<EventData, any, WAHAEvents>): Promise<any> {
|
||||
const event: WAHAWebhookMessageRevoked = job.data.event as any;
|
||||
const key = WhatsAppChatIdKey(job.data.app, this.GetChatId(event));
|
||||
return await this.withMutex(job, key, () =>
|
||||
this.ProcessAndReportErrors(job),
|
||||
);
|
||||
}
|
||||
|
||||
protected async ReportErrorForMessage(
|
||||
info: MessageReportInfo,
|
||||
job: Job,
|
||||
err: any,
|
||||
) {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
let conversation: Conversation;
|
||||
if (info.conversationId) {
|
||||
conversation = container
|
||||
.ContactConversationService()
|
||||
.ConversationById(info.conversationId);
|
||||
} else {
|
||||
conversation = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
}
|
||||
|
||||
const header: string = this.ErrorHeaderKey()
|
||||
? container.Locale().key(this.ErrorHeaderKey()).render()
|
||||
: err.message || `${err}`;
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportError(
|
||||
conversation,
|
||||
header,
|
||||
info.type || MessageType.INCOMING,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a job as recovered after retries
|
||||
* This method will only send a report if the job has been retried (not on its first attempt)
|
||||
*/
|
||||
protected async ReportErrorRecovered(info: MessageReportInfo, job: Job) {
|
||||
if (!HasBeenRetried(job)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
let conversation: Conversation;
|
||||
if (info.conversationId) {
|
||||
conversation = container
|
||||
.ContactConversationService()
|
||||
.ConversationById(info.conversationId);
|
||||
} else {
|
||||
conversation = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
}
|
||||
|
||||
const reporter = container.ChatWootErrorReporter(job);
|
||||
await reporter.ReportSucceeded(
|
||||
conversation,
|
||||
info.type || MessageType.INCOMING,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IMessageInfo {
|
||||
onConversationId(id: number): void;
|
||||
|
||||
onMessageType(type: MessageType): void;
|
||||
}
|
||||
|
||||
class MessageReportInfo implements IMessageInfo {
|
||||
public conversationId: number | null = null;
|
||||
public type: MessageType | null = null;
|
||||
|
||||
onConversationId(id: number) {
|
||||
this.conversationId = id;
|
||||
}
|
||||
|
||||
onMessageType(type: MessageType) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
protected info: IMessageInfo,
|
||||
protected session: WAHASessionAPI,
|
||||
protected l: Locale,
|
||||
protected waha: WAHASelf,
|
||||
) {}
|
||||
|
||||
protected abstract getMessage(
|
||||
payload: Payload,
|
||||
): Promise<ChatWootMessagePartial>;
|
||||
|
||||
abstract getReplyToWhatsAppID(payload: Payload): string | undefined;
|
||||
|
||||
async handle(data: WAHAWebhook<Payload>) {
|
||||
const payload = data.payload;
|
||||
// Find the type as soon as possible for error reporting
|
||||
const type = payload.fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
|
||||
this.info.onMessageType(type);
|
||||
|
||||
// Check if we have that message already in ChatWoot to avoid duplication
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const contactInfo = WhatsAppContactInfo(this.session, payload.from, 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(
|
||||
`Created message as '${message.message_type}' from WhatsApp: ${response.id}`,
|
||||
);
|
||||
await this.saveMapping(response, payload);
|
||||
return message;
|
||||
}
|
||||
|
||||
private async saveMapping(
|
||||
chatwootMessage: generic_id & message,
|
||||
whatsappMessage: WAMessageBase,
|
||||
) {
|
||||
const chatwoot: Omit<ChatwootMessage, 'id'> = {
|
||||
timestamp: new Date(chatwootMessage.created_at * 1000),
|
||||
conversation_id: chatwootMessage.conversation_id,
|
||||
message_id: chatwootMessage.id,
|
||||
};
|
||||
const key = parseMessageIdSerialized(whatsappMessage.id);
|
||||
const whatsapp: WhatsAppMessage = {
|
||||
timestamp: new Date(whatsappMessage.timestamp * 1000),
|
||||
chat_id: toCusFormat(key.remoteJid),
|
||||
message_id: key.id,
|
||||
from_me: key.fromMe,
|
||||
participant: null,
|
||||
};
|
||||
await this.mappingService.map(chatwoot, whatsapp, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
private async buildChatWootMessage(
|
||||
payload: Payload,
|
||||
): Promise<conversation_message_create> {
|
||||
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 });
|
||||
}
|
||||
|
||||
const chatId = payload.from;
|
||||
|
||||
// Add participant name to group messages
|
||||
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);
|
||||
const name = contact?.name || contact?.pushName || contact?.pushname;
|
||||
if (name) {
|
||||
participant = `${name} (${participant})`;
|
||||
}
|
||||
content = this.l.key(TKey.WHATSAPP_GROUP_MESSAGE).render({
|
||||
text: content,
|
||||
participant: participant,
|
||||
});
|
||||
}
|
||||
const replyTo = await this.getReplyToChatWootMessageID(payload).catch(
|
||||
(err) => {
|
||||
this.logger.error(`Error getting reply to message ID: ${err}`);
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const type = payload.fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
|
||||
return {
|
||||
content: content,
|
||||
message_type: type,
|
||||
private: message.private ?? payload.fromMe,
|
||||
attachments: message.attachments as any,
|
||||
content_attributes: {
|
||||
in_reply_to: replyTo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getReplyToChatWootMessageID(
|
||||
payload: Payload,
|
||||
): Promise<number | undefined> {
|
||||
const replyToWhatsAppID = this.getReplyToWhatsAppID(payload);
|
||||
if (!replyToWhatsAppID) {
|
||||
return;
|
||||
}
|
||||
const chatwoot = await this.mappingService.getChatWootMessage({
|
||||
chat_id: payload.from,
|
||||
message_id: replyToWhatsAppID,
|
||||
});
|
||||
return chatwoot.message_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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 { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/text';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { WAHAEvents } from '@waha/structures/enums.dto';
|
||||
import { WAMessage } from '@waha/structures/responses.dto';
|
||||
import { WAHAWebhookMessageAny } from '@waha/structures/webhooks.dto';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { JobLink } from '@waha/apps/app_sdk/JobUtils';
|
||||
import { proto } from '@adiwajshing/baileys';
|
||||
import * as lodash from 'lodash';
|
||||
import { parseVCardV3, SimpleVCardInfo } from '@waha/core/vcard';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime-types');
|
||||
|
||||
@Processor(QueueName.WAHA_MESSAGE_ANY, { concurrency: JOB_CONCURRENCY })
|
||||
export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'WAHAMessageAnyConsumer');
|
||||
}
|
||||
|
||||
GetChatId(event: WAHAWebhookMessageAny): string {
|
||||
return event.payload.from;
|
||||
}
|
||||
|
||||
async Process(
|
||||
job: Job<EventData, any, WAHAEvents>,
|
||||
info: IMessageInfo,
|
||||
): Promise<any> {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
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(),
|
||||
info,
|
||||
session,
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
);
|
||||
return await handler.handle(event);
|
||||
}
|
||||
}
|
||||
|
||||
function isEmptyString(content: string) {
|
||||
if (!content) {
|
||||
return true;
|
||||
}
|
||||
return content == '' || content == '\n';
|
||||
}
|
||||
|
||||
class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
|
||||
protected async getMessage(
|
||||
payload: WAMessage,
|
||||
): Promise<ChatWootMessagePartial> {
|
||||
const protoMessage = this.getProtoMessage(payload);
|
||||
let msg = await this.getTextMessage(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
msg = this.getLocationMessage(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
msg = this.getShareContactMessage(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
return this.getUnsupportedMessage();
|
||||
}
|
||||
|
||||
protected async getTextMessage(
|
||||
payload: WAMessage,
|
||||
_: proto.Message | null,
|
||||
): Promise<ChatWootMessagePartial | null> {
|
||||
const attachments = await this.getAttachments(payload);
|
||||
let content = this.l
|
||||
.key(TKey.WA_TO_CW_MESSAGE)
|
||||
.render({ payload: payload });
|
||||
if (isEmptyString(content) && attachments.length == 0) {
|
||||
return null;
|
||||
}
|
||||
if (isEmptyString(content)) {
|
||||
content = null;
|
||||
}
|
||||
return {
|
||||
content: WhatsappToMarkdown(content),
|
||||
attachments: attachments,
|
||||
private: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getLocationMessage(
|
||||
payload: WAMessage,
|
||||
message: proto.Message | null,
|
||||
): ChatWootMessagePartial | null {
|
||||
const hasLocation = !lodash.isEmpty(message?.locationMessage);
|
||||
const hasLiveLocation = !lodash.isEmpty(message?.liveLocationMessage);
|
||||
if (!hasLocation && !hasLiveLocation) {
|
||||
return null;
|
||||
}
|
||||
const location = this.l.key(TKey.WA_TO_CW_MESSAGE_LOCATION).r({
|
||||
payload,
|
||||
message,
|
||||
});
|
||||
if (isEmptyString(location)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
content: location,
|
||||
attachments: [],
|
||||
private: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getShareContactMessage(
|
||||
_payload: WAMessage,
|
||||
message: proto.Message | null,
|
||||
): ChatWootMessagePartial | null {
|
||||
let vcards: string[] = [];
|
||||
|
||||
if (!lodash.isEmpty(message?.contactsArrayMessage?.contacts)) {
|
||||
vcards = message!.contactsArrayMessage!.contacts.map((c) => c.vcard);
|
||||
} else if (!lodash.isEmpty(message?.contactMessage?.vcard)) {
|
||||
vcards = [message!.contactMessage!.vcard];
|
||||
}
|
||||
if (vcards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const attachments: SendAttachment[] = vcards.map((v, i) => ({
|
||||
content: Buffer.from(v, 'utf8').toString('base64'),
|
||||
encoding: 'base64',
|
||||
filename: `vcard-${i + 1}.vcf`,
|
||||
}));
|
||||
let contacts: SimpleVCardInfo[] = [];
|
||||
try {
|
||||
contacts = vcards.map(parseVCardV3);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error parsing some vcards: vcards=${vcards}, err=${err}`,
|
||||
);
|
||||
}
|
||||
const msg = this.l.key(TKey.WA_TO_CW_MESSAGE_CONTACTS).r({ contacts });
|
||||
if (contacts.length === 0 && attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
content: msg,
|
||||
attachments: attachments,
|
||||
private: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getUnsupportedMessage(): ChatWootMessagePartial {
|
||||
const unsupported = this.l
|
||||
.key(TKey.WA_TO_CW_MESSAGE_UNSUPPORTED)
|
||||
.render({ details: JobLink(this.job) });
|
||||
return {
|
||||
content: unsupported,
|
||||
attachments: [],
|
||||
private: true,
|
||||
};
|
||||
}
|
||||
|
||||
private getProtoMessage(payload: WAMessage): proto.Message | null {
|
||||
// GOWS
|
||||
if (payload._data.Message) {
|
||||
return payload._data.Message;
|
||||
}
|
||||
// NOWEB
|
||||
if (payload._data.message) {
|
||||
return payload._data.message;
|
||||
}
|
||||
// WEBJS - not available
|
||||
return null;
|
||||
}
|
||||
|
||||
getReplyToWhatsAppID(payload: WAMessage): string {
|
||||
const replyTo = payload.replyTo;
|
||||
if (!replyTo) {
|
||||
return undefined;
|
||||
}
|
||||
const key = parseMessageIdSerialized(replyTo.id, true);
|
||||
return key.id;
|
||||
}
|
||||
|
||||
async getAttachments(payload: WAMessage): Promise<SendAttachment[]> {
|
||||
//
|
||||
// WAHA
|
||||
//
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
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,
|
||||
} from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
import { MessageBaseHandler } from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
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';
|
||||
import {
|
||||
WAHAWebhookMessageEdited,
|
||||
WAMessageEditedBody,
|
||||
} from '@waha/structures/webhooks.dto';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { SendAttachment } from '../../client/types';
|
||||
import { WAHASessionAPI } from '../../session/WAHASelf';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { WAMessage } from '@waha/structures/responses.dto';
|
||||
|
||||
@Processor(QueueName.WAHA_MESSAGE_EDITED, { concurrency: JOB_CONCURRENCY })
|
||||
export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'WAHAMessageEditedConsumer');
|
||||
}
|
||||
|
||||
GetChatId(event: WAHAWebhookMessageEdited): string {
|
||||
return event.payload.from;
|
||||
}
|
||||
|
||||
async Process(
|
||||
job: Job<EventData, any, WAHAEvents>,
|
||||
info: IMessageInfo,
|
||||
): Promise<any> {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
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(),
|
||||
info,
|
||||
session,
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
);
|
||||
return await handler.handle(event);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageEditedHandler extends MessageBaseHandler<WAMessageEditedBody> {
|
||||
protected async getMessage(
|
||||
payload: WAMessageEditedBody,
|
||||
): Promise<ChatWootMessagePartial> {
|
||||
const body = payload.body;
|
||||
const formatted = WhatsappToMarkdown(body);
|
||||
const content = this.l
|
||||
.key(TKey.MESSAGE_EDITED_IN_WHATSAPP)
|
||||
.render({ text: formatted });
|
||||
return {
|
||||
content: content,
|
||||
attachments: [],
|
||||
private: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getReplyToWhatsAppID(payload: WAMessageEditedBody): string {
|
||||
return payload.editedMessageId;
|
||||
}
|
||||
|
||||
async getAttachments(
|
||||
payload: WAMessageEditedBody,
|
||||
): Promise<SendAttachment[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
} 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 { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { WAHAEvents } from '@waha/structures/enums.dto';
|
||||
import { WAMessageReaction, WAReaction } from '@waha/structures/responses.dto';
|
||||
import { WAHAWebhookMessageReaction } from '@waha/structures/webhooks.dto';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
@Processor(QueueName.WAHA_MESSAGE_REACTION, { concurrency: JOB_CONCURRENCY })
|
||||
export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'WAHAMessageReactionConsumer');
|
||||
}
|
||||
|
||||
GetChatId(event: WAHAWebhookMessageReaction): string {
|
||||
return event.payload.from;
|
||||
}
|
||||
|
||||
async Process(
|
||||
job: Job<EventData, any, WAHAEvents>,
|
||||
info: IMessageInfo,
|
||||
): Promise<any> {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const event: WAHAWebhookMessageReaction = job.data.event as any;
|
||||
const session = new WAHASessionAPI(event.session, container.WAHASelf());
|
||||
const handler = new MessageReactionHandler(
|
||||
job,
|
||||
container.MessageMappingService(),
|
||||
container.ContactConversationService(),
|
||||
container.Logger(),
|
||||
info,
|
||||
session,
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
);
|
||||
return await handler.handle(event);
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageReactionHandler extends MessageBaseHandler<WAMessageReaction> {
|
||||
protected async getMessage(
|
||||
payload: WAMessageReaction,
|
||||
): Promise<ChatWootMessagePartial> {
|
||||
const reaction = payload.reaction as WAReaction;
|
||||
const emoji = reaction.text;
|
||||
let content: string;
|
||||
if (emoji) {
|
||||
content = this.l.key(TKey.WHATSAPP_REACTION_ADDED).render({
|
||||
emoji: emoji,
|
||||
});
|
||||
} else {
|
||||
content = this.l.key(TKey.WHATSAPP_REACTION_REMOVED).render();
|
||||
}
|
||||
return {
|
||||
content: content,
|
||||
attachments: [],
|
||||
private: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getReplyToWhatsAppID(payload: WAMessageReaction) {
|
||||
const reaction = payload.reaction as WAReaction;
|
||||
const messageId = reaction.messageId;
|
||||
const key = parseMessageIdSerialized(messageId, false);
|
||||
return key.id;
|
||||
}
|
||||
|
||||
async getAttachments(payload: WAMessageReaction): Promise<SendAttachment[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { conversation_message_create } from '@figuro/chatwoot-sdk';
|
||||
import { 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 { MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
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 { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import {
|
||||
ChatwootMessage,
|
||||
MessageMappingService,
|
||||
} from '@waha/apps/chatwoot/storage';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { toCusFormat } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { WAHAEvents } from '@waha/structures/enums.dto';
|
||||
import { WAHAWebhookMessageRevoked } from '@waha/structures/webhooks.dto';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
@Processor(QueueName.WAHA_MESSAGE_REVOKED, { concurrency: JOB_CONCURRENCY })
|
||||
export class WAHAMessageRevokedConsumer extends ChatWootWAHABaseConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'WAHAMessageRevokedConsumer');
|
||||
}
|
||||
|
||||
GetChatId(event: WAHAWebhookMessageRevoked): string {
|
||||
return event.payload.after.from;
|
||||
}
|
||||
|
||||
async Process(
|
||||
job: Job<EventData, any, WAHAEvents>,
|
||||
info: IMessageInfo,
|
||||
): Promise<any> {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const event: WAHAWebhookMessageRevoked = job.data.event as any;
|
||||
const handler = new MessageRevokedHandler(
|
||||
container.MessageMappingService(),
|
||||
container.ContactConversationService(),
|
||||
container.Logger(),
|
||||
container.Locale(),
|
||||
info,
|
||||
);
|
||||
return await handler.handle(event);
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageRevokedHandler {
|
||||
constructor(
|
||||
private mappingService: MessageMappingService,
|
||||
private repo: ContactConversationService,
|
||||
private logger: ILogger,
|
||||
private l: Locale,
|
||||
private info: IMessageInfo,
|
||||
) {}
|
||||
|
||||
async handle(event: WAHAWebhookMessageRevoked) {
|
||||
const payload = event.payload;
|
||||
const messageId = event.payload.revokedMessageId;
|
||||
const chatId = payload.after.from;
|
||||
const chatwoot = await this.mappingService.getChatWootMessage({
|
||||
chat_id: toCusFormat(chatId),
|
||||
message_id: messageId,
|
||||
});
|
||||
if (!chatwoot) {
|
||||
this.logger.warn('Message not found in mapping service');
|
||||
return;
|
||||
}
|
||||
const conversation = this.repo.ConversationById(chatwoot.conversation_id);
|
||||
const message = this.buildChatWootMessage(chatwoot, payload.after.fromMe);
|
||||
this.info.onConversationId(chatwoot.conversation_id);
|
||||
this.info.onMessageType(message.message_type as MessageType);
|
||||
await conversation.send(message);
|
||||
}
|
||||
|
||||
private buildChatWootMessage(
|
||||
chatwoot: ChatwootMessage,
|
||||
fromMe: boolean,
|
||||
): conversation_message_create {
|
||||
const content = this.l.key(TKey.MESSAGE_REMOVED_IN_WHATSAPP).render();
|
||||
const type = fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
|
||||
return {
|
||||
content: content,
|
||||
message_type: type,
|
||||
private: true,
|
||||
content_attributes: {
|
||||
in_reply_to: chatwoot.message_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { conversation_message_create } from '@figuro/chatwoot-sdk/dist/models/conversation_message_create';
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
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 { 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 { Locale } from '@waha/apps/chatwoot/i18n/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 { WAHAEvents, WAHASessionStatus } from '@waha/structures/enums.dto';
|
||||
import { WAHAWebhookSessionStatus } from '@waha/structures/webhooks.dto';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { sleep } from '@nestjs/terminus/dist/utils';
|
||||
import { waitUntil } from '@waha/utils/promiseTimeout';
|
||||
|
||||
@Processor(QueueName.WAHA_SESSION_STATUS, { concurrency: JOB_CONCURRENCY })
|
||||
export class WAHASessionStatusConsumer extends ChatWootWAHABaseConsumer {
|
||||
constructor(
|
||||
protected readonly manager: SessionManager,
|
||||
log: PinoLogger,
|
||||
rmutex: RMutexService,
|
||||
) {
|
||||
super(manager, log, rmutex, 'WAHASessionStatusConsumer');
|
||||
}
|
||||
|
||||
GetChatId(event: any): string {
|
||||
return 'session.status';
|
||||
}
|
||||
|
||||
async Process(
|
||||
job: Job<EventData, any, WAHAEvents>,
|
||||
info: IMessageInfo,
|
||||
): Promise<any> {
|
||||
const container = await this.DIContainer(job, job.data.app);
|
||||
const handler = new SessionStatusHandler(
|
||||
container.ContactConversationService(),
|
||||
container.Locale(),
|
||||
container.WAHASelf(),
|
||||
);
|
||||
return await handler.handle(job.data.event as any);
|
||||
}
|
||||
}
|
||||
|
||||
const RECOVER_TIME_MS = 10_000;
|
||||
const CHECK_STATUS_INTERVAL_MS = 2_000;
|
||||
|
||||
export class SessionStatusHandler {
|
||||
constructor(
|
||||
private repo: ContactConversationService,
|
||||
private l: Locale,
|
||||
private waha: WAHASelf,
|
||||
) {}
|
||||
|
||||
async handle(data: WAHAWebhookSessionStatus) {
|
||||
const payload = data.payload;
|
||||
let text = '';
|
||||
const current = payload.statuses?.at(-1);
|
||||
const previous = payload.statuses?.at(-2);
|
||||
const older = payload.statuses?.at(-3);
|
||||
switch (payload.status) {
|
||||
case WAHASessionStatus.STARTING:
|
||||
// WORKING => STARTING [=> WORKING]
|
||||
if (previous?.status == WAHASessionStatus.WORKING) {
|
||||
// if previous is WORKING - wait, give time to recover
|
||||
const recovered = await waitUntil(
|
||||
async () => {
|
||||
const response = await this.waha.get(data.session);
|
||||
return response.status == WAHASessionStatus.WORKING;
|
||||
},
|
||||
CHECK_STATUS_INTERVAL_MS,
|
||||
RECOVER_TIME_MS,
|
||||
);
|
||||
if (recovered) {
|
||||
// Current status is WORKING, no needs for an alert
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case WAHASessionStatus.WORKING:
|
||||
// WORKING => STARTING => WORKING
|
||||
if (
|
||||
previous?.status == WAHASessionStatus.STARTING &&
|
||||
older?.status == WAHASessionStatus.WORKING
|
||||
) {
|
||||
if (current?.timestamp - previous?.timestamp < RECOVER_TIME_MS) {
|
||||
// Session got recovered fast, no needs to notify about that
|
||||
return;
|
||||
}
|
||||
}
|
||||
text = this.l.key(TKey.APP_SESSION_STATUS_WORKING).r({
|
||||
name: data.me?.pushName || 'Unknown',
|
||||
id: data.me?.id || 'No phone number',
|
||||
});
|
||||
break;
|
||||
case WAHASessionStatus.STOPPED:
|
||||
case WAHASessionStatus.FAILED:
|
||||
text = this.l.key(TKey.APP_SESSION_STATUS_ERROR).r();
|
||||
text += '\n\n';
|
||||
text += this.l.key(TKey.APP_HELP_REMINDER).r();
|
||||
text += '\n\n';
|
||||
break;
|
||||
case WAHASessionStatus.SCAN_QR_CODE:
|
||||
text = this.l.key(TKey.APP_SESSION_SCAN_QR_CODE).r();
|
||||
}
|
||||
//
|
||||
// Report Status Change if required
|
||||
//
|
||||
const conversation = await this.repo.InboxNotifications();
|
||||
const emoji = SessionStatusEmoji(data.payload.status);
|
||||
const activity = this.l.key(TKey.APP_SESSION_STATUS_CHANGE).r({
|
||||
emoji: emoji,
|
||||
session: data.session,
|
||||
status: data.payload.status,
|
||||
});
|
||||
await conversation.activity(activity);
|
||||
|
||||
if (text) {
|
||||
const message: conversation_message_create = {
|
||||
content: text,
|
||||
message_type: MessageType.INCOMING as any,
|
||||
};
|
||||
await conversation.send(message);
|
||||
}
|
||||
|
||||
// Send QR to the inbox
|
||||
if (payload.status === WAHASessionStatus.SCAN_QR_CODE) {
|
||||
const buffer = await this.waha.qr(data.session);
|
||||
const message = AttachmentFromBuffer(buffer, 'qr.jpg');
|
||||
message.message_type = MessageType.INCOMING;
|
||||
return await conversation.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { public_contact_create_update_payload } from '@figuro/chatwoot-sdk';
|
||||
import { ContactInfo } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
/**
|
||||
* Inbox Notifications Contact
|
||||
*/
|
||||
export class InboxContactInfo implements ContactInfo {
|
||||
constructor(private l: Locale) {}
|
||||
|
||||
ChatId(): string {
|
||||
return INBOX_CONTACT_CHAT_ID;
|
||||
}
|
||||
|
||||
async AvatarUrl() {
|
||||
return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render();
|
||||
}
|
||||
|
||||
async Attributes(): Promise<any> {
|
||||
return {
|
||||
[AttributeKey.WA_CHAT_ID]: INBOX_CONTACT_CHAT_ID,
|
||||
};
|
||||
}
|
||||
|
||||
async PublicContactCreate(): Promise<public_contact_create_update_payload> {
|
||||
return {
|
||||
identifier: INBOX_CONTACT_CHAT_ID,
|
||||
name: this.l.key(TKey.APP_INBOX_CONTACT_NAME).render(),
|
||||
avatar_url: await this.AvatarUrl(),
|
||||
custom_attributes: await this.Attributes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
isJidGroup,
|
||||
isJidNewsletter,
|
||||
isJidStatusBroadcast,
|
||||
} from '@adiwajshing/baileys';
|
||||
import {
|
||||
isJidBroadcast,
|
||||
isLidUser,
|
||||
} from '@adiwajshing/baileys/lib/WABinary/jid-utils';
|
||||
import {
|
||||
public_contact_create_update_payload,
|
||||
public_contact_create_update_payload as Contact,
|
||||
} from '@figuro/chatwoot-sdk';
|
||||
import { ContactInfo } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
import { AttributeKey } from '@waha/apps/chatwoot/const';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { WAHASessionAPI } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import { Channel } from '@waha/structures/channels.dto';
|
||||
import { CacheAsync } from '@waha/utils/Cache';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
/**
|
||||
* Base WhatsApp contact info class
|
||||
*/
|
||||
abstract class ChatContactInfo implements ContactInfo {
|
||||
constructor(
|
||||
protected session: WAHASessionAPI,
|
||||
protected chatId: string,
|
||||
protected locale: Locale,
|
||||
) {}
|
||||
|
||||
ChatId(): string {
|
||||
return this.chatId;
|
||||
}
|
||||
|
||||
abstract AvatarUrl(): Promise<string | null>;
|
||||
|
||||
abstract Attributes(): Promise<any>;
|
||||
|
||||
abstract PublicContactCreate(): Promise<Contact>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular JID contact info
|
||||
*/
|
||||
class JidContactInfo extends ChatContactInfo {
|
||||
@CacheAsync()
|
||||
async AvatarUrl(): Promise<string> {
|
||||
return await this.session.getChatPicture(this.chatId);
|
||||
}
|
||||
|
||||
@CacheAsync()
|
||||
async fetchLid() {
|
||||
return await this.session.findLIDByPN(this.chatId);
|
||||
}
|
||||
|
||||
@CacheAsync()
|
||||
async Attributes() {
|
||||
const attributes = {
|
||||
[AttributeKey.WA_CHAT_ID]: this.chatId,
|
||||
[AttributeKey.WA_JID]: this.chatId,
|
||||
};
|
||||
const lid = await this.fetchLid();
|
||||
if (lid) {
|
||||
attributes[AttributeKey.WA_LID] = lid;
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
async PublicContactCreate(): Promise<Contact> {
|
||||
const contact: any = await this.session.getContact(this.chatId);
|
||||
const name =
|
||||
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
|
||||
const phoneNumber = this.chatId.split('@')[0];
|
||||
const phoneNumberE164 = '+' + phoneNumber;
|
||||
|
||||
const result: Contact = {
|
||||
name: name,
|
||||
custom_attributes: {
|
||||
[AttributeKey.WA_CHAT_ID]: this.chatId,
|
||||
[AttributeKey.WA_JID]: this.chatId,
|
||||
},
|
||||
phone_number: phoneNumberE164,
|
||||
};
|
||||
result.custom_attributes = await this.Attributes();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LID contact info
|
||||
*/
|
||||
class LidContactInfo extends ChatContactInfo {
|
||||
@CacheAsync()
|
||||
async jid() {
|
||||
const pn = await this.session.findPNByLid(this.chatId);
|
||||
return new JidContactInfo(this.session, pn, this.locale);
|
||||
}
|
||||
|
||||
async AvatarUrl(): Promise<string | null> {
|
||||
const jid = await this.jid();
|
||||
if (jid) {
|
||||
return await jid.AvatarUrl();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@CacheAsync()
|
||||
async Attributes() {
|
||||
const jid = await this.jid();
|
||||
let attributes = {};
|
||||
if (jid) {
|
||||
attributes = await jid.Attributes();
|
||||
}
|
||||
attributes[AttributeKey.WA_CHAT_ID] = this.chatId;
|
||||
attributes[AttributeKey.WA_LID] = this.chatId;
|
||||
return attributes;
|
||||
}
|
||||
|
||||
async PublicContactCreate(): Promise<Contact> {
|
||||
const jid = await this.jid();
|
||||
let result;
|
||||
if (jid) {
|
||||
result = await jid.PublicContactCreate();
|
||||
} else {
|
||||
result = {
|
||||
inbox_id: 0,
|
||||
identifier: this.chatId,
|
||||
name: this.chatId,
|
||||
};
|
||||
}
|
||||
result.custom_attributes = await this.Attributes();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group contact info
|
||||
*/
|
||||
class GroupContactInfo extends ChatContactInfo {
|
||||
@CacheAsync()
|
||||
async AvatarUrl(): Promise<string> {
|
||||
return await this.session.getChatPicture(this.chatId);
|
||||
}
|
||||
|
||||
async Attributes() {
|
||||
return {
|
||||
[AttributeKey.WA_CHAT_ID]: this.chatId,
|
||||
};
|
||||
}
|
||||
|
||||
async PublicContactCreate(): Promise<Contact> {
|
||||
let name = this.chatId;
|
||||
const group: any = await this.session?.getGroup(this.chatId);
|
||||
if (group) {
|
||||
name = group.subject || group.name || group.topic || name;
|
||||
const suffix = this.locale
|
||||
.key(TKey.WHATSAPP_CONTACT_GROUP_SUFFIX)
|
||||
.render();
|
||||
name = `${name} (${suffix})`;
|
||||
}
|
||||
|
||||
return {
|
||||
identifier: this.chatId,
|
||||
name: name,
|
||||
custom_attributes: await this.Attributes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel (newsletter) contact info
|
||||
*/
|
||||
class ChannelContactInfo extends ChatContactInfo {
|
||||
@CacheAsync()
|
||||
async AvatarUrl(): Promise<string> {
|
||||
return await this.session.getChatPicture(this.chatId);
|
||||
}
|
||||
|
||||
async Attributes() {
|
||||
return {
|
||||
[AttributeKey.WA_CHAT_ID]: this.chatId,
|
||||
};
|
||||
}
|
||||
|
||||
async PublicContactCreate(): Promise<Contact> {
|
||||
let name = this.chatId;
|
||||
const channel: Channel = await this.session.getChannel(this.chatId);
|
||||
if (channel) {
|
||||
name = channel.name || name;
|
||||
const suffix = this.locale
|
||||
.key(TKey.WHATSAPP_CONTACT_CHANNEL_SUFFIX)
|
||||
.render();
|
||||
name = `${name} (${suffix})`;
|
||||
}
|
||||
return {
|
||||
identifier: this.chatId,
|
||||
name: name,
|
||||
custom_attributes: await this.Attributes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status broadcast contact info
|
||||
*/
|
||||
class StatusContactInfo extends ChatContactInfo {
|
||||
@CacheAsync()
|
||||
async AvatarUrl(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async Attributes() {
|
||||
return {
|
||||
[AttributeKey.WA_CHAT_ID]: this.chatId,
|
||||
};
|
||||
}
|
||||
|
||||
async PublicContactCreate(): Promise<Contact> {
|
||||
const name = this.locale.key(TKey.WHATSAPP_CONTACT_STATUS_NAME).render();
|
||||
|
||||
return {
|
||||
identifier: this.chatId,
|
||||
name: `🟢 ${name}`,
|
||||
custom_attributes: await this.Attributes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BroadcastContactInfo extends ChatContactInfo {
|
||||
async Attributes() {
|
||||
return {
|
||||
[AttributeKey.WA_CHAT_ID]: this.chatId,
|
||||
};
|
||||
}
|
||||
|
||||
@CacheAsync()
|
||||
async AvatarUrl(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async PublicContactCreate(): Promise<Contact> {
|
||||
const name = this.locale
|
||||
.key(TKey.WHATSAPP_CONTACT_BROADCAST_SUFFIX)
|
||||
.render();
|
||||
|
||||
return {
|
||||
identifier: this.chatId,
|
||||
name: `${name} (${this.chatId})`,
|
||||
custom_attributes: await this.Attributes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to get the appropriate ContactInfo implementation
|
||||
* based on the chat ID type
|
||||
*/
|
||||
export function WhatsAppContactInfo(
|
||||
session: WAHASessionAPI,
|
||||
chatId: string,
|
||||
locale: Locale,
|
||||
): ContactInfo {
|
||||
if (isJidGroup(chatId)) {
|
||||
return new GroupContactInfo(session, chatId, locale);
|
||||
} else if (isJidNewsletter(chatId)) {
|
||||
return new ChannelContactInfo(session, chatId, locale);
|
||||
} else if (isJidStatusBroadcast(chatId)) {
|
||||
return new StatusContactInfo(session, chatId, locale);
|
||||
} else if (isJidBroadcast(chatId)) {
|
||||
return new BroadcastContactInfo(session, chatId, locale);
|
||||
} else if (isLidUser(chatId)) {
|
||||
return new LidContactInfo(session, chatId, locale);
|
||||
}
|
||||
return new JidContactInfo(session, chatId, locale);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import ChatwootClient from '@figuro/chatwoot-sdk';
|
||||
import * as lodash from 'lodash';
|
||||
import { AxiosLogging } from '@waha/apps/app_sdk/AxiosLogging';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { ContactAPI } from '@waha/apps/chatwoot/client/ContactAPI';
|
||||
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
|
||||
import { ConversationAPI } from '@waha/apps/chatwoot/client/ConversationAPI';
|
||||
import { CustomAttributesAPI } from '@waha/apps/chatwoot/client/CustomAttributesAPI';
|
||||
import { ChatWootInboxAPI } from '@waha/apps/chatwoot/client/interfaces';
|
||||
import {
|
||||
ChatWootAppConfig,
|
||||
ChatWootConfig,
|
||||
DEFAULT_LOCALE,
|
||||
LinkPreview,
|
||||
} from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { ChatWootErrorReporter } from '@waha/apps/chatwoot/error/ChatWootErrorReporter';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { WAHASelf } from '@waha/apps/chatwoot/session/WAHASelf';
|
||||
import {
|
||||
ChatwootMessageRepository,
|
||||
MessageMappingRepository,
|
||||
MessageMappingService,
|
||||
WhatsAppMessageRepository,
|
||||
} from '@waha/apps/chatwoot/storage';
|
||||
import { Job } from 'bullmq';
|
||||
import { Knex } from 'knex';
|
||||
import { i18n } from '@waha/apps/chatwoot/i18n';
|
||||
|
||||
/**
|
||||
* Dependency Injection Container for ChatWoot
|
||||
* Manages the creation and caching of various clients and repositories
|
||||
*/
|
||||
export class DIContainer {
|
||||
private accountAPI: ChatwootClient;
|
||||
private inboxAPI: ChatWootInboxAPI;
|
||||
private contactAPI: ContactAPI;
|
||||
private conversationAPI: ConversationAPI;
|
||||
private contactConversationService: ContactConversationService;
|
||||
private locale: Locale;
|
||||
private messageMappingService: MessageMappingService;
|
||||
private wahaSelf: WAHASelf;
|
||||
|
||||
/**
|
||||
* Creates a new DIContainer with the given configuration
|
||||
*/
|
||||
constructor(
|
||||
private readonly appPk: number,
|
||||
private readonly config: ChatWootAppConfig,
|
||||
private readonly logger: ILogger,
|
||||
private readonly knex: Knex,
|
||||
) {}
|
||||
|
||||
public Logger(): ILogger {
|
||||
return this.logger;
|
||||
}
|
||||
|
||||
private AppPk() {
|
||||
if (!this.appPk) {
|
||||
throw new Error('AppPk not set or 0');
|
||||
}
|
||||
return this.appPk;
|
||||
}
|
||||
|
||||
private Knex() {
|
||||
if (!this.knex) {
|
||||
throw new Error('Knex not set');
|
||||
}
|
||||
return this.knex;
|
||||
}
|
||||
|
||||
public Locale(): Locale {
|
||||
if (!this.locale) {
|
||||
this.locale = i18n.locale(this.config.locale || DEFAULT_LOCALE);
|
||||
this.locale = this.locale.override(this.ChatWootConfig().templates);
|
||||
}
|
||||
return this.locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the AccountAPI client
|
||||
* @returns ChatwootClient instance
|
||||
*/
|
||||
public AccountAPI(): ChatwootClient {
|
||||
if (!this.accountAPI) {
|
||||
this.accountAPI = new ChatwootClient({
|
||||
config: {
|
||||
basePath: this.config.url,
|
||||
with_credentials: true,
|
||||
credentials: 'include',
|
||||
token: this.config.accountToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.accountAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the InboxAPI client
|
||||
* @returns ChatWootInboxAPI instance
|
||||
*/
|
||||
public InboxAPI(): ChatWootInboxAPI {
|
||||
if (!this.inboxAPI) {
|
||||
const chatwootClientAPI = new ChatwootClient({
|
||||
config: {
|
||||
basePath: this.config.url,
|
||||
with_credentials: true,
|
||||
credentials: 'include',
|
||||
token: this.config.inboxIdentifier,
|
||||
},
|
||||
});
|
||||
this.inboxAPI = chatwootClientAPI.client as ChatWootInboxAPI;
|
||||
}
|
||||
return this.inboxAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ContactAPI
|
||||
* @returns ContactAPI instance
|
||||
*/
|
||||
private ContactAPI(): ContactAPI {
|
||||
if (!this.contactAPI) {
|
||||
this.contactAPI = new ContactAPI(
|
||||
this.config,
|
||||
this.AccountAPI(),
|
||||
this.InboxAPI(),
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
return this.contactAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ConversationAPI
|
||||
* @returns ConversationAPI instance
|
||||
*/
|
||||
private ConversationAPI(): ConversationAPI {
|
||||
if (!this.conversationAPI) {
|
||||
this.conversationAPI = new ConversationAPI(
|
||||
this.config,
|
||||
this.InboxAPI(),
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
return this.conversationAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ContactConversationService
|
||||
* @returns ContactConversationService instance
|
||||
*/
|
||||
public ContactConversationService(): ContactConversationService {
|
||||
if (!this.contactConversationService) {
|
||||
this.contactConversationService = new ContactConversationService(
|
||||
this.config,
|
||||
this.ContactAPI(),
|
||||
this.ConversationAPI(),
|
||||
this.AccountAPI(),
|
||||
this.logger,
|
||||
this.Locale(),
|
||||
);
|
||||
}
|
||||
return this.contactConversationService;
|
||||
}
|
||||
|
||||
private ChatwootMessageRepository(): ChatwootMessageRepository {
|
||||
return new ChatwootMessageRepository(this.Knex(), this.AppPk());
|
||||
}
|
||||
|
||||
private WhatsAppMessageRepository(): WhatsAppMessageRepository {
|
||||
return new WhatsAppMessageRepository(this.Knex(), this.AppPk());
|
||||
}
|
||||
|
||||
private MessageMappingRepository(): MessageMappingRepository {
|
||||
return new MessageMappingRepository(this.Knex(), this.AppPk());
|
||||
}
|
||||
|
||||
public MessageMappingService(): MessageMappingService {
|
||||
if (!this.messageMappingService) {
|
||||
this.messageMappingService = new MessageMappingService(
|
||||
this.Knex(),
|
||||
this.WhatsAppMessageRepository(),
|
||||
this.ChatwootMessageRepository(),
|
||||
this.MessageMappingRepository(),
|
||||
);
|
||||
}
|
||||
return this.messageMappingService;
|
||||
}
|
||||
|
||||
public ChatWootErrorReporter(job: Job): ChatWootErrorReporter {
|
||||
return new ChatWootErrorReporter(this.Logger(), job, this.Locale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WAHASelf instance
|
||||
* @returns WAHASelf instance
|
||||
*/
|
||||
public WAHASelf(): WAHASelf {
|
||||
if (!this.wahaSelf) {
|
||||
this.wahaSelf = new WAHASelf();
|
||||
const logging = new AxiosLogging(this.Logger());
|
||||
logging.applyTo(this.wahaSelf.client);
|
||||
}
|
||||
return this.wahaSelf;
|
||||
}
|
||||
|
||||
public CustomAttributesAPI() {
|
||||
return new CustomAttributesAPI(this.config, this.AccountAPI());
|
||||
}
|
||||
|
||||
public ChatWootConfig(): ChatWootConfig {
|
||||
const defaults: ChatWootConfig = {
|
||||
templates: {},
|
||||
linkPreview: LinkPreview.OFF,
|
||||
commands: {
|
||||
server: true,
|
||||
},
|
||||
};
|
||||
return lodash.defaults({}, this.config, defaults);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ChatWootAPIConfig } from '@waha/apps/chatwoot/client/interfaces';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDynamicObject } from '@waha/nestjs/validation/IsDynamicObject';
|
||||
|
||||
export const DEFAULT_LOCALE = 'en-US';
|
||||
|
||||
export class ChatWootCommandsConfig {
|
||||
@IsBoolean()
|
||||
server: boolean = true;
|
||||
}
|
||||
|
||||
export enum LinkPreview {
|
||||
OFF = 'OFF',
|
||||
LQ = 'LG',
|
||||
HQ = 'HG',
|
||||
}
|
||||
|
||||
export interface ChatWootConfig {
|
||||
templates: Record<string, string>;
|
||||
linkPreview: LinkPreview;
|
||||
commands: ChatWootCommandsConfig;
|
||||
}
|
||||
|
||||
export class ChatWootAppConfig implements ChatWootAPIConfig {
|
||||
@IsString()
|
||||
url: string;
|
||||
|
||||
@IsNumber()
|
||||
accountId: number;
|
||||
|
||||
@IsString()
|
||||
accountToken: string;
|
||||
|
||||
@IsNumber()
|
||||
inboxId: number;
|
||||
|
||||
@IsString()
|
||||
inboxIdentifier: string;
|
||||
|
||||
@IsEnum(LinkPreview)
|
||||
@IsOptional()
|
||||
linkPreview?: LinkPreview = LinkPreview.OFF;
|
||||
|
||||
@IsString()
|
||||
locale: string = DEFAULT_LOCALE;
|
||||
|
||||
@IsOptional()
|
||||
@IsDynamicObject()
|
||||
templates?: Record<string, string>;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ChatWootCommandsConfig)
|
||||
commands?: ChatWootCommandsConfig;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { conversation_message_create } from '@figuro/chatwoot-sdk/dist/models/conversation_message_create';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import {
|
||||
JobLink,
|
||||
NextAttemptDelayInWholeSeconds,
|
||||
} from '@waha/apps/app_sdk/JobUtils';
|
||||
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||
import { MessageType } from '@waha/apps/chatwoot/client/types';
|
||||
import { ErrorRenderer } from '@waha/apps/chatwoot/error/ErrorRenderer';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { Job } from 'bullmq';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
export class ChatWootErrorReporter {
|
||||
private errorRenderer: ErrorRenderer = new ErrorRenderer();
|
||||
|
||||
constructor(
|
||||
private logger: ILogger,
|
||||
private job: Job,
|
||||
private l: Locale,
|
||||
) {}
|
||||
|
||||
async ReportError(
|
||||
conversation: Conversation,
|
||||
header: string,
|
||||
type: MessageType,
|
||||
error: any,
|
||||
replyTo?: number,
|
||||
) {
|
||||
const errorText = this.errorRenderer.renderError(error);
|
||||
this.logger.error(errorText);
|
||||
const template = this.l.key(TKey.JOB_REPORT_ERROR);
|
||||
const nextDelay = NextAttemptDelayInWholeSeconds(this.job);
|
||||
const attempts = {
|
||||
current: this.job.attemptsMade + 1,
|
||||
max: this.job.opts?.attempts || 1,
|
||||
nextDelay: nextDelay,
|
||||
};
|
||||
const content = template.render({
|
||||
header: header,
|
||||
error: nextDelay != null ? null : errorText,
|
||||
details: JobLink(this.job),
|
||||
attempts: attempts,
|
||||
});
|
||||
const request: conversation_message_create = {
|
||||
content: content,
|
||||
message_type: type as any,
|
||||
private: true, // Always private note
|
||||
};
|
||||
if (replyTo) {
|
||||
request.content_attributes = {
|
||||
in_reply_to: replyTo,
|
||||
};
|
||||
}
|
||||
await conversation.send(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a job as recovered after retries.
|
||||
* This method will only send a report if the job has been retried (not on its first attempt).
|
||||
*
|
||||
* @param conversation The conversation to send the report to
|
||||
* @param type The message type
|
||||
* @param replyTo Optional message ID to reply to
|
||||
* @returns Promise that resolves when the report is sent, or void if no report is sent
|
||||
*/
|
||||
async ReportSucceeded(
|
||||
conversation: Conversation,
|
||||
type: MessageType,
|
||||
replyTo?: number,
|
||||
): Promise<void> {
|
||||
const jobUrl = `http://localhost:3000/jobs/queue/${encodeURIComponent(
|
||||
this.job.queueName,
|
||||
)}/${this.job.id}`;
|
||||
const template = this.l.key(TKey.JOB_REPORT_SUCCEEDED);
|
||||
const attempts = {
|
||||
current: this.job.attemptsMade + 1,
|
||||
max: this.job.opts?.attempts || 1,
|
||||
};
|
||||
|
||||
const content = template.render({
|
||||
details: {
|
||||
text: `${this.job.queueName} => ${this.job.id}`,
|
||||
url: jobUrl,
|
||||
},
|
||||
attempts: attempts,
|
||||
});
|
||||
|
||||
const request: conversation_message_create = {
|
||||
content: content,
|
||||
message_type: type as any,
|
||||
private: true, // Always private note
|
||||
};
|
||||
|
||||
if (replyTo) {
|
||||
request.content_attributes = {
|
||||
in_reply_to: replyTo,
|
||||
};
|
||||
}
|
||||
|
||||
await conversation.send(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ApiError } from '@figuro/chatwoot-sdk/dist/core/ApiError';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
/**
|
||||
* Class responsible for rendering error information based on error type
|
||||
*/
|
||||
export class ErrorRenderer {
|
||||
/**
|
||||
* Renders error information based on error type
|
||||
* @param error The error to render
|
||||
* @returns Formatted error text
|
||||
*/
|
||||
public renderError(error: any): string {
|
||||
// Handle Axios errors
|
||||
if (error.isAxiosError) {
|
||||
return this.renderAxiosError(error as AxiosError);
|
||||
}
|
||||
|
||||
// Handle Chatwoot SDK ApiError
|
||||
if (error instanceof ApiError) {
|
||||
return this.renderApiError(error);
|
||||
}
|
||||
|
||||
// For all other errors
|
||||
return this.renderGenericError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders Axios error information
|
||||
* @param error The Axios error to render
|
||||
* @returns Formatted error text
|
||||
*/
|
||||
private renderAxiosError(error: AxiosError): string {
|
||||
let errorText = `Axios Error: ${error.message}`;
|
||||
if (!error.response?.data) {
|
||||
return errorText;
|
||||
}
|
||||
|
||||
// Add response data if it exists and is JSON
|
||||
try {
|
||||
const data = error.response.data as any;
|
||||
let json: undefined;
|
||||
if (Buffer.isBuffer(data)) {
|
||||
json = JSON.parse(data.toString());
|
||||
} else if (typeof data === 'object') {
|
||||
json = data;
|
||||
} else {
|
||||
json = JSON.parse(data as any);
|
||||
}
|
||||
errorText += `\nData: ${JSON.stringify(json, null, 2)}`;
|
||||
} catch (e) {
|
||||
errorText += 'Data: <unparsable>';
|
||||
}
|
||||
|
||||
return errorText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders Chatwoot SDK ApiError information
|
||||
* @param error The ApiError to render
|
||||
* @returns Formatted error text
|
||||
*/
|
||||
private renderApiError(error: ApiError): string {
|
||||
let errorText = `ChatWoot API Error: ${error.message}`;
|
||||
errorText += `\nStatus: ${error.status}`;
|
||||
|
||||
// Add body if it exists
|
||||
if (error.body) {
|
||||
try {
|
||||
const body =
|
||||
typeof error.body === 'object' ? error.body : JSON.parse(error.body);
|
||||
errorText += `\nBody: ${JSON.stringify(body, null, 2)}`;
|
||||
} catch (e) {
|
||||
errorText += `\nBody: ${error.body}`;
|
||||
}
|
||||
}
|
||||
|
||||
return errorText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders generic error information
|
||||
* @param error The error to render
|
||||
* @returns Formatted error text
|
||||
*/
|
||||
private renderGenericError(error: any): string {
|
||||
return error.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class ChatIDNotFoundForContactError extends Error {
|
||||
constructor(public sender: any) {
|
||||
super('Chat ID not found');
|
||||
}
|
||||
}
|
||||
|
||||
export class PhoneNumberNotFoundInWhatsAppError extends Error {
|
||||
constructor(public phone: any) {
|
||||
super(`Phone number not found in WhatsApp: ${phone}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
|
||||
export class I18N {
|
||||
private readonly locales: Record<string, Locale>;
|
||||
|
||||
constructor() {
|
||||
this.locales = {};
|
||||
}
|
||||
|
||||
available(): Array<{ name: string; locale: string }> {
|
||||
const result = [];
|
||||
for (const [key, template] of Object.entries(this.locales)) {
|
||||
result.push({ locale: key, name: template.key(TKey.LOCALE_NAME).r() });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
load(locales: Record<string, Record<string, string>>) {
|
||||
for (const [locale, strings] of Object.entries(locales)) {
|
||||
this.add(locale, strings);
|
||||
}
|
||||
}
|
||||
|
||||
add(locale: string, strings: Record<string, string>) {
|
||||
if (!this.locales[locale]) {
|
||||
this.locales[locale] = new Locale(strings);
|
||||
} else {
|
||||
this.locales[locale] = this.locales[locale].override(strings);
|
||||
}
|
||||
}
|
||||
|
||||
locale(locale: string) {
|
||||
if (!this.has(locale)) {
|
||||
throw new Error(`Locale ${locale} not found`);
|
||||
}
|
||||
return this.locales[locale];
|
||||
}
|
||||
|
||||
has(locale: string) {
|
||||
return !!this.locales[locale];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as path from 'path';
|
||||
import { YamlLocaleLoader } from '@waha/apps/chatwoot/i18n/loader';
|
||||
import { I18N } from '@waha/apps/chatwoot/i18n/i18n';
|
||||
|
||||
const i18n = new I18N();
|
||||
const localesDir = path.join(__dirname, 'locales');
|
||||
// Load both .yml and .yaml files from locales directory (non-recursive)
|
||||
i18n.load(new YamlLocaleLoader(localesDir, 'yml').load());
|
||||
i18n.load(new YamlLocaleLoader(localesDir, 'yaml').load());
|
||||
|
||||
// Resolve env var (supports '.' and '~'); fallback to default
|
||||
const additional = process.env.WAHA_APPS_CHATWOOT_LANGUAGES_FOLDER?.trim();
|
||||
if (additional) {
|
||||
const resolved = path.resolve(additional);
|
||||
i18n.load(new YamlLocaleLoader(resolved, 'yml').load());
|
||||
i18n.load(new YamlLocaleLoader(resolved, 'yaml').load());
|
||||
}
|
||||
|
||||
export { i18n };
|
||||
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user