Compare commits

..
180 Commits
Author SHA1 Message Date
devlikepro 90ba445a0e up(dashboard): fix refresh chat on new messages
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-10-02 13:07:08 +07:00
devlikepro bf0f49b2e3 up: GOWS - v1.0.48 2026-10-02 13:07:08 +07:00
devlikepro c9ac14e1a4 fix(WEBJS): hide "what's new" modal - use gating days 2026-10-02 13:07:08 +07:00
devlikepro c4f224b7a6 fix(WEBJS): remove hide ux fresh look 2026-10-02 13:07:08 +07:00
devlikepro 0da9ef68cf feat: groups share history mode - fix #2282 2026-10-02 13:07:08 +07:00
Alison White cc650fca3b feat(NOWEB): expose group member-share-history-mode as a settable endpoint (#2283) 2026-10-02 13:07:08 +07:00
devlikepro 9c080611b6 fix(NOWEB): fix delete channel message - fix #2038 2026-10-02 13:07:08 +07:00
devlikepro 235ec01d8b fix(NOWEB): fix update contact - mention #2285 2026-10-02 13:07:08 +07:00
devlikepro ce1e42bb17 up(WEBJS): fix set push name 2026-10-02 13:07:08 +07:00
devlikepro 7440cfc72c up(WEBJS): fix some modules 2026-10-02 13:07:08 +07:00
devlikepro dbb6be3814 up(WEBJS): fix forward message - fix #2278 2026-10-02 13:07:08 +07:00
devlikepro 9035dd95ff up: WPP 2026-10-02 13:07:08 +07:00
devlikepro 9a70c50f62 version: 2026.9.2 2026-10-02 13:07:08 +07:00
Brian Babón 7d7d331195 fix(NOWEB): fix deleting channel message - fix #2038 (#2284) 2026-09-30 15:41:12 +07:00
devlikepro 55a7d78e3f fix(NOWEB): template message with image header — hasMedia:true but download fails - fix #2275
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-09-22 10:38:41 +07:00
devlikepro 377a2d55c9 up: WPP 2026-09-22 10:31:51 +07:00
devlikepro 82046700d7 up: WEBJS - fix "Data passed to getter must include an id property (it's how we memoize) but got undefined" - fix #2271 fix #2273 2026-09-22 10:29:22 +07:00
devlikepro 0dc745bab9 docs: mentions is null in example 2026-09-22 10:26:45 +07:00
Jefferson Emerick 3a52c7dc19 docs: expose mentions in the OpenAPI spec - closes #2268 (#2270) 2026-09-22 10:23:18 +07:00
devlikepro dcb4f5f2c0 up: dashboard 2026-09-17 13:49:25 +07:00
devlikepro a48247c2d2 feat(apps): add mexican phone number apps 2026-09-17 13:36:13 +07:00
devlikepro 0e73561b8e fix(openapi): tags for apps 2026-09-17 13:00:44 +07:00
devlikepro 3ac4ab0746 feat(apps): phone numbers apps to check chat id before sending a message
closes #2260
Co-authored-by: ropu <rovagnati@gmail.com>
2026-09-17 12:55:23 +07:00
devlikeproandlucirlei 1c6081a871 feat(chatwoot): sync message status (delivered/read) from WhatsApp acks
Closes #2264

Co-authored-by: lucirlei <lucirleisantos@msn.com>
2026-09-16 13:46:19 +07:00
devlikepro f1f25cf812 up(NOWEB): fix thumbnails in history 2026-09-16 12:23:37 +07:00
devlikepro 8b73f51267 fix: logger args 2026-09-16 12:23:37 +07:00
devlikepro b1a87b435a fix(NOWEB): fix media reupload - fix #2265 2026-09-16 12:23:37 +07:00
devlikepro 5c04d95da7 chore: make rebuild-noweb 2026-09-16 11:32:45 +07:00
devlikepro 7b6cf27081 up(WEBJS) - fix "POST /api/{session}/groups/join fails with joinGroupViaInvite is undefined" fix #2266 2026-09-16 11:02:03 +07:00
devlikepro d86ea302cb fix: parse invite code without query params 2026-09-16 10:54:18 +07:00
devlikepro 4f0b255b96 chore: make link/unlink 2026-09-16 10:42:05 +07:00
devlikepro def74cd4da up(WPP) 2026-09-16 10:08:00 +07:00
devlikepro 3a4cb909fd fix(chatwoot): remove tier check messages from Chatwoot 2026-09-16 09:51:23 +07:00
devlikepro 495866fc8f up: chrome 2026-09-11 14:57:47 +07:00
devlikepro 408128a537 up: puppeter 25 2026-09-11 14:57:41 +07:00
devlikepro 49878f943d feat(ChatWoot): contacts pull --name=keep|fill|overwrite - fix #2252 2026-09-11 14:56:06 +07:00
devlikepro ee6a952490 feat(ChatWoot): send messages from device as messages in ChatWoot - fix #2249 2026-09-11 13:39:59 +07:00
devlikepro 8e36c7a14c fix(WEBJS): fix presence.update 2026-09-11 12:38:39 +07:00
devlikepro b5702f1a48 chore: validate poll payload - mention #2261 2026-09-11 11:57:54 +07:00
devlikeproandVíctor Rodríguez-Fernández 300f300a02 fix(WEBJS): fix presence subscribe on LID accounts - close #2262
Co-authored-by: Víctor Rodríguez-Fernández <victor.rodriguezf@uam.es>
2026-09-11 11:46:38 +07:00
devlikeproandPoorvaja 254906cf4c fix(NOWEB): archive/unread chat without recent message (closes #2258, fixes #2181)
Co-Authored-By: Poorvaja <poorvaja.s@outlook.com>
2026-09-11 11:27:09 +07:00
devlikepro f51a14eaeb fix(GOWS) - no empty subject for a group - mention #2257 2026-09-11 11:11:31 +07:00
devlikepro bf286f406e up(GOWS)
Fix setting empty group description (topic) - fix #2257
2026-09-11 11:10:11 +07:00
Berg Pinheiro 164706ff61 feat(GOWS): implement forwardMessage (closes #2256, fixes #1439) 2026-09-11 10:33:07 +07:00
devlikepro 86d2816bed up(NOWEB) 2026-09-11 10:33:07 +07:00
devlikepro ae8f68e74f up(WPP) 2026-09-11 10:33:07 +07:00
Berg Pinheiro a27180f212 [core] fix: pin sharp to one version so Lottie stickers convert
fix #2239
2026-09-11 10:33:07 +07:00
devlikepro d0cf4c4f30 version: 2026.9.1 2026-09-11 10:33:07 +07:00
jperquin aa0663b187 fix: add default limit to chats-list pagination (closes #2254) (fix #2253) 2026-09-09 17:56:21 +07:00
devlikepro 8ab642b20d fix(ChatWoot): duplicate contact created for same LID without phone number - fix #2246
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-09-01 15:36:04 +07:00
devlikeproandJulián Valdés 93fa55b7b7 fix(ChatWoot): name LID contacts by push name instead of raw @lid id - closes #1493
Co-authored-by: Julián Valdés <julianvaldes24@gmail.com>
2026-09-01 14:56:33 +07:00
devlikepro 2881458baf chore: waha-dashboard and waha-swagger modules 2026-08-31 18:12:17 +07:00
devlikepro 2595ccfe7b chore: http.paths - access log, auth, metrics 2026-08-31 17:40:01 +07:00
devlikepro b7c4eac93b feat: Prometheus support - GET /metrics - closes #2245 fix #2245 2026-08-31 17:23:56 +07:00
devlikepro 89543ab371 chore: EventWildUnmask support wildcard - group.* 2026-08-31 17:05:52 +07:00
devlikepro 86aba9ea94 chore: EventWildUnmask return unknown 2026-08-31 17:05:52 +07:00
devlikepro 6d25680d43 fix(GOWS): Messages carrying senderKeyDistributionMessage never decrypt — silent group message loss since @lid migration - fix #2242 2026-08-31 12:38:19 +07:00
devlikepro 068658df23 feat: modules docs 2026-08-31 12:36:22 +07:00
devlikepro 69306942ee feat: waha-webhook 2026-08-31 12:32:48 +07:00
devlikepro e968b49349 feat: waha-session-runtime-info 2026-08-31 12:21:17 +07:00
devlikepro 6dc19229ae feat: waha-message-source 2026-08-31 12:17:16 +07:00
devlikepro 63d56b8467 feat: waha-wid-jid + waha-wid-suffix plugins 2026-08-31 12:14:58 +07:00
devlikepro 53687647e9 feat: plugins + modules 2026-08-31 12:07:46 +07:00
devlikepro fdf0589b68 up(WEBJS): handle reply_to in channels - fix #2233 2026-08-31 11:38:22 +07:00
devlikepro f32dace3f8 up(WEBJS): handle duplicated messages - fix #2235 2026-08-31 11:22:58 +07:00
devlikepro ed056cb1f6 fix(WEBJS): handle duplicated messages - fix #2235 2026-08-31 11:20:05 +07:00
devlikepro 0e9b02d92c up(WEBJS)
- Handle reply_to in channels - fix #2233
2026-08-31 10:50:08 +07:00
devlikepro fc556dad77 up(NOWEB)
- Handle reply_to in channels - fix #2233
2026-08-31 10:49:51 +07:00
devlikepro 5780bc09f9 up(WEBJS)
- fix delete message in channels - fix #2236
2026-08-31 10:44:22 +07:00
devlikepro d79940e494 up(dashboard) 2026-08-31 10:44:22 +07:00
devlikepro 1df6ec68db feat: /api/sendSticker
Co-authored-by: nickxs1 <lucasendlichgomes@hotmail.com>

fix #1287
closes #2240
2026-08-31 10:44:22 +07:00
devlikepro a06a72901a fix: Save zip if lottie processing failed
mention #2239
2026-08-31 10:44:22 +07:00
devlikepro 7e859b4ca9 up(WPP) 2026-08-31 10:44:22 +07:00
devlikepro 9490a05586 feat: app purge API 2026-08-31 10:44:22 +07:00
devlikepro b244115600 chore: remove CoreMediaConverter 2026-08-31 10:44:22 +07:00
devlikepro 287d52fe1a feat: Plugin.attach 2026-08-31 10:44:22 +07:00
devlikepro 21efcf07b1 fix: apps openapi tags, brazilian phone numbers app cache 2026-08-31 10:44:22 +07:00
devlikepro aaa0eb12d5 fix: gows .engine 2026-08-31 10:44:22 +07:00
devlikepro 1c5ece5a86 fix: app config import 2026-08-31 10:44:22 +07:00
devlikepro 061601826e fix: circular dependencies - noweb - chatwoot 2026-08-31 10:44:22 +07:00
devlikepro af51c0ae65 feat: app.modules.ts 2026-08-31 10:44:22 +07:00
devlikepro 16b50c672b feat: unique flag for apps - one instance per session 2026-08-31 10:44:22 +07:00
devlikepro dbda9c0457 feat: session plugins registry 2026-08-31 10:44:22 +07:00
devlikeproandbergpinheiro ff30e5ca90 feat: BrazilianPhoneNumberApp + Plugin
Co-authored-by: bergpinheiro
  <24882737+bergpinheiro@users.noreply.github.com>

closes #2180
2026-08-31 10:44:22 +07:00
devlikepro 2770c649c6 feat: IsDuration nestjs field validation 2026-08-31 10:44:22 +07:00
devlikepro 61f1f71343 feat: hooks, plugins, apps
- MaintainOnlineStatusPlugin (Activity)
- MessageSourceCachePlugin
- WebhookPlugin
- Wid*Plugin
2026-08-31 10:44:22 +07:00
devlikeproandElimeshi1 99e4dfcfb5 feat: granular control for media - api, events, mimetypes
closes #2211

Co-authored-by: Elimeshi1 <eliyaume@edu.hac.ac.il>
2026-08-31 10:44:22 +07:00
devlikeproandicecubex300 cc12380e48 feat(groups): membership approval - settings, pending requests API and group.v2.participants.join-request event
Closes #2200

Co-authored-by: icecubex300 <83597812+icecubex300@users.noreply.github.com>
2026-08-31 10:44:22 +07:00
devlikepro b5e223a333 up: axios 1.19.0 2026-08-31 10:44:22 +07:00
devlikeproandAnderson Lemes f6db89c141 fix(proxy): honor HTTP(S)_PROXY when downloading media
Closes #2224

Co-authored-by: Anderson Lemes <andersonlemes@outlook.pt>
2026-08-31 10:44:22 +07:00
devlikepro d2545c2de6 feat: use traceparent from opentelemetry
fix #2176 closes #2179
2026-08-31 10:44:22 +07:00
devlikepro 12a774c864 up(GOWS)
- Fix maps mirrored fromMe messages to the wrong contact/conversation - fix #2241
- Unable to retrieve WhatsApp groups - fix #2234
2026-08-31 10:44:22 +07:00
devlikepro 186847a455 up(WPP) 2026-08-19 15:51:46 +07:00
devlikepro 8c44b70109 version: 2026.8.2 2026-08-14 19:15:57 +07:00
devlikepro 382c1a7e94 fix(WEBJS): phantom AUTHENTICATED/READY on logout and onNewMessageId binding race - fix #2222
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-08-14 18:48:18 +07:00
devlikepro d7fb3091a6 build: ignore on watch 2026-08-14 18:48:18 +07:00
devlikepro 1fbe60e646 fix(WEBJS): session stuck in STARTING 2026-08-14 18:48:18 +07:00
devlikepro 841063739e feat: GET /api/sessions/{name}/timelock - fix #2219 2026-08-14 18:48:18 +07:00
devlikepro 8d4eb02202 up(WEBJS): hide "What's new on WhatsApp Web" - fix #2217 2026-08-14 18:48:18 +07:00
Moshe Grunwald f6ed147acc fix(s3): set ContentType on uploaded media objects 2026-08-14 17:45:14 +07:00
devlikepro 96a106cff7 up(WPP): fix promote to admin types 2026-08-14 17:39:23 +07:00
devlikepro 98854e2da3 up(WPP) 2026-08-14 17:25:11 +07:00
devlikepro 48c34cbc17 up(dashboard) 2026-08-05 16:00:16 +07:00
devlikepro 926b5c1b66 fea(GOWS): disable contacts, messageSecrets - mention #2207 2026-08-05 15:31:40 +07:00
devlikepro 608bad07a9 fix(ChatWoot): populate phone number if available fix #2208 2026-08-05 15:00:43 +07:00
devlikepro 64c6a32786 fix(GOWS): populate jid if available fix #2208 2026-08-05 14:34:59 +07:00
devlikepro f33ca787a5 up(NOWEB): fix presense chat issue 2026-08-05 14:34:59 +07:00
devlikepro 78aea9aa51 fix: set participant.pn if available 2026-08-05 14:34:59 +07:00
devlikepro 5e75d47149 up(WPP) 2026-08-05 14:34:59 +07:00
devlikepro c5a53d36bb fix(NOWEB): Fix group.v2.leave - fix #2206 closes #2209 2026-08-05 14:34:59 +07:00
devlikepro 0213692705 fix(ChatWot): do not save full attachments content in a task result - fix #2201 closes #2202 2026-08-05 14:34:59 +07:00
devlikepro a112fde248 fix(WEBJS): set group description #2199 2026-08-05 14:34:59 +07:00
devlikepro c207f3c08c fix(WEBJS): kill chrome at the end 2026-08-05 14:34:59 +07:00
devlikepro 1b8027473e fix(NOWEB): stop session properly 2026-08-05 14:34:59 +07:00
devlikepro 976066b216 chore(WEBJS): Up chrome to 142.0.7444.134-1 2026-08-05 14:34:59 +07:00
devlikepro af229a1d40 fix(GOWS): SIGKILL if hanged 2026-08-05 14:34:59 +07:00
devlikepro d14126d8a6 fix: process catch on promise timeout 2026-08-05 14:34:59 +07:00
devlikepro d4ce0e6541 fix: process.once on signals 2026-08-05 14:34:59 +07:00
devlikepro 5ddbeb995e feat(WEBJS, NOWEB): message capping API and events mention #2186 2026-08-05 14:34:59 +07:00
devlikepro f26d43b299 fix(WEBJS) - send button reply fix #2183 2026-08-05 14:34:59 +07:00
Berg Pinheiro ff998579f0 feat(GOWS): expose new-chat message capping (per-cycle quota) (#2186) 2026-08-05 14:34:59 +07:00
devlikepro 3cae921fba up(WPP): up engine 2026-08-05 14:34:59 +07:00
devlikepro 4682dddb4e chore(WPP): make with ignore sharp 2026-08-05 14:34:59 +07:00
devlikepro 2e6683d26f up(WEBJS): fix add group participants 2026-08-05 14:34:59 +07:00
devlikepro e97107ba1a up(GOWS)
- Update proto
- Fix disable whole store - fix #2207
2026-08-05 14:34:59 +07:00
devlikepro 2d96a57f72 feat(NOWEB): WAHA_NOWEB_WA_VERSION=auto-web|auto-baileys - fetch the latest WhatsApp Web version on start 2026-07-29 16:36:19 +07:00
devlikepro 9adcd3b133 version: 2026.8.1 2026-07-29 15:53:48 +07:00
devlikepro 79233e09e3 fix(NOWEB): Add WAHA_NOWEB_WA_VERSION / WAHA_NOWEB_WA_VERSION_FORCE - closes #2193
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-07-29 15:18:03 +07:00
devlikepro a96a4b2161 up(NOWEB): fix version - fix #2191 2026-07-29 14:31:41 +07:00
devlikepro f736105873 up(dashboard): session info, copy button in Event Monitor 2026-07-22 17:42:41 +07:00
devlikepro 2c1ea2c7c1 feat: /settings/security/member-add-mode - fix #2165 2026-07-22 17:35:49 +07:00
Ali White 216e00cc25 [core] feat(NOWEB): expose group member-add-mode as a settable endpoint close #2172 2026-07-22 17:19:34 +07:00
devlikeproandBerg Pinheiro ac1a015046 [core] Fix restartStoppedSessions aborting on one stuck session, closes #2169
Co-Authored-By: Berg Pinheiro <berg.pinheiro2009@gmail.com>
2026-07-22 17:17:35 +07:00
devlikepro 8f4af6bb08 feat: expose Reachout Timelock data - WORKING status and /me info fix #2166 2026-07-22 16:56:44 +07:00
devlikepro acee1b7d79 chore: timehelper docs 2026-07-22 16:12:21 +07:00
devlikepro 99241c3089 chore: SessionName to validation 2026-07-22 16:00:28 +07:00
devlikepro 8e764372d2 fix: LongTimeout and unref() for not keeping the process alive 2026-07-22 15:53:05 +07:00
devlikepro fa21a60cf3 fix(ChatWoot): safe read, typing when sending messages - fix #2173 2026-07-22 13:20:18 +07:00
devlikepro 02fa0ea06b ci: build dev nightly 2026-07-22 13:20:18 +07:00
devlikepro 8d1ad04625 fix(NOWEB): fix empty message.edited body - fix #2168 2026-07-22 13:20:18 +07:00
devlikepro a639baad8d fix(NOWEB): up engine 2026-07-22 13:20:18 +07:00
devlikepro 2e24107018 chore: up yarn@4.17.1 2026-07-22 13:20:18 +07:00
devlikepro 750ce208b3 chore(WEBJS): Up chrome 140.0.7339.207-1
The previous version is not found
2026-07-22 13:20:18 +07:00
devlikepro 057e0a0e70 up(GOWS): fix channels link preview - mention #2163, fix unknown field "faviconMMSMetadata" - fix #2172 2026-07-22 13:20:18 +07:00
devlikepro 255f84a12d fix(NOWEB): sending preview in channels mention #2163 2026-07-17 19:00:38 +07:00
devlikepro ac6d14394a chore: publish dev on core 2026-07-17 19:00:38 +07:00
devlikepro cb77c2fc49 fix(WEBJS): up engine. Fix sending link preview to channels fix #2163 2026-07-17 19:00:38 +07:00
devlikepro 5c279c5240 [core] 2026.7.2 2026-07-17 14:55:42 +07:00
devlikepro 7e719d8894 [core] 2026.7.1
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-07-15 20:04:29 +07:00
devlikepro 8faa3ca5a4 [core] Up dashboard 2026-07-15 20:04:29 +07:00
devlikepro 5dd8bf140d [core] Up WEBJS 2026-07-15 20:04:29 +07:00
devlikepro 0501c37500 [core] WEBJS - add lid to /me 2026-07-15 20:04:29 +07:00
devlikepro 1496274a99 [core] Up WEBJS - fix _serialized id rename fix #2157 fix #2158 fix #2159 fix #2160 fix #2162 2026-07-15 20:04:29 +07:00
devlikepro b6ba3951da [core] Up NOWEB 2026-07-15 20:04:29 +07:00
devlikepro a4a3dc93ad [core] Up WPP - fix _serialized issues 2026-07-15 20:04:28 +07:00
devlikepro 57eb0e14ca [core] Up dashboard - fix image emoji 2026-07-15 20:04:28 +07:00
devlikepro d8970326be [core] GOWS - fix "Session silently stops sending webhook events after <stream:error> (media ack)" - fix #2151 2026-07-15 20:04:28 +07:00
devlikepro 06412c2c1e [core] Up WEBJS - fix sending image with "msg.avParams is not a function" fix #2149 2026-07-15 20:04:28 +07:00
devlikepro 0b39645c50 [core] Update Dashboard - passkey flow driven by session.status
Picks up the UI side of the passkey rework: no more passkey.* events,
PASSKEY_CONFIRMATION_REQUIRED handled, GET /auth/passkey/challenge.
2026-07-15 20:04:28 +07:00
devlikepro 76f8cc6f53 [core] Don't watch src/dashboard assets - fixes ENOSPC inotify limit on start:dev 2026-07-15 20:04:28 +07:00
devlikepro ea79b1d886 [core] Passkey - collapse events into session.status, add GET /auth/passkey/challenge
- Remove 'passkey.required' and 'passkey.confirmation.required' events.
  Passkey pairing is a session state, so it rides on 'session.status'
  instead - one new status value per pairing step WhatsApp adds, not
  one new event.
- Add PASSKEY_CONFIRMATION_REQUIRED session status.
- Add 'data' to the session.status payload - the extra info that belongs
  to the current status. PASSKEY_REQUIRED carries the WebAuthn challenge,
  PASSKEY_CONFIRMATION_REQUIRED carries { code }, null otherwise.
- Base session gets setStatus(status, data); the plain 'status = value'
  setter delegates to it with no data, so the data clears itself as soon
  as the session moves on (WORKING, STOPPED, ...) with no extra bookkeeping.
- REST: GET /auth/passkey/challenge (was GET /auth/passkey) returns the
  challenge object, GET /auth/passkey/confirmation returns { code }.
  Both throw 422 when nothing is pending.
- MCP: auth-passkey-challenge, auth-passkey-submit, auth-passkey-confirmation,
  auth-passkey-confirm.
- Drop the SkipHandoffUX auto-confirm branch - it was dead code. whatsmeow
  confirms on its own in that case and only emits passkey-confirmation for
  the manual one (qrchan.go).
- Keep QR rotation from bouncing PASSKEY_CONFIRMATION_REQUIRED back to
  SCAN_QR_CODE, same as PASSKEY_REQUIRED.
2026-07-15 20:04:27 +07:00
devlikepro e54604eb97 [core] rm settings.local.json 2026-07-15 20:04:27 +07:00
Berg Pinheiro d267a29e87 [core] Update Dashboard - adds Passkey UI (extension-assisted + manual DevTools fallback) 2026-07-15 20:04:27 +07:00
Berg Pinheiro d4625359e6 [core] Up GOWS - v1.0.43, adds SubmitPasskeyResponse/ConfirmPasskey RPCs 2026-07-15 20:04:27 +07:00
Berg Pinheiro 3618b92c3e feat(passkey): Add Passkey (WebAuthn) session pairing
- New engine step: gows emits passkey-request/passkey-confirmation, session
  status PASSKEY_REQUIRED, challenge stored and exposed via getPasskeyChallenge().
- REST: GET/POST /api/:session/auth/passkey, GET /api/:session/auth/passkey/confirmation,
  POST /api/:session/auth/passkey/confirm.
- Webhooks: passkey.required (challenge) and passkey.confirmation.required (manual
  code case; most pairings auto-confirm server-side right after the assertion).
- QR rotation no longer bounces PASSKEY_REQUIRED back to SCAN_QR_CODE.
2026-07-15 20:04:27 +07:00
devlikepro 982092cf2b [core] MCP - do no share real api key for media and auth - qr or screenshot - fix #2146 2026-07-15 20:04:27 +07:00
devlikepro 6a452cd66e [core] Up WPP. Use 'main' branch versions for wa-js and wppconnect 2026-07-15 20:04:27 +07:00
devlikepro 19625e38e9 [core] Up NOWEB. Fix chat history ordering fix #2139. 2026-07-15 20:04:27 +07:00
devlikepro 208f4f3d78 [core] GOWS - fix document media — 403 on live media + media-retry never completes/refreshes directPath - fix ##2131
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-06-27 16:49:26 +07:00
devlikepro a9ff7d763d [core] GOWS - use gows-plus repo 2026-06-27 16:49:26 +07:00
devlikepro e290fd545f [core] make release 2026-06-27 16:32:05 +07:00
devlikepro 84f111e2c1 [core] 2026.6.2 2026-06-27 16:32:05 +07:00
devlikepro 55dddd5991 [core] WEBJS - remove all mentions of window.WAHA 2026-06-27 16:32:05 +07:00
devlikepro 638555297c [core] Up WEBJS 2026-06-27 16:32:04 +07:00
devlikepro 0e4de03da3 [core] Up GOWS 2026-06-27 16:32:04 +07:00
devlikepro c6219be356 [core] Up NOWEB 2026-06-27 16:32:04 +07:00
devlikepro 2460a23cab [core] Up WPP 2026-06-27 16:32:04 +07:00
devlikepro c2ed34a171 [core] 2026.6.1
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
2026-06-22 15:14:17 +07:00
devlikepro 1db8ae3423 [core] Merge PLUS functionality into CORE
Copy the storage (Mongo/Postgres/SQLite), media (S3/Postgres), engine
auth/store and util implementations from src/plus into src/core, and fold
the *Plus engine sessions, session manager, factories, health and channels
services into their *Core classes.

Core now supports multi-session orchestration, Mongo/Postgres session
storage, S3/Postgres media storage, FFmpeg media conversion, real health
checks and channels metadata. mediaConverter is set once in the base
session, and AppModuleCore wires the conditional media modules and health
indicators.
2026-06-22 15:14:17 +07:00
devlikepro 8a06ee3d44 [core] Up GOWS 2026-06-22 15:14:17 +07:00
381 changed files with 23706 additions and 3657 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
{
"permissions": {
"allow": [
"Bash(yarn build:*)",
"Bash(cat:*)",
"Bash(node:*)",
"Bash(npm show:*)",
"Bash(python3:*)",
"Bash(yarn test:unit:*)",
"Bash(npx tsc:*)",
"Bash(pre-commit run:*)",
"Bash(yarn test:*)"
]
}
}
+24
View File
@@ -56,6 +56,20 @@ WAHA_LOG_LEVEL=info
# Don't print QR codes in logs
WAHA_PRINT_QR=False
# ======================
# ===== PROMETHEUS =====
# ======================
# Prometheus metrics endpoint - GET /metrics
# WAHA_PROMETHEUS_ENABLED=True
# WAHA_PROMETHEUS_PATH=/metrics
# WAHA_PROMETHEUS_METRIC_PREFIX=waha_
# WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS=0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10,30
# Events to count in waha_events_total ('*' for all) - the server actively processes listed events even with no webhooks
# WAHA_PROMETHEUS_TRACK_EVENTS=message.any
# Optional basic auth for the metrics endpoint (both must be set)
# WAHA_PROMETHEUS_USERNAME=admin
# WAHA_PROMETHEUS_PASSWORD=secret
# =========================
# ===== MEDIA STORAGE =====
# =========================
@@ -64,7 +78,17 @@ WAHA_MEDIA_STORAGE=LOCAL
WHATSAPP_FILES_LIFETIME=0
WHATSAPP_FILES_FOLDER=/app/.media
#
# Media download settings
#
# Events (webhooks, websockets)
# WAHA_EVENTS_DOWNLOAD_MEDIA=true
# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.)
# WAHA_API_DOWNLOAD_MEDIA=true
# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above
# WHATSAPP_DOWNLOAD_MEDIA=true
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
+1
View File
@@ -0,0 +1 @@
SHARP_IGNORE_GLOBAL_LIBVIPS=1
+2 -1
View File
@@ -35,7 +35,7 @@ jobs:
# run: yarn test:unit
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
if: github.repository == 'devlikeapro/waha'
# needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
@@ -73,6 +73,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: dev
fetch-depth: 0
- name: Check recent changes
+7
View File
@@ -1,9 +1,16 @@
approvedGitRepositories:
- '**'
compressionLevel: mixed
enableGlobalCache: false
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0
supportedArchitectures:
cpu:
- arm
+2 -2
View File
@@ -16,10 +16,10 @@ RUN apt-get update && \
WORKDIR /git
COPY package.json .
COPY yarn.lock .
COPY .yarnrc.yml .
ENV YARN_CHECKSUM_BEHAVIOR=update
RUN npm install -g corepack && corepack enable
RUN yarn set version 4.9.2
RUN yarn install
# App
@@ -179,7 +179,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="140.0.7339.80-1"
ARG CHROME_VERSION="152.0.7977.82-1"
ARG OPUSTAGS_VERSION="1.10.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 \
+27 -3
View File
@@ -41,11 +41,24 @@ up-noweb-libsignal:
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26
link-webjs:
yarn up whatsapp-web.js@link:../whatsapp-web.js
unlink-webjs: up-webjs
rebuild-noweb:
cd ../Baileys && yarn build
link-noweb: rebuild-noweb
yarn up @adiwajshing/baileys@link:../Baileys
unlink-noweb: up-noweb
up-wpp:
yarn up @wppconnect-team/wppconnect
yarn up @wppconnect/wa-js
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
up-rust-bridge:
yarn up -R whatsapp-rust-bridge
@@ -61,6 +74,17 @@ gows:
(export PATH=${HOME}/go/bin:${PATH} || echo failed) && \
make all
ORIGIN ?= waha-plus
CORE_REMOTE ?= waha
release:
node scripts/release.js
release-push: release
git push $(ORIGIN) core plus
git push --force-with-lease $(ORIGIN) dev
git push $(CORE_REMOTE) core
up-dashboard:
node scripts/up-dashboard.js
+10
View File
@@ -60,7 +60,17 @@ WAHA_MEDIA_STORAGE=LOCAL
WHATSAPP_FILES_LIFETIME=1800
WHATSAPP_FILES_FOLDER=/app/.media
#
# Media download settings
#
# Events (webhooks, websockets)
# WAHA_EVENTS_DOWNLOAD_MEDIA=true
# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.)
# WAHA_API_DOWNLOAD_MEDIA=true
# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above
# WHATSAPP_DOWNLOAD_MEDIA=true
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
+6 -7
View File
@@ -3,14 +3,13 @@
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"],
"watchPathIgnorePatterns": ["node_modules", "src/dashboard", "dist"],
"assets": [
"dashboard/**",
"core/engines/webjs/*",
"plus/engines/webjs/*",
"apps/chatwoot/i18n/locales/*.yaml",
"apps/chatwoot/i18n/locales/*.yml"
{ "include": "dashboard/**", "watchAssets": false },
{ "include": "core/engines/webjs/*", "watchAssets": true },
{ "include": "plus/engines/webjs/*", "watchAssets": true },
{ "include": "apps/chatwoot/i18n/locales/*.yaml", "watchAssets": true },
{ "include": "apps/chatwoot/i18n/locales/*.yml", "watchAssets": true }
],
"watchAssets": true
"watchAssets": false
}
}
+17 -9
View File
@@ -54,19 +54,25 @@
"@nestjs/swagger": "^7.1.11",
"@nestjs/terminus": "^10.2.3",
"@nestjs/websockets": "^11.0.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/instrumentation-http": "^0.221.0",
"@opentelemetry/instrumentation-pino": "^0.67.0",
"@opentelemetry/sdk-node": "^0.221.0",
"@opentelemetry/sdk-trace": "^2.10.0",
"@prometheus-io/client": "^0.16.1",
"@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",
"@wppconnect-team/wppconnect": "^2.2.1",
"@wppconnect/wa-js": "^4.3.0",
"@wppconnect-team/wppconnect": "github:wppconnect-team/wppconnect#master",
"@wppconnect/wa-js": "github:wppconnect-team/wa-js#main",
"adm-zip": "0.5.10",
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
"audio-decode": "^2.2.2",
"axios": "^1.9.0",
"axios": "^1.19.0",
"axios-retry": "^4.5.0",
"better-sqlite3": "^12.4.1",
"bullmq": "^5.48.1",
@@ -105,7 +111,7 @@
"pino-pretty": "^11.2.1",
"pretty-bytes": "5.6.0",
"promise-retry": "^2.0.1",
"puppeteer": "^24.31.0",
"puppeteer": "^25.10.0",
"qrcode": "^1.5.1",
"qrcode-terminal": "^0.12.0",
"reflect-metadata": "^0.1.13",
@@ -115,11 +121,12 @@
"shell-quote": "^1.8.3",
"sqlite3": "^5.1.7",
"swagger-ui-express": "^4.1.4",
"tapable": "^2.2.2",
"ulid": "^2.3.0",
"undici": "^7.16.0",
"uniqid": "^5.4.0",
"user-agents": "^1.1.669",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1",
"zod": "^4.3.6"
@@ -131,10 +138,11 @@
"typescript": "5.9.2",
"bufferutil": "^4.0.8",
"ws": "^8.18.0",
"puppeteer": "^24.31.0",
"puppeteer": "^25.10.0",
"whatwg-url": "13.0.0",
"axios": "^1.9.0",
"whatsapp-rust-bridge": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2"
"axios": "^1.19.0",
"whatsapp-rust-bridge": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2",
"sharp": "0.35.3"
},
"devDependencies": {
"@grpc/grpc-js": "^1.14.1",
@@ -205,5 +213,5 @@
}
]
},
"packageManager": "yarn@4.9.2"
"packageManager": "yarn@4.17.1"
}
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
'use strict';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { execFileSync } = require('child_process');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const readline = require('readline');
const DEV_BRANCH = process.env.WAHA_DEV_BRANCH || 'dev';
const CORE_BRANCH = process.env.WAHA_CORE_BRANCH || 'core';
const PLUS_BRANCH = process.env.WAHA_PLUS_BRANCH || 'plus';
const CORE_PREFIX = '[core]';
const PLUS_PREFIX = '[PLUS]';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const assumeYes = args.includes('--yes') || args.includes('-y');
function git(gitArgs, options) {
const opts = options || {};
return execFileSync('git', gitArgs, {
encoding: 'utf8',
stdio: opts.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
});
}
function gitOut(gitArgs) {
return git(gitArgs, { inherit: false }).trim();
}
function log(message) {
console.log(message);
}
function fail(message) {
console.error(`\n✗ ${message}`);
process.exit(1);
}
function ensureCleanTree() {
const status = gitOut(['status', '--porcelain']);
if (status) {
fail(
'Working tree is not clean. Commit or stash your changes before releasing.',
);
}
}
function ensureBranchExists(branch) {
try {
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
} catch {
fail(`Branch "${branch}" does not exist locally.`);
}
}
// Non-merge commits in `boundary..head`, oldest first, whose subject starts
// with prefix. The boundary is the last-released plus tip: dev is rebased onto
// plus every release, so `plus..dev` is exactly the new, unreleased work.
// We intentionally do not use `git cherry` (patch-id matching) because core is
// a rewritten history whose old commits share no patch-ids with dev.
function commitsToCherryPick(boundary, head, prefix) {
const format = '%H%x09%s';
const output = gitOut([
'rev-list',
'--reverse',
'--no-merges',
`--format=${format}`,
`${boundary}..${head}`,
]);
if (!output) {
return [];
}
const picks = [];
for (const line of output.split('\n')) {
// rev-list --format prefixes each entry with a "commit <sha>" header line.
if (!line || line.startsWith('commit ')) {
continue;
}
const tab = line.indexOf('\t');
const sha = line.slice(0, tab);
const subject = line.slice(tab + 1);
if (subject.startsWith(prefix)) {
picks.push({ sha: sha, subject: subject });
}
}
return picks;
}
function cherryPickAll(picks) {
for (const pick of picks) {
log(` cherry-pick ${pick.sha.slice(0, 9)} ${pick.subject}`);
if (dryRun) {
continue;
}
try {
git(['cherry-pick', pick.sha], { inherit: true });
} catch {
git(['cherry-pick', '--abort'], { inherit: true });
fail(
`Cherry-pick of ${pick.sha.slice(0, 9)} failed (conflict). ` +
`Aborted the cherry-pick — resolve manually and re-run.`,
);
}
}
}
function checkout(branch) {
log(`\n→ checkout ${branch}`);
if (!dryRun) {
git(['checkout', branch], { inherit: true });
}
}
function confirm(question) {
if (assumeYes || dryRun) {
return Promise.resolve(true);
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(function resolver(resolve) {
rl.question(`${question} [y/N] `, function onAnswer(answer) {
rl.close();
resolve(/^y(es)?$/i.test(answer.trim()));
});
});
}
async function main() {
ensureCleanTree();
[DEV_BRANCH, CORE_BRANCH, PLUS_BRANCH].forEach(ensureBranchExists);
const startBranch = gitOut(['rev-parse', '--abbrev-ref', 'HEAD']);
if (dryRun) {
log('Running in --dry-run mode: no branches will be modified.\n');
}
// Capture the last-released plus tip before we touch anything. Step 3 merges
// core into plus and moves the branch, so both pick sets must be computed
// against this saved boundary, not the live plus ref.
const boundary = gitOut(['rev-parse', PLUS_BRANCH]);
log(`Boundary (last released ${PLUS_BRANCH}): ${boundary.slice(0, 9)}\n`);
const corePicks = commitsToCherryPick(boundary, DEV_BRANCH, CORE_PREFIX);
log(
`Found ${corePicks.length} "${CORE_PREFIX}" commit(s) in ` +
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
);
// Step 1 + 2: bring missing [core] commits onto core.
checkout(CORE_BRANCH);
cherryPickAll(corePicks);
// Step 3: merge the freshly updated core into plus.
checkout(PLUS_BRANCH);
log(`\n→ merge ${CORE_BRANCH} into ${PLUS_BRANCH}`);
if (!dryRun) {
try {
git(['merge', '--no-edit', CORE_BRANCH], { inherit: true });
} catch {
git(['merge', '--abort'], { inherit: true });
fail(
`Merge of ${CORE_BRANCH} into ${PLUS_BRANCH} failed (conflict). ` +
`Aborted the merge — resolve manually and re-run.`,
);
}
}
// Step 4: bring missing [PLUS] commits onto plus (same saved boundary).
const plusPicks = commitsToCherryPick(boundary, DEV_BRANCH, PLUS_PREFIX);
log(
`\nFound ${plusPicks.length} "${PLUS_PREFIX}" commit(s) in ` +
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
);
cherryPickAll(plusPicks);
// Step 5: rebase dev onto the freshly built plus.
log(`\n→ rebase ${DEV_BRANCH} onto ${PLUS_BRANCH} (rewrites ${DEV_BRANCH})`);
const proceed = await confirm(
`This will force-rewrite "${DEV_BRANCH}". Continue?`,
);
if (!proceed) {
fail('Aborted before rebasing dev. core/plus changes are kept.');
}
checkout(DEV_BRANCH);
if (!dryRun) {
try {
git(['rebase', PLUS_BRANCH], { inherit: true });
} catch {
git(['rebase', '--abort'], { inherit: true });
fail(
`Rebase of ${DEV_BRANCH} onto ${PLUS_BRANCH} failed (conflict). ` +
`Aborted the rebase — resolve manually and re-run.`,
);
}
}
if (dryRun) {
checkout(startBranch);
log('\nDry run complete. Re-run without --dry-run to apply.');
} else {
log(
`\n✓ Release complete. ${DEV_BRANCH} now sits on top of ${PLUS_BRANCH}.`,
);
log(
` Push when ready: git push origin ${CORE_BRANCH} ${PLUS_BRANCH} ` +
`&& git push --force-with-lease origin ${DEV_BRANCH}`,
);
}
}
main().catch(function onError(error) {
fail(error.message || String(error));
});
+26 -2
View File
@@ -12,12 +12,16 @@ import {
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanServer } from '@waha/core/auth/policies';
import { CanServer, CanSession, FromBody } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKeyService } from '@waha/core/services/ApiKeyService';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import {
ApiKeyDTO,
ApiKeyRequest,
ScopedApiKeyRequest,
} from '@waha/structures/apikeys.dto';
@ApiSecurity('api_key')
@Controller('api/keys')
@@ -44,6 +48,26 @@ export class ApiKeysController {
return this.service.list();
}
@Post('/media')
@ApiOperation({
summary: 'Create or get a media-download-only API key for a session',
})
@CheckPolicies(CanSession(Action.Read, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async media(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
return this.service.createOrGetMediaKey(body.session);
}
@Post('/control')
@ApiOperation({
summary: 'Create or get a control-only API key for a session',
})
@CheckPolicies(CanSession(Action.Control, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async control(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
return this.service.createOrGetControlKey(body.session);
}
@Put('/:id')
@ApiOperation({ summary: 'Update an API key' })
@UsePipes(new WAHAValidationPipe())
+57 -1
View File
@@ -7,7 +7,12 @@ import {
UseInterceptors,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import {
QRCodeSessionParam,
@@ -19,6 +24,9 @@ import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import {
PasskeyAssertionRequest,
PasskeyChallenge,
PasskeyConfirmationResponse,
QRCodeFormat,
QRCodeQuery,
QRCodeValue,
@@ -68,6 +76,54 @@ class AuthController {
) {
return session.requestCode(request.phoneNumber, request.method, request);
}
@Get('passkey/challenge')
@SessionApiParam
@ApiOperation({
summary: 'Get the pending passkey (WebAuthn) challenge.',
description:
'Available while the session is in PASSKEY_REQUIRED status. ' +
'Pass the challenge to navigator.credentials.get({ publicKey: challenge }) ' +
'on the https://web.whatsapp.com origin.',
})
@ApiOkResponse({ type: PasskeyChallenge })
getPasskeyChallenge(@SessionParam session: WhatsappSession) {
return session.getPasskeyChallenge();
}
@Post('passkey')
@SessionApiParam
@ApiOperation({
summary: 'Submit a WebAuthn passkey assertion to finish pairing.',
})
submitPasskey(
@SessionParam session: WhatsappSession,
@Body() request: PasskeyAssertionRequest,
) {
return session.sendPasskeyResponse(JSON.stringify(request));
}
@Get('passkey/confirmation')
@SessionApiParam
@ApiOperation({
summary: 'Get the pending passkey confirmation code.',
description:
'Available while the session is in PASSKEY_CONFIRMATION_REQUIRED status. ' +
'Most pairings skip this step - WhatsApp confirms them right after the assertion.',
})
@ApiOkResponse({ type: PasskeyConfirmationResponse })
getPasskeyConfirmation(@SessionParam session: WhatsappSession) {
return session.getPasskeyConfirmation();
}
@Post('passkey/confirm')
@SessionApiParam
@ApiOperation({
summary: 'Confirm passkey pairing (only needed for the manual code case).',
})
confirmPasskey(@SessionParam session: WhatsappSession) {
return session.confirmPasskey();
}
}
export { AuthController };
+43 -1
View File
@@ -9,7 +9,7 @@ import {
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import {
GetChatMessagesFilter,
@@ -37,6 +37,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextQuery,
MessageTextRequest,
MessageVideoRequest,
@@ -145,6 +146,46 @@ export class ChattingController {
return whatsapp.sendVideo(request);
}
@Post('/sendSticker')
@ApiOperation({
summary: 'Send a sticker',
description:
'The image will not be converted. It must already be in WebP format. PNG and JPEG are not supported.',
})
@ApiBody({
type: MessageStickerRequest,
examples: {
url: {
summary: 'From URL',
value: {
session: 'default',
chatId: '11111111111@c.us',
reply_to: null,
file: {
url: 'https://www.gstatic.com/webp/gallery/1.webp',
},
},
},
base64webp: {
summary: 'From base64 WebP',
value: {
session: 'default',
chatId: '11111111111@c.us',
reply_to: null,
file: {
mimetype: 'image/webp',
data: 'your-base64-data-of-webp',
},
},
},
},
})
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
async sendSticker(@Body() request: MessageStickerRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendSticker(request);
}
@Post('/send/link-custom-preview')
@ApiOperation({
summary: 'Send a text message with a CUSTOM link preview.',
@@ -255,6 +296,7 @@ export class ChattingController {
description: 'You can use it as buttons or list replacement',
})
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendPoll(@Body() request: MessagePollRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendPoll(request);
+158
View File
@@ -32,12 +32,17 @@ import {
CreateGroupRequest,
DescriptionRequest,
GroupField,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupsListFields,
GroupsPaginationParams,
JoinGroupRequest,
JoinGroupResponse,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMemberShareHistoryMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
@@ -309,6 +314,159 @@ export class GroupsController {
return session.getMessagesAdminsOnly(id);
}
@Put(':id/settings/security/member-add-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Update settings - who can add new members',
description:
'Updates the group settings for who can add new members to the group - all members or admins only.',
})
setMemberAddMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SettingsMemberAddMode,
) {
return session.setMemberAddMode(id, request.membersCanAddNewMember);
}
@Get(':id/settings/security/member-add-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - who can add new members',
description:
'The group settings for who can add new members to the group - all members or admins only.',
})
getMemberAddMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<SettingsMemberAddMode> {
return session.getMemberAddMode(id);
}
@Put(':id/settings/security/member-share-history-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary:
'Update settings - members can send message history to new members',
description:
'Updates the group settings for whether members can share message history with new members, or only admins can.',
})
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
setMemberShareHistoryMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SettingsMemberShareHistoryMode,
): Promise<boolean> {
return session.setMemberShareHistoryMode(
id,
request.membersCanShareHistory,
);
}
@Get(':id/settings/security/member-share-history-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - members can send message history to new members',
description:
'The group settings for whether members can share message history with new members, or only admins can.',
})
getMemberShareHistoryMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<SettingsMemberShareHistoryMode> {
return session.getMemberShareHistoryMode(id);
}
@Put(':id/settings/security/membership-approval')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Update settings - approve new members',
description:
'Enables or disables admin approval for users requesting to join the group.',
})
setMembershipApprovalMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SettingsMembershipApproval,
): Promise<boolean> {
return session.setMembershipApprovalMode(
id,
request.newMembersApprovalRequired,
);
}
@Get(':id/settings/security/membership-approval')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - approve new members',
description:
'Returns whether admin approval is required for users requesting to join the group.',
})
getMembershipApprovalMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<SettingsMembershipApproval> {
return session.getMembershipApprovalMode(id);
}
@Get(':id/participants/join-requests')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get pending requests to join the group',
})
getGroupJoinRequests(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<GroupJoinRequest[]> {
return session.getGroupJoinRequests(id);
}
@Post(':id/participants/join-requests/approve')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Approve pending requests to join the group',
})
approveGroupJoinRequests(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
return session.approveGroupJoinRequests(id, request);
}
@Post(':id/participants/join-requests/reject')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Reject pending requests to join the group',
})
rejectGroupJoinRequests(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
return session.rejectGroupJoinRequests(id, request);
}
@Get(':id/invite-code')
@SessionApiParam
@GroupIdApiParam
+42 -2
View File
@@ -34,11 +34,14 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
MessageCappingData,
ReachoutTimelockData,
SessionCreateRequest,
SessionDTO,
SessionExpand,
SessionInfo,
SessionInfoQuery,
SessionLogoutRequest,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
@@ -98,6 +101,39 @@ class SessionsController {
return this.sessionService.getSessionMe(session);
}
@Get(':session/capping')
@SessionApiParam
@ApiOperation({
summary: 'Fetch the account new-chat message capping (per-cycle quota)',
description:
'Fetch a fresh new-chat message capping (quota) state from WhatsApp. ' +
'The same value is also available under me.messageCapping in the session ' +
'info, and changes are pushed through the session.status event.',
})
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
fetchMessageCapping(
@SessionParam session: WhatsappSession,
): Promise<MessageCappingData> {
return session.fetchMessageCapping();
}
@Get(':session/timelock')
@SessionApiParam
@ApiOperation({
summary: 'Fetch the account reachout timelock state',
description:
'Fetch a fresh reachout timelock state from WhatsApp - the restriction ' +
'behind "server returned error 463" when messaging new contacts. ' +
'The same value is also available under me.reachoutTimelock in the session ' +
'info, and changes are pushed through the session.status event.',
})
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
fetchReachoutTimelock(
@SessionParam session: WhatsappSession,
): Promise<ReachoutTimelockData> {
return session.fetchReachoutTimelock();
}
@Post('')
@ApiOperation({
summary: 'Create a session',
@@ -168,10 +204,14 @@ class SessionsController {
summary: 'Logout from the session',
description: 'Logout the session, restart a session if it was not STOPPED',
})
@ApiBody({ type: SessionLogoutRequest, required: false })
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async logout(@Param('session') name: string): Promise<SessionDTO> {
return this.sessionService.logoutSession(name);
async logout(
@Param('session') name: string,
@Body() request?: SessionLogoutRequest,
): Promise<SessionDTO> {
return this.sessionService.logoutSession(name, request);
}
@Post(':session/restart')
+1 -1
View File
@@ -1,4 +1,4 @@
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
export const SessionExamples = {
basic: {
+8 -3
View File
@@ -97,7 +97,7 @@ export class WebsocketGatewayCore
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const events: WAHAEvents[] = params.events;
const events = params.events as WAHAEvents[];
this.logger.debug(
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
);
@@ -131,8 +131,13 @@ export class WebsocketGatewayCore
const paramsEvents = query.getAll('events');
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
const eventsList = eventsRaw.flatMap((value) => value.split(','));
const events = this.eventUnmask.unmask(eventsList);
return { session, events };
const result = this.eventUnmask.unmask(eventsList);
if (result.unknown.length > 0) {
this.logger.warn(
`Ignoring unknown websocket events: ${result.unknown.join(', ')}`,
);
}
return { session, events: result.events };
}
handleDisconnect(socket: WebSocket): any {
+1 -1
View File
@@ -52,7 +52,7 @@ export class JobLoggerWrapper implements ILogger {
this.job
.log(`[${timestamp}] ${level.toUpperCase()}: ${msg}`)
.catch((err) => {
this.logger.error('Error logging message to job', err);
this.logger.error(err, 'Error logging message to job');
});
}
}
+49 -5
View File
@@ -14,7 +14,9 @@ import {
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import {
AppsService,
IAppsService,
@@ -26,12 +28,13 @@ import {
CanServer,
CanSession,
FromBody,
FromParam,
FromQuery,
} from '@waha/core/auth/policies';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { APPS } from '../apps/definition';
import { GetApp } from '../apps/registry';
import { App } from '../dto/app.dto';
import { ListAppsQuery } from '../dto/query.dto';
@@ -44,6 +47,7 @@ export class AppsController {
@Inject(AppsService)
private appsService: IAppsService,
private manager: SessionManager,
private resolver: UniqueAppResolver,
) {}
@Get('/')
@@ -63,7 +67,11 @@ export class AppsController {
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning && app.enabled && APPS[app.app].restartOnChange) {
if (
isRunning &&
app.enabled &&
GetApp(app.app)?.definition.restartOnChange
) {
await this.manager.restart(app.session);
}
return result;
@@ -114,7 +122,7 @@ export class AppsController {
const result = await this.appsService.upsert(this.manager, app);
const isRunning = this.manager.isRunning(result.session);
if (isRunning && APPS[result.app].restartOnChange) {
if (isRunning && GetApp(result.app)?.definition.restartOnChange) {
await this.manager.restart(result.session);
}
return result;
@@ -134,8 +142,44 @@ export class AppsController {
}
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning && APPS[app.app].restartOnChange) {
if (isRunning && GetApp(app.app)?.definition.restartOnChange) {
await this.manager.restart(app.session);
}
}
@Post('/:id/purge')
@ApiOperation({
summary: 'Purge app storage by app ID',
description:
"Delete the app's stored data (database rows, caches) while keeping the app configured.",
})
@CheckPolicies(CanServer(Action.Retrieve))
@UsePipes(new WAHAValidationPipe())
async purge(@Param('id') id: string, @Req() req: any): Promise<App> {
const existing = await this.appsService.get(this.manager, id);
if (!existing) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.App, new SessionName(existing.session))) {
throw new ForbiddenException();
}
return await this.appsService.purge(this.manager, id);
}
@Post('/:app/:session/purge')
@ApiOperation({
summary: 'Purge app storage by app name and session',
description:
"Delete the unique app's stored data for the session. The app must be enabled.",
})
@ApiParam({ name: 'app', enum: AppName })
@CheckPolicies(CanSession(Action.App, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async purgeUniqueApp(
@Param('app') appName: string,
@Param('session') session: string,
): Promise<App> {
const app = await this.resolver.getEnabledApp(session, appName);
return await this.appsService.purge(this.manager, app.id);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
export interface ApiTag {
name: string;
description: string;
}
/**
* OpenAPI tags for all registered apps - one "🧩 Apps: {Title}" tag per app,
* so app-specific endpoints get their own section in Swagger.
* Includes disabled apps too (only their openapi metadata is used) - some app
* controllers are served even when apps are disabled ('disabledControllers');
* their description is prefixed with "DISABLED" to make the state visible.
*/
export function GetAppsApiTags(): ApiTag[] {
return GetApps().map((app) => {
const enabled = AppRuntimeConfig.HasApp(app.name);
const description = enabled
? app.openapi.description
: `DISABLED - ${app.openapi.description}`;
return {
name: AppApiTag(app.openapi),
description: description,
};
});
}
+8 -8
View File
@@ -1,15 +1,15 @@
import { AppEnv } from '@waha/apps/app_sdk/env';
import { AppDefinition, APPS } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
class AppRuntimeConfigC {
private constructor(private apps: AppDefinition[] | null) {}
private constructor(private apps: AppModule[] | null) {}
static FromEnv(env: typeof AppEnv) {
if (!env.enabled) {
return new AppRuntimeConfigC(null);
}
let apps = Object.values(APPS);
let apps: AppModule[] = GetApps();
// Include
if (env.on && env.on.length > 0) {
apps = apps.filter((app) => env.on!.includes(app.name));
@@ -30,18 +30,18 @@ class AppRuntimeConfigC {
}
GetAppsWithMigration() {
return this.GetApps().filter((app) => app.migrations);
return this.GetApps().filter((app) => app.definition.migrations);
}
GetAppsRequiringPlainKey() {
return this.GetApps().filter((app) => app.plainkey);
return this.GetApps().filter((app) => app.definition.plainkey);
}
GetAppsRequiringQueue() {
return this.GetApps().filter((app) => app.queue);
return this.GetApps().filter((app) => app.definition.queue);
}
HasApp(name: AppName) {
HasApp(name: string) {
return this.GetApps().some((app) => app.name === name);
}
+37
View File
@@ -0,0 +1,37 @@
import { ArgentinePhoneNumbersAppConfig } from '@waha/apps/argentine-phone-numbers/dto';
import { Type } from '@nestjs/common';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { MexicanPhoneNumbersAppConfig } from '@waha/apps/mexican-phone-numbers/dto';
import { PhoneNumbersAppConfig } from '@waha/apps/phone-numbers/dto/config.dto';
export enum AppName {
argentinePhoneNumbers = 'argentine-phone-numbers',
brazilianPhoneNumbers = 'brazilian-phone-numbers',
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
mexicanPhoneNumbers = 'mexican-phone-numbers',
phoneNumbers = 'phone-numbers',
}
/**
* DTO classes used to transform and validate App.config, by app name.
* Kept separate from the registry so DTOs (imported by core structures) can resolve config classes
* without pulling in every app module (controllers, services, queues) - that creates require cycles.
*/
export const AppConfigClasses: Record<AppName, Type<any>> = {
[AppName.argentinePhoneNumbers]: ArgentinePhoneNumbersAppConfig,
[AppName.brazilianPhoneNumbers]: BrazilianPhoneNumbersAppConfig,
[AppName.phoneNumbers]: PhoneNumbersAppConfig,
[AppName.calls]: CallsAppConfig,
[AppName.chatwoot]: ChatWootAppConfig,
[AppName.mcp]: McpAppConfig,
[AppName.mexicanPhoneNumbers]: MexicanPhoneNumbersAppConfig,
};
export function GetAppConfigClass(name: AppName): Type<any> {
return AppConfigClasses[name] ?? Object;
}
+51 -27
View File
@@ -1,8 +1,10 @@
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { Type } from '@nestjs/common';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { AppOpenAPI } from '@waha/apps/app_sdk/apps/openapi';
import { GetApp } from '@waha/apps/app_sdk/apps/registry';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
export interface AppDefinition {
// App name
name: AppName;
// If app requires WAHA_API_KEY_PLAIN to work
plainkey: boolean;
// If app requires queue to work
@@ -11,29 +13,51 @@ export interface AppDefinition {
migrations: boolean;
// If adding, updating, or removing this app requires a session restart
restartOnChange: boolean;
// If only one instance of this app is allowed per session
unique: boolean;
}
// All Apps
export const APPS: Record<AppName, AppDefinition> = {
[AppName.calls]: {
name: AppName.calls,
plainkey: false,
queue: false,
migrations: false,
restartOnChange: true,
},
[AppName.chatwoot]: {
name: AppName.chatwoot,
plainkey: true,
queue: true,
migrations: true,
restartOnChange: true,
},
[AppName.mcp]: {
name: AppName.mcp,
plainkey: false,
queue: false,
migrations: false,
restartOnChange: false,
},
};
// NestJS module parts, included when the app is enabled in runtime configuration
export interface AppNestJS {
imports: any[];
controllers: any[];
providers: any[];
}
/**
* Contract for the default export of 'src/apps/<name>/app.module.ts'.
* Each app self-describes with this and gets listed in the registry ('apps/registry.ts').
*/
export interface AppModule {
// App name
name: AppName;
// OpenAPI metadata (tag title, description) from 'src/apps/<name>/openapi.ts'
openapi: AppOpenAPI;
// Static app metadata (requirements, behavior flags)
definition: AppDefinition;
// NestJS module parts
nestjs: AppNestJS;
// Service implementing app lifecycle hooks; must also be listed in 'nestjs.providers'
Service: Type<IAppService>;
}
export function isUniqueApp(name: AppName): boolean {
return GetApp(name)?.definition.unique === true;
}
// Returns the first unique AppName that appears more than once in the list, or null
export function findDuplicateUniqueApp(
apps: Array<{ app: AppName }>,
): AppName | null {
const seen = new Set<AppName>();
for (const app of apps) {
if (!isUniqueApp(app.app)) {
continue;
}
if (seen.has(app.app)) {
return app.app;
}
seen.add(app.app);
}
return null;
}
-5
View File
@@ -1,5 +0,0 @@
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
}
+14
View File
@@ -0,0 +1,14 @@
/**
* OpenAPI metadata for an app, defined inline in the app's AppModule ('openapi' field).
* The registry applies the "🧩 Apps: {Title}" tag to every controller in 'nestjs.controllers',
* so app controllers do not declare @ApiTags themselves - a new app only touches its own folder.
*/
export interface AppOpenAPI {
title: string;
description: string;
}
// OpenAPI tag for app-specific endpoints - "🧩 Apps: {Title}"
export function AppApiTag(openapi: AppOpenAPI): string {
return `🧩 Apps: ${openapi.title}`;
}
+40
View File
@@ -0,0 +1,40 @@
import ArgentinePhoneNumbersAppModule from '@waha/apps/argentine-phone-numbers/app.module';
import { ApiTags } from '@nestjs/swagger';
import BrazilianPhoneNumbersAppModule from '@waha/apps/brazilian-phone-numbers/app.module';
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
import CallsAppModule from '@waha/apps/calls/app.module';
import ChatWootAppModule from '@waha/apps/chatwoot/app.module';
import McpAppModule from '@waha/apps/mcp/app.module';
import MexicanPhoneNumbersAppModule from '@waha/apps/mexican-phone-numbers/app.module';
import PhoneNumbersAppModule from '@waha/apps/phone-numbers/app.module';
/**
* Registry of available apps.
* Add new apps here - the rest of app_sdk works off this list.
*/
const APPS: AppModule[] = [
ArgentinePhoneNumbersAppModule,
BrazilianPhoneNumbersAppModule,
CallsAppModule,
ChatWootAppModule,
McpAppModule,
MexicanPhoneNumbersAppModule,
PhoneNumbersAppModule,
];
// Tag every app controller with the app's OpenAPI tag ("🧩 Apps: {Title}") from AppModule.openapi,
// so controllers do not declare @ApiTags themselves (they cannot import their own app.module - require cycle).
for (const app of APPS) {
for (const controller of app.nestjs.controllers) {
ApiTags(AppApiTag(app.openapi))(controller);
}
}
export function GetApps(): AppModule[] {
return APPS;
}
export function GetApp(name: string): AppModule | undefined {
return APPS.find((app) => app.name === name);
}
+12 -39
View File
@@ -1,6 +1,3 @@
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { Type } from 'class-transformer';
import {
IsBoolean,
@@ -10,15 +7,13 @@ import {
ValidateNested,
} from 'class-validator';
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import {
AppConfigClasses,
AppName,
GetAppConfigClass,
} from '@waha/apps/app_sdk/apps/apps';
export type AllowedAppConfig =
| ChatWootAppConfig
| CallsAppConfig
| McpAppConfig;
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig, McpAppConfig)
export class App<T extends AllowedAppConfig = any> {
export class App<T = any> {
@IsString()
id: string;
@@ -41,36 +36,14 @@ export class App<T extends AllowedAppConfig = any> {
@ValidateNested()
@Type((options) => {
if (options && options.object && options.object.app) {
switch (options.object.app) {
case AppName.chatwoot:
return ChatWootAppConfig;
case AppName.calls:
return CallsAppConfig;
case AppName.mcp:
return McpAppConfig;
default:
return Object;
}
const name = options?.object?.app;
if (!name) {
return Object;
}
return Object;
return GetAppConfigClass(name);
})
config: T;
}
export class ChatWootAppDto extends App<ChatWootAppConfig> {
@Type(() => ChatWootAppConfig)
config: ChatWootAppConfig;
}
export class CallsAppDto extends App<CallsAppConfig> {
@Type(() => CallsAppConfig)
config: CallsAppConfig;
}
export class McpAppDto extends App<McpAppConfig> {
@Type(() => McpAppConfig)
config: McpAppConfig;
}
export type AppDto = ChatWootAppDto | CallsAppDto | McpAppDto;
// Swagger models for app configs
ApiExtraModels(...Object.values(AppConfigClasses))(App);
+3 -3
View File
@@ -1,9 +1,9 @@
import { parseBool } from '@waha/helpers';
import { APPS } from '@waha/apps/app_sdk/apps/definition';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
// Apps that don't require queue (Redis) - derived from AppDefinition
const IN_MEMORY_APPS = Object.values(APPS)
.filter((app) => !app.queue)
const IN_MEMORY_APPS = GetApps()
.filter((app) => !app.definition.queue)
.map((app) => app.name);
function parseCommaSeparatedList(value: string | undefined): string[] {
@@ -41,6 +41,14 @@ export class AppsDisabledService implements IAppsService {
throw new AppsIsDisabledError();
}
async purge(manager: SessionManager, appId: string): Promise<App> {
throw new AppsIsDisabledError();
}
async purgeBySession(manager: SessionManager, session: string) {
return;
}
async removeBySession(manager: SessionManager, session: string) {
return;
}
+77 -41
View File
@@ -1,15 +1,12 @@
import {
Injectable,
NotFoundException,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
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 { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
@@ -19,8 +16,12 @@ import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { App } from '../dto/app.dto';
import { AppRepository } from '../storage/AppRepository';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import {
findDuplicateUniqueApp,
isUniqueApp,
} from '@waha/apps/app_sdk/apps/definition';
import { GetApp } from '@waha/apps/app_sdk/apps/registry';
export class AppDisableError extends UnprocessableEntityException {
constructor(app: string) {
@@ -30,14 +31,21 @@ export class AppDisableError extends UnprocessableEntityException {
}
}
export class AppUniquePerSessionError extends UnprocessableEntityException {
constructor(app: string, session: string, existingAppId: string) {
super(
`Only one '${app}' app is allowed per session. ` +
`Session '${session}' already has a '${app}' app with ID '${existingAppId}'.`,
);
}
}
@Injectable()
export class AppsEnabledService implements IAppsService {
constructor(
@InjectPinoLogger('AppsService')
protected logger: PinoLogger,
@Optional() protected readonly chatwootService: ChatWootAppService,
@Optional() protected readonly callsAppService: CallsAppService,
@Optional() protected readonly mcpAppService: McpAppService,
private readonly moduleRef: ModuleRef,
) {}
async list(manager: SessionManager, session: string): Promise<App[]> {
@@ -64,31 +72,17 @@ export class AppsEnabledService implements IAppsService {
throw new Error(`App with ID '${app.id}' already exists.`);
}
let existingApps: App[] = [];
if (app.app === AppName.chatwoot || app.app === AppName.calls) {
existingApps = await repo.getAllBySession(app.session);
}
// Validate only one Chatwoot app per session
if (app.app === AppName.chatwoot) {
const existingChatwootApp = existingApps.find(
(existingApp) => existingApp.app === AppName.chatwoot,
// Validate only one instance of a unique app per session
if (isUniqueApp(app.app)) {
const existingApps = await repo.getAllBySession(app.session);
const duplicateApp = existingApps.find(
(existingApp) => existingApp.app === app.app,
);
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}'.`,
);
}
}
// Validate only one Calls app per session
if (app.app === AppName.calls) {
const existingCallsApp = existingApps.find(
(existingApp) => existingApp.app === AppName.calls,
);
if (existingCallsApp) {
throw new Error(
`Only one Calls app is allowed per session. Session '${app.session}' already has a Calls app with ID '${existingCallsApp.id}'.`,
if (duplicateApp) {
throw new AppUniquePerSessionError(
app.app,
app.session,
duplicateApp.id,
);
}
}
@@ -192,6 +186,35 @@ export class AppsEnabledService implements IAppsService {
return app;
}
async purge(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`);
}
const service = this.getAppService(app);
if (!service) {
throw new AppDisableError(app.app);
}
await service.purge(manager, app);
delete app.pk;
return app;
}
async purgeBySession(manager: SessionManager, session: string) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const apps = await repo.getAllBySession(session);
for (const app of apps) {
const service = this.getAppService(app);
if (!service) {
continue;
}
await service.purge(manager, app);
}
}
async removeBySession(manager: SessionManager, session: string) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
@@ -212,6 +235,10 @@ export class AppsEnabledService implements IAppsService {
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
const plugins = service.plugins(app, session, store);
for (const options of plugins) {
session.plugins.add(options, app.id);
}
service.beforeSessionStart(app, session);
}
}
@@ -234,6 +261,15 @@ export class AppsEnabledService implements IAppsService {
session: string,
apps: App[],
): Promise<void> {
// Reject duplicate unique apps in the payload before any writes,
// otherwise the by-type matching below binds them to the same app
const duplicateUniqueApp = findDuplicateUniqueApp(apps);
if (duplicateUniqueApp !== null) {
throw new UnprocessableEntityException(
`Only one '${duplicateUniqueApp}' app is allowed per session - remove duplicate entries from 'apps'.`,
);
}
const existing = await this.list(manager, session);
const ids = new Set<string>();
@@ -266,15 +302,15 @@ export class AppsEnabledService implements IAppsService {
}
private getAppService(app: App): IAppService | null {
switch (app.app) {
case AppName.chatwoot:
return this.chatwootService;
case AppName.calls:
return this.callsAppService;
case AppName.mcp:
return this.mcpAppService;
default:
throw new Error(`App '${app.app}' not supported`);
const appModule = GetApp(app.app);
if (!appModule) {
throw new Error(`App '${app.app}' not supported`);
}
try {
return this.moduleRef.get(appModule.Service, { strict: false });
} catch {
// Provider is not registered - app is disabled via WAHA_APPS_ON / WAHA_APPS_OFF
return null;
}
}
+19
View File
@@ -1,6 +1,8 @@
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 { PluginOptions } from '@waha/core/abc/session.plugin';
/**
* Exact App service
@@ -42,6 +44,12 @@ export interface IAppService {
beforeDeleted(manager: SessionManager, app: App): Promise<void>;
/**
* Deletes the app's stored data (database rows, caches) while keeping the app configured.
* Apps without storage implement it as a no-op.
*/
purge(manager: SessionManager, app: App): Promise<void>;
/**
* Called for each app before a bulk session deletion removes all app records.
* Use this to clean up external resources tied to the app (e.g. API keys).
@@ -54,6 +62,17 @@ export interface IAppService {
*/
enrich(manager: SessionManager, app: App): Promise<void>;
/**
* Session plugins contributed by this app. AppsEnabledService registers them with the app id,
* and the session manager attaches their hooks and events before session.start().
* store - the server data store, for apps whose plugins persist data.
*/
plugins(
app: App,
session: WhatsappSession,
store?: DataStore,
): PluginOptions[];
beforeSessionStart(app: App, session: WhatsappSession): void;
afterSessionStart(app: App, session: WhatsappSession): void;
@@ -17,6 +17,10 @@ export interface IAppsService {
delete(manager: SessionManager, appId: string): Promise<App>;
purge(manager: SessionManager, appId: string): Promise<App>;
purgeBySession(manager: SessionManager, session: string): Promise<void>;
removeBySession(manager: SessionManager, session: string): Promise<void>;
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
@@ -0,0 +1,55 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { SessionPlugin } from '@waha/core/abc/session.plugin';
/**
* Resolves the single instance of a unique-per-session app ('definition.unique') by session name,
* so app controllers can expose session-keyed routes ('api/apps/{app}/{session}/...') instead of app-id ones.
* Uniqueness is guaranteed at write time by AppsEnabledService, so the first enabled row is the instance.
* Must not import 'apps/registry.ts' or 'apps/definition.ts' - controllers import this resolver,
* and the registry imports app modules (with their controllers), which would create a require cycle.
*/
@Injectable()
export class UniqueAppResolver {
constructor(private readonly manager: SessionManager) {}
/**
* Enabled app row for the session; 404 if the app is missing or disabled.
* Does not require the session to be running - persistent app data stays accessible for stopped sessions.
*/
async getEnabledApp(sessionName: string, appName: string): Promise<AppDB> {
const knex = this.manager.store.getWAHADatabase();
const repository = new AppRepository(knex);
const app = await repository.getEnabledBySessionAndApp(
sessionName,
appName,
);
if (!app) {
throw new NotFoundException(
`App '${appName}' is not enabled for session '${sessionName}'`,
);
}
return app;
}
/**
* Live plugin instance for the app, or null when the session is not running (best-effort access).
*/
getPlugin<Plugin extends SessionPlugin<any, any>>(
app: AppDB,
PluginClass: abstract new (...args: any[]) => Plugin,
): Plugin | null {
if (!this.manager.isRunning(app.session)) {
return null;
}
let session;
try {
session = this.manager.getSession(app.session);
} catch {
return null;
}
return session.plugins.get(PluginClass, app.id);
}
}
+19
View File
@@ -83,6 +83,25 @@ export class AppRepository {
return this.deserialize(app);
}
/**
* Gets the enabled app of the given type for a session.
* Intended for unique-per-session apps - returns the first match.
*/
async getEnabledBySessionAndApp(
session: string,
appName: string,
): Promise<AppDB | null> {
const app = await this.knex(this.tableName)
.where('session', session)
.andWhere('app', appName)
.andWhere('enabled', true)
.first();
if (!app) {
return null;
}
return this.deserialize(app);
}
/**
* Gets all apps
*/
+2
View File
@@ -1,5 +1,6 @@
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
@@ -9,6 +10,7 @@ export const AppsDisabled = {
provide: AppsService,
useClass: AppsDisabledService,
},
UniqueAppResolver,
],
imports: [],
controllers: [AppsController, ChatwootLocalesController],
+15 -32
View File
@@ -4,17 +4,20 @@ import { RMutexModule } from '@waha/modules/rmutex';
import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
import { McpModuleExports } from '@waha/apps/mcp/mcp.module';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { CallsAppExports } from '@waha/apps/calls/calls.module';
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { Auth } from '@waha/core/auth/config';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
const QUEUES_IMPORTS_REQUIRED = [
HttpPathsRegistration(
{ prefix: '/jobs', include: { authBasic: false } },
{ prefix: '/jobs/', include: { authBasic: false, accessLog: false } },
),
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
@@ -71,47 +74,27 @@ const QUEUES_IMPORTS = AppRuntimeConfig.HasAppsRequiringQueue()
? QUEUES_IMPORTS_REQUIRED
: [];
function getAppModule(name: AppName) {
if (!AppRuntimeConfig.HasApp(name)) {
return {
imports: [],
controllers: [],
providers: [],
};
}
switch (name) {
case AppName.calls:
return CallsAppExports;
case AppName.chatwoot:
return ChatWootExports;
case AppName.mcp:
return McpModuleExports;
default:
throw Error(`App module not found for ${name}`);
}
}
// Apps enabled in the runtime configuration (WAHA_APPS_ON / WAHA_APPS_OFF)
const ENABLED_APPS = GetApps().filter((app) =>
AppRuntimeConfig.HasApp(app.name),
);
export const AppsEnabled = {
imports: [
...QUEUES_IMPORTS,
...getAppModule(AppName.mcp).imports,
...getAppModule(AppName.chatwoot).imports,
...getAppModule(AppName.calls).imports,
...ENABLED_APPS.flatMap((app) => app.nestjs.imports),
],
controllers: [
AppsController,
...getAppModule(AppName.mcp).controllers,
...getAppModule(AppName.chatwoot).controllers,
...getAppModule(AppName.calls).controllers,
...ENABLED_APPS.flatMap((app) => app.nestjs.controllers),
],
providers: [
{
provide: AppsService,
useClass: AppsEnabledService,
},
...getAppModule(AppName.mcp).providers,
...getAppModule(AppName.calls).providers,
...getAppModule(AppName.chatwoot).providers,
UniqueAppResolver,
...ENABLED_APPS.flatMap((app) => app.nestjs.providers),
],
};
@@ -0,0 +1,28 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { ArgentinePhoneNumbersController } from '@waha/apps/argentine-phone-numbers/controller';
import { ArgentinePhoneNumbersAppService } from '@waha/apps/argentine-phone-numbers/services/ArgentinePhoneNumbersAppService';
const ArgentinePhoneNumbersAppModule: AppModule = {
name: AppName.argentinePhoneNumbers,
openapi: {
title: 'Phone Numbers: Argentina',
description:
'Resolve Argentine phone numbers (with and without the mobile 9)',
},
definition: {
plainkey: false,
queue: false,
migrations: true,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: [],
controllers: [ArgentinePhoneNumbersController],
providers: [ArgentinePhoneNumbersAppService],
},
Service: ArgentinePhoneNumbersAppService,
};
export default ArgentinePhoneNumbersAppModule;
@@ -0,0 +1,21 @@
import { Controller, UseGuards } from '@nestjs/common';
import { ApiSecurity } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { ArgentinePhoneNumbersAppService } from '@waha/apps/argentine-phone-numbers/services/ArgentinePhoneNumbersAppService';
import { PhoneNumbersCacheController } from '@waha/apps/phone-numbers/api/PhoneNumbersCacheController';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
@ApiSecurity('api_key')
@Controller('api/apps/argentine-phone-numbers/:session')
@UseGuards(PoliciesGuard)
export class ArgentinePhoneNumbersController extends PhoneNumbersCacheController {
constructor(
manager: SessionManager,
resolver: UniqueAppResolver,
appService: ArgentinePhoneNumbersAppService,
) {
super(manager, resolver, appService, AppName.argentinePhoneNumbers);
}
}
+3
View File
@@ -0,0 +1,3 @@
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
export class ArgentinePhoneNumbersAppConfig extends PhoneNumbersBaseConfig {}
@@ -0,0 +1,9 @@
import { migrations_001_init_cache } from '@waha/apps/phone-numbers/storage/migrations_001_init_cache';
const migration = migrations_001_init_cache({
table: 'app_argentine_phone_numbers_cache',
index: 'arphone',
});
exports.up = migration.up;
exports.down = migration.down;
@@ -0,0 +1,106 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { ArgentinePhoneNumberRules } from '@waha/apps/argentine-phone-numbers/rules/ArgentinePhoneNumberRules';
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
import {
buildPlugin as buildPhoneNumbersPlugin,
resolveChat,
stubLookup,
} from '@waha/apps/phone-numbers/plugins/testing';
function buildPlugin(config: Partial<PhoneNumbersBaseConfig> = {}) {
return buildPhoneNumbersPlugin({
config: config,
rules: ArgentinePhoneNumberRules(),
});
}
describe('ArgentinePhoneNumbers', () => {
it('uses the supplied form when it exists, without checking the other one', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: true, chatId: '541112345678@c.us' });
const resolved = await resolveChat(session, '+541112345678');
expect(resolved).toBe('541112345678@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
expect(session.checkNumberStatus).toHaveBeenCalledWith({
phone: '541112345678',
session: 'test',
});
});
it('tries the form with 9 after the supplied one is absent, keeps a LID answer', async () => {
const { session } = buildPlugin();
session.checkNumberStatus = jest
.fn()
.mockResolvedValueOnce({ numberExists: false })
.mockResolvedValueOnce({ numberExists: true, chatId: '123456@lid' });
const resolved = await resolveChat(session, '541112345678@c.us');
expect(resolved).toBe('123456@lid');
expect(session.checkNumberStatus).toHaveBeenLastCalledWith({
phone: '5491112345678',
session: 'test',
});
// Both forms hit the cache now
expect(await resolveChat(session, '5491112345678@c.us')).toBe('123456@lid');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
});
it('tries the form without 9 as the fallback', async () => {
const { session } = buildPlugin();
session.checkNumberStatus = jest
.fn()
.mockResolvedValueOnce({ numberExists: false })
.mockResolvedValueOnce({
numberExists: true,
chatId: '542920123456@c.us',
});
const resolved = await resolveChat(session, '5492920123456@c.us');
expect(resolved).toBe('542920123456@c.us');
});
it('prefers the pn when the engine answers with both pn and a LID', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '123@lid',
pn: '5491112345678@c.us',
});
expect(await resolveChat(session, '541112345678@c.us')).toBe(
'5491112345678@c.us',
);
});
it('soft mode keeps the supplied form when both are absent, strict rejects', async () => {
const soft = buildPlugin();
stubLookup(soft.session, { numberExists: false });
expect(await resolveChat(soft.session, '541112345678@c.us')).toBe(
'541112345678@c.us',
);
expect(soft.session.checkNumberStatus).toHaveBeenCalledTimes(2);
const strict = buildPlugin({ strict: true });
stubLookup(strict.session, { numberExists: false });
await expect(
resolveChat(strict.session, '541112345678@c.us'),
).rejects.toThrow(UnprocessableEntityException);
});
it('leaves local forms and other countries untouched', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
expect(await resolveChat(session, '1112345678@c.us')).toBe(
'1112345678@c.us',
);
expect(await resolveChat(session, '5511912345678@c.us')).toBe(
'5511912345678@c.us',
);
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
import { ArgentinePhoneNumberRules } from './ArgentinePhoneNumberRules';
describe('ArgentinePhoneNumberRules', () => {
const rules = ArgentinePhoneNumberRules();
function resolve(digits: string) {
const rule = rules.find((r) => r.matches(digits));
return rule?.resolve(digits) ?? null;
}
it.each([
['541112345678', ['541112345678', '5491112345678']],
['5491112345678', ['5491112345678', '541112345678']],
['543511234567', ['543511234567', '5493511234567']],
['5492920123456', ['5492920123456', '542920123456']],
])('keeps the supplied form first for %s', (digits, expected) => {
expect(resolve(digits)).toEqual({ candidates: expected, fallback: digits });
});
it.each([
'0111512345678',
'1112345678',
'91112345678',
'54111512345678',
'548001234567',
'54911123',
'54911123456789',
'5511912345678',
'',
])('does not match %s', (digits) => {
expect(resolve(digits)).toBeNull();
});
});
@@ -0,0 +1,13 @@
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
import { RegexpPhoneNumberRule } from '@waha/apps/phone-numbers/rules/RegexpPhoneNumberRule';
/**
* Argentina: mobiles have a 9 after the country code, callers send either form - check the supplied one first.
* 54 + area code (1-3...) + number, 10 digits, international form only
*/
export function ArgentinePhoneNumberRules(): PhoneNumberRule[] {
return [
new RegexpPhoneNumberRule('^54([1-3]\\d{9})$', '549$1'),
new RegexpPhoneNumberRule('^549([1-3]\\d{9})$', '54$1'),
];
}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { ArgentinePhoneNumbersAppConfig } from '@waha/apps/argentine-phone-numbers/dto';
import { ArgentinePhoneNumberRules } from '@waha/apps/argentine-phone-numbers/rules/ArgentinePhoneNumberRules';
import { ArgentinePhoneNumbersCacheRepository } from '@waha/apps/argentine-phone-numbers/storage/ArgentinePhoneNumbersCacheRepository';
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
import { PhoneNumbersAppServiceBase } from '@waha/apps/phone-numbers/services/PhoneNumbersAppServiceBase';
@Injectable()
export class ArgentinePhoneNumbersAppService extends PhoneNumbersAppServiceBase<ArgentinePhoneNumbersAppConfig> {
protected readonly Repository = ArgentinePhoneNumbersCacheRepository;
constructor(resolver: UniqueAppResolver) {
super(resolver);
}
protected rules(config: ArgentinePhoneNumbersAppConfig): PhoneNumberRule[] {
void config;
return ArgentinePhoneNumberRules();
}
}
@@ -0,0 +1,5 @@
import { PhoneNumbersCacheRepository } from '@waha/apps/phone-numbers/storage/PhoneNumbersCacheRepository';
export class ArgentinePhoneNumbersCacheRepository extends PhoneNumbersCacheRepository {
static tableName = 'app_argentine_phone_numbers_cache';
}
@@ -0,0 +1,27 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { BrazilianPhoneNumbersController } from '@waha/apps/brazilian-phone-numbers/controller';
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
const BrazilianPhoneNumbersAppModule: AppModule = {
name: AppName.brazilianPhoneNumbers,
openapi: {
title: 'Phone Numbers: Brazil',
description: 'Resolve Brazilian phone numbers (with and without 9 digit)',
},
definition: {
plainkey: false,
queue: false,
migrations: true,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: [],
controllers: [BrazilianPhoneNumbersController],
providers: [BrazilianPhoneNumbersAppService],
},
Service: BrazilianPhoneNumbersAppService,
};
export default BrazilianPhoneNumbersAppModule;
@@ -0,0 +1,21 @@
import { Controller, UseGuards } from '@nestjs/common';
import { ApiSecurity } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
import { PhoneNumbersCacheController } from '@waha/apps/phone-numbers/api/PhoneNumbersCacheController';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
@ApiSecurity('api_key')
@Controller('api/apps/brazilian-phone-numbers/:session')
@UseGuards(PoliciesGuard)
export class BrazilianPhoneNumbersController extends PhoneNumbersCacheController {
constructor(
manager: SessionManager,
resolver: UniqueAppResolver,
appService: BrazilianPhoneNumbersAppService,
) {
super(manager, resolver, appService, AppName.brazilianPhoneNumbers);
}
}
+3
View File
@@ -0,0 +1,3 @@
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
export class BrazilianPhoneNumbersAppConfig extends PhoneNumbersBaseConfig {}
@@ -0,0 +1,9 @@
import { migrations_001_init_cache } from '@waha/apps/phone-numbers/storage/migrations_001_init_cache';
const migration = migrations_001_init_cache({
table: 'app_brazilian_phone_numbers_cache',
index: 'brphone',
});
exports.up = migration.up;
exports.down = migration.down;
@@ -0,0 +1,431 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { BrazilianPhoneNumberRule } from '@waha/apps/brazilian-phone-numbers/rules/BrazilianPhoneNumberRule';
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
import { PhoneNumbersGowsPlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersGowsPlugin';
import { PhoneNumbersNowebPlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersNowebPlugin';
import { PhoneNumbersCorePlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersCorePlugin';
import {
buildPlugin as buildPhoneNumbersPlugin,
resolveChat,
stubLookup,
} from '@waha/apps/phone-numbers/plugins/testing';
interface BuildOptions {
config?: Partial<PhoneNumbersBaseConfig>;
repository?: any;
pluginClass?: typeof PhoneNumbersCorePlugin;
}
function buildPlugin(options: BuildOptions = {}) {
return buildPhoneNumbersPlugin({
...options,
rules: [new BrazilianPhoneNumberRule()],
});
}
describe('BrazilianPhoneNumbers - wid.chat', () => {
it('resolves and caches the canonical phone when the engine answers with a PN', async () => {
// '558591203123' is the real number: DDD 85 with an 8-digit local part.
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
const first = await resolveChat(session, '5585991203123@c.us');
const second = await resolveChat(session, '5585991203123@c.us');
expect(first).toBe('558591203123@c.us');
expect(second).toBe('558591203123@c.us');
// Second call is served from cache - no extra WhatsApp lookup.
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
expect(session.checkNumberStatus).toHaveBeenCalledWith({
phone: '558591203123',
session: 'test',
});
});
it('prefers the pn form when the engine answers with both pn and a LID', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '77820596330581@lid',
pn: '558591203123@c.us',
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
});
it('caches a LID answer as-is instead of stapling a phone suffix on it', async () => {
// Engines answer with a LID only when the account has no phone form. The
// LID is routable, but '77820596330581@c.us' - its digits with a phone
// suffix - addresses nobody.
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '77820596330581@lid',
});
const first = await resolveChat(session, '5585991203123@c.us');
const second = await resolveChat(session, '5585991203123@c.us');
expect(first).toBe('77820596330581@lid');
expect(second).toBe('77820596330581@lid');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('reuses the cache across both forms of the same number', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
// Wrong form first, then the already-correct one: same target, one lookup.
const wrongForm = await resolveChat(session, '5585991203123@c.us');
const rightForm = await resolveChat(session, '558591203123@c.us');
expect(wrongForm).toBe('558591203123@c.us');
expect(rightForm).toBe('558591203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('shares one in-flight lookup between concurrent sends (single-flight)', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
const [first, second] = await Promise.all([
resolveChat(session, '5585991203123@c.us'),
resolveChat(session, '558591203123@c.us'),
]);
expect(first).toBe('558591203123@c.us');
expect(second).toBe('558591203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('rewrites a dialed toll-free (0800) to the stored form, no lookup', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
// Dialed forms with and without the country code, plus the already-stored
// form, all address the same chat id - and none hits the WhatsApp lookup.
expect(await resolveChat(session, '08000464636@c.us')).toBe(
'558000464636@c.us',
);
expect(await resolveChat(session, '5508000464636@c.us')).toBe(
'558000464636@c.us',
);
expect(await resolveChat(session, '558000464636@c.us')).toBe(
'558000464636@c.us',
);
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('applies the static 9th-digit rule for DDD below the lookup range, no lookup', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
const resolved = await resolveChat(session, '551198765432@c.us');
expect(resolved).toBe('5511998765432@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('leaves numbers of other countries untouched', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
const resolved = await resolveChat(session, '12132132130@c.us');
expect(resolved).toBe('12132132130@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('passes recursion-sensitive methods through untouched', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
const viaCheck = await resolveChat(
session,
'5585991203123@c.us',
'checkNumberStatus',
);
const viaLidMap = await resolveChat(
session,
'5585991203123@c.us',
'findLIDByPhoneNumber',
);
expect(viaCheck).toBe('5585991203123@c.us');
expect(viaLidMap).toBe('5585991203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('resolves non-send methods locally only - no lookup, cache still honored', async () => {
const { session } = buildPlugin();
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
// Cache miss on a local-only method: input goes through unresolved.
const cold = await resolveChat(
session,
'5585991203123@c.us',
'startTyping',
);
expect(cold).toBe('5585991203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
// A send resolves and caches; the local-only method now sees the cache.
await resolveChat(session, '5585991203123@c.us', 'sendText');
const warm = await resolveChat(
session,
'5585991203123@c.us',
'startTyping',
);
expect(warm).toBe('558591203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('throws 422 for a malformed number on send, passes it through otherwise', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
await expect(resolveChat(session, '55859912@c.us')).rejects.toThrow(
UnprocessableEntityException,
);
const readOp = await resolveChat(session, '55859912@c.us', 'getPresence');
expect(readOp).toBe('55859912@c.us');
});
it('soft mode: sends the best-guess when the number is confirmed not to exist', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
const resolved = await resolveChat(session, '558591203123@c.us');
// DDD 85 keeps the 8-digit heuristic as the best-guess.
expect(resolved).toBe('558591203123@c.us');
// Both candidates were tried before giving up.
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
});
it('strict mode: rejects a confirmed-nonexistent number and caches the negative', async () => {
const { session } = buildPlugin({ config: { strict: true } });
stubLookup(session, { numberExists: false });
await expect(resolveChat(session, '5585991203123@c.us')).rejects.toThrow(
UnprocessableEntityException,
);
const lookups = session.checkNumberStatus.mock.calls.length;
// The negative is cached - the retry rejects without a new lookup...
await expect(resolveChat(session, '5585991203123@c.us')).rejects.toThrow(
UnprocessableEntityException,
);
expect(session.checkNumberStatus).toHaveBeenCalledTimes(lookups);
// ...and local-only methods still pass the number through.
const readOp = await resolveChat(
session,
'5585991203123@c.us',
'getPresence',
);
expect(readOp).toBe('5585991203123@c.us');
});
it('sends as-is without caching when the lookup fails', async () => {
const { session } = buildPlugin();
session.checkNumberStatus = jest.fn(async () => {
throw new Error('engine down');
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('5585991203123@c.us');
const lookups = session.checkNumberStatus.mock.calls.length;
// Not cached: the next send retries the lookup.
await resolveChat(session, '5585991203123@c.us');
expect(session.checkNumberStatus.mock.calls.length).toBeGreaterThan(
lookups,
);
});
it('lookup: false disables the WhatsApp tier', async () => {
const { session } = buildPlugin({ config: { lookup: false } });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('5585991203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
});
describe('BrazilianPhoneNumbers - wid.mention', () => {
it('resolves mentions best-effort and never breaks the send', async () => {
const { session } = buildPlugin({ config: { strict: true } });
stubLookup(session, { numberExists: false });
// Strict mode rejects the number on the chat hook, but a mention falls
// back to the input instead of failing the whole message.
const mention = await session.hooks.wid.mention.promise(
'5585991203123@c.us',
'sendText',
);
expect(mention).toBe('5585991203123@c.us');
});
});
describe('BrazilianPhoneNumbers - persistent cache tier', () => {
function buildRepository() {
return {
get: jest.fn().mockResolvedValue(null),
setMany: jest.fn().mockResolvedValue(undefined),
};
}
it('serves a persisted resolution without any lookup and warms the memory tier', async () => {
const repository = buildRepository();
repository.get.mockResolvedValue({
key: '5585991203123',
chatId: '558591203123@c.us',
verified: true,
resolvedAt: new Date(),
});
const { session } = buildPlugin({ repository: repository });
stubLookup(session, { numberExists: false });
const first = await resolveChat(session, '5585991203123@c.us');
const second = await resolveChat(session, '5585991203123@c.us');
expect(first).toBe('558591203123@c.us');
expect(second).toBe('558591203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
// Second call hits the memory tier - the database is read once.
expect(repository.get).toHaveBeenCalledTimes(1);
});
it('persists verified resolutions under both forms of the number', async () => {
const repository = buildRepository();
const { session } = buildPlugin({ repository: repository });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
await resolveChat(session, '5585991203123@c.us');
expect(repository.setMany).toHaveBeenCalledTimes(1);
const [keys, chatId, verified] = repository.setMany.mock.calls[0];
expect([...keys].sort()).toEqual(['558591203123', '5585991203123']);
expect(chatId).toBe('558591203123@c.us');
expect(verified).toBe(true);
});
it('does not persist best-guesses or static-rule rewrites', async () => {
const repository = buildRepository();
const { session } = buildPlugin({ repository: repository });
stubLookup(session, { numberExists: false });
// Static rule (DDD 11) and a confirmed-nonexistent best-guess (DDD 85).
await resolveChat(session, '551198765432@c.us');
await resolveChat(session, '558591203123@c.us');
expect(repository.setMany).not.toHaveBeenCalled();
});
it('resolves normally when the persistent tier errors out', async () => {
const repository = buildRepository();
repository.get.mockRejectedValue(new Error('db down'));
const { session } = buildPlugin({ repository: repository });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
});
});
describe('BrazilianPhoneNumbers GOWS - local LID map tier', () => {
it('resolves via the LID map with no WhatsApp lookup', async () => {
const { session } = buildPlugin({ pluginClass: PhoneNumbersGowsPlugin });
stubLookup(session, { numberExists: false });
session.findLIDByPhoneNumber = jest.fn(async (phone: string) => {
if (phone === '558591203123') {
return { lid: '77820596330581@lid', pn: '558591203123@c.us' };
}
return { lid: null, pn: null };
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('falls through to the WhatsApp lookup for unknown numbers', async () => {
const { session } = buildPlugin({ pluginClass: PhoneNumbersGowsPlugin });
stubLookup(session, {
numberExists: true,
chatId: '5585991203123@c.us',
});
session.findLIDByPhoneNumber = jest.fn(async () => {
return { lid: null, pn: null };
});
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('5585991203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalled();
});
});
describe('BrazilianPhoneNumbers NOWEB - local contact store tier', () => {
it('resolves via the contact store with no WhatsApp lookup', async () => {
const { session } = buildPlugin({ pluginClass: PhoneNumbersNowebPlugin });
stubLookup(session, { numberExists: false });
session.store = {
getContactById: jest.fn(async (jid: string) => {
if (jid === '558591203123@s.whatsapp.net') {
return { id: '558591203123@s.whatsapp.net' };
}
return null;
}),
};
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('falls through to the WhatsApp lookup when the store has no match', async () => {
const { session } = buildPlugin({ pluginClass: PhoneNumbersNowebPlugin });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
});
session.store = {
getContactById: jest.fn(async () => null),
};
const resolved = await resolveChat(session, '5585991203123@c.us');
expect(resolved).toBe('558591203123@c.us');
expect(session.checkNumberStatus).toHaveBeenCalled();
});
});
@@ -0,0 +1,122 @@
import {
extractPhoneDigits,
shouldSkipPhoneNormalization,
} from '@waha/apps/phone-numbers/utils/phone';
import {
generateBrazilMobileLookupCandidates,
isBrazilCountryCode,
isBrazilLandline,
isBrazilMobile,
isMalformedBrazilPhone,
needsBrazilWhatsAppLookup,
normalizeBrazilMobileForSendDigits,
normalizeBrazilTollFreeDigits,
} from './BrazilianPhoneNumberRule';
describe('BrazilianPhoneNumberRule', () => {
it('skips groups and lids', () => {
expect(shouldSkipPhoneNormalization('123@g.us')).toBe(true);
expect(shouldSkipPhoneNormalization('123@lid')).toBe(true);
});
it('detects BR country code', () => {
expect(isBrazilCountryCode('558591203123')).toBe(true);
expect(isBrazilCountryCode('5491123456789')).toBe(false);
});
it('flags malformed BR numbers and accepts valid lengths', () => {
expect(isMalformedBrazilPhone('55859912')).toBe(true);
expect(isMalformedBrazilPhone('558591203123')).toBe(false);
expect(isMalformedBrazilPhone('5585991203123')).toBe(false);
// not a BR number, not our concern
expect(isMalformedBrazilPhone('123')).toBe(false);
});
it('detects BR landline numbers', () => {
expect(isBrazilLandline('558540423147')).toBe(true);
expect(isBrazilMobile('558540423147')).toBe(false);
});
it('detects BR mobile numbers', () => {
expect(isBrazilMobile('558591203123')).toBe(true);
expect(isBrazilMobile('5585991203123')).toBe(true);
});
it('accepts any digit after the leading 9 on a 9-digit local', () => {
// Brazil has no 9-digit landline, so '9' + 8 digits is always mobile.
// The old rule required 6-9 right after the 9 and rejected real SP lines.
expect(isBrazilMobile('5511953523741')).toBe(true);
expect(isBrazilLandline('5511953523741')).toBe(false);
expect(isBrazilMobile('5511912345678')).toBe(true);
expect(isBrazilMobile('5511902345678')).toBe(true);
});
it('keeps 9-digit mobiles out of the lookup range untouched', () => {
// DDD 11 is below the lookup range: no candidates, no WhatsApp lookup.
expect(needsBrazilWhatsAppLookup('5511953523741')).toBe(false);
expect(normalizeBrazilMobileForSendDigits('5511953523741')).toBe(
'5511953523741',
);
});
it('detects landlines in both DDD ranges', () => {
expect(isBrazilLandline('551151923057')).toBe(true);
expect(isBrazilLandline('558540428310')).toBe(true);
expect(isBrazilMobile('551151923057')).toBe(false);
expect(isBrazilMobile('558540428310')).toBe(false);
});
it('rewrites toll-free (0800) numbers to the stored form', () => {
// Dialed '0800' + 7 digits -> stored '55800' + 7 digits (leading 0 dropped,
// country code added). Both the bare and the 55-prefixed dialed forms map
// to the same stored number the server resolves them to.
expect(normalizeBrazilTollFreeDigits('08000464636')).toBe('558000464636');
expect(normalizeBrazilTollFreeDigits('5508000464636')).toBe('558000464636');
});
it('leaves non-toll-free and already-stored numbers untouched', () => {
// Already-stored form routes through normally, so no rewrite here.
expect(normalizeBrazilTollFreeDigits('558000464636')).toBeNull();
expect(normalizeBrazilTollFreeDigits('5585991203123')).toBeNull();
expect(normalizeBrazilTollFreeDigits('551151923057')).toBeNull();
// 0300/0500/0900 share the shape but are not handled here.
expect(normalizeBrazilTollFreeDigits('03001234567')).toBeNull();
});
it('requires lookup only for DDD 31-99 mobile numbers', () => {
expect(needsBrazilWhatsAppLookup('5511987654321')).toBe(false);
expect(needsBrazilWhatsAppLookup('558591203123')).toBe(true);
expect(needsBrazilWhatsAppLookup('558540423147')).toBe(false);
});
it('keeps send heuristic without 9 for DDD 31-99 until lookup resolves', () => {
expect(normalizeBrazilMobileForSendDigits('558591203123')).toBe(
'558591203123',
);
expect(normalizeBrazilMobileForSendDigits('555399034520')).toBe(
'555399034520',
);
});
it('normalizes low DDD mobile numbers for send with 9 digits', () => {
expect(normalizeBrazilMobileForSendDigits('551198765432')).toBe(
'5511998765432',
);
});
it('generates with and without 9 candidates', () => {
expect(generateBrazilMobileLookupCandidates('558591203123')).toEqual([
'558591203123',
'5585991203123',
]);
expect(generateBrazilMobileLookupCandidates('5585991203123')).toEqual([
'558591203123',
'5585991203123',
]);
});
it('extracts digits from chat ids', () => {
expect(extractPhoneDigits('558591203123@c.us')).toBe('558591203123');
expect(extractPhoneDigits('+558591203123')).toBe('558591203123');
});
});
@@ -0,0 +1,166 @@
import {
PhoneNumberResolution,
PhoneNumberRule,
} from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
const COUNTRY_CODE = '55';
const LANDLINE_FIRST_DIGIT = /^[2-5]/;
const MOBILE_FIRST_DIGIT = /^[6-9]/;
export const BR_PHONE_DDD_LOOKUP_MIN = 31;
export const BR_PHONE_DDD_LOOKUP_MAX = 99;
// A Brazil number (country code 55) must be 55 + DDD(2) + local(8 or 9) digits.
const BR_PHONE_MIN_LENGTH = 12;
const BR_PHONE_MAX_LENGTH = 13;
export function isBrazilCountryCode(digits: string): boolean {
return digits.startsWith(COUNTRY_CODE);
}
export function isMalformedBrazilPhone(digits: string): boolean {
if (!isBrazilCountryCode(digits)) {
return false;
}
return (
digits.length < BR_PHONE_MIN_LENGTH || digits.length > BR_PHONE_MAX_LENGTH
);
}
export function isBrazilPhone(digits: string): boolean {
return digits.startsWith(COUNTRY_CODE) && digits.length >= 12;
}
export function getBrazilDdd(digits: string): number {
return parseInt(digits.substring(2, 4), 10);
}
export function getBrazilLocalPart(digits: string): string {
return digits.substring(4);
}
export function isBrazilLandline(digits: string): boolean {
if (!isBrazilPhone(digits)) {
return false;
}
const local = getBrazilLocalPart(digits);
if (local.length !== 8) {
return false;
}
return LANDLINE_FIRST_DIGIT.test(local);
}
export function isBrazilMobile(digits: string): boolean {
if (!isBrazilPhone(digits) || isBrazilLandline(digits)) {
return false;
}
const local = getBrazilLocalPart(digits);
// 8-digit local: the legacy form, missing its 9. Only 6-9 tells it apart
// from a landline - 2-5 is genuinely ambiguous and treated as a landline.
if (local.length === 8) {
return MOBILE_FIRST_DIGIT.test(local);
}
// 9-digit local starting with 9: unambiguously mobile. Brazil has no
// 9-digit landline, and the digit after the leading 9 is unrestricted -
// e.g. SP 11 9535-23741. Do not test it.
if (local.length === 9 && local[0] === '9') {
return true;
}
return false;
}
// Brazilian toll-free (0800) numbers are non-geographic: dialed as '0800' plus
// 7 subscriber digits. WhatsApp stores them under country code 55 with the
// leading 0 dropped ('08000464636' -> '558000464636'). Callers send the dialed
// form, so map it to the stored one. The rewrite is deterministic - no WhatsApp
// lookup, unlike the 9th-digit mobile case. Returns null when not a toll-free
// number in a dialed form (the stored form '55800...' already routes untouched).
const BR_TOLLFREE_DIALED = /^0800\d{7}$/;
const BR_TOLLFREE_DIALED_CC = /^550800\d{7}$/;
export function normalizeBrazilTollFreeDigits(digits: string): string | null {
if (BR_TOLLFREE_DIALED.test(digits)) {
return `${COUNTRY_CODE}${digits.slice(1)}`;
}
if (BR_TOLLFREE_DIALED_CC.test(digits)) {
return `${COUNTRY_CODE}${digits.slice(3)}`;
}
return null;
}
export function needsBrazilWhatsAppLookup(digits: string): boolean {
if (!isBrazilMobile(digits)) {
return false;
}
const ddd = getBrazilDdd(digits);
return ddd >= BR_PHONE_DDD_LOOKUP_MIN && ddd <= BR_PHONE_DDD_LOOKUP_MAX;
}
export function normalizeBrazilMobileForSendDigits(digits: string): string {
if (!isBrazilMobile(digits)) {
return digits;
}
const ddd = getBrazilDdd(digits);
if (ddd >= BR_PHONE_DDD_LOOKUP_MIN) {
return digits;
}
const local = getBrazilLocalPart(digits);
if (local.length === 8 && MOBILE_FIRST_DIGIT.test(local)) {
return `${COUNTRY_CODE}${ddd}9${local}`;
}
return digits;
}
export function generateBrazilMobileLookupCandidates(digits: string): string[] {
if (!isBrazilMobile(digits)) {
return [digits];
}
const ddd = digits.substring(2, 4);
const local = getBrazilLocalPart(digits);
let without9 = digits;
let with9 = digits;
if (local.length === 9 && local[0] === '9') {
without9 = `${COUNTRY_CODE}${ddd}${local.substring(1)}`;
with9 = `${COUNTRY_CODE}${ddd}${local}`;
} else if (local.length === 8) {
without9 = `${COUNTRY_CODE}${ddd}${local}`;
with9 = `${COUNTRY_CODE}${ddd}9${local}`;
}
const candidates = [without9, with9];
return [...new Set(candidates)];
}
/**
* Brazil: mobiles got a 9th digit, callers send either form - static rule for DDD < 31, lookup for the rest
*/
export class BrazilianPhoneNumberRule implements PhoneNumberRule {
matches(digits: string): boolean {
// Dialed toll-free has no country code
return isBrazilCountryCode(digits) || BR_TOLLFREE_DIALED.test(digits);
}
resolve(digits: string): PhoneNumberResolution | null {
// Before the length check - dialed '0800...' is shorter than a full number
const tollFree = normalizeBrazilTollFreeDigits(digits);
if (tollFree) {
return { candidates: [], fallback: tollFree };
}
if (isMalformedBrazilPhone(digits)) {
return null;
}
// Landlines are sent as is
if (!isBrazilMobile(digits)) {
return { candidates: [], fallback: digits };
}
const normalized = normalizeBrazilMobileForSendDigits(digits);
if (!needsBrazilWhatsAppLookup(digits)) {
return { candidates: [], fallback: normalized };
}
return {
candidates: generateBrazilMobileLookupCandidates(digits),
fallback: normalized,
};
}
}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto';
import { BrazilianPhoneNumberRule } from '@waha/apps/brazilian-phone-numbers/rules/BrazilianPhoneNumberRule';
import { BrazilianPhoneNumbersCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneNumbersCacheRepository';
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
import { PhoneNumbersAppServiceBase } from '@waha/apps/phone-numbers/services/PhoneNumbersAppServiceBase';
@Injectable()
export class BrazilianPhoneNumbersAppService extends PhoneNumbersAppServiceBase<BrazilianPhoneNumbersAppConfig> {
protected readonly Repository = BrazilianPhoneNumbersCacheRepository;
constructor(resolver: UniqueAppResolver) {
super(resolver);
}
protected rules(config: BrazilianPhoneNumbersAppConfig): PhoneNumberRule[] {
void config;
return [new BrazilianPhoneNumberRule()];
}
}
@@ -0,0 +1,5 @@
import { PhoneNumbersCacheRepository } from '@waha/apps/phone-numbers/storage/PhoneNumbersCacheRepository';
export class BrazilianPhoneNumbersCacheRepository extends PhoneNumbersCacheRepository {
static tableName = 'app_brazilian_phone_numbers_cache';
}
+26
View File
@@ -0,0 +1,26 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
const CallsAppModule: AppModule = {
name: AppName.calls,
openapi: {
title: 'Calls',
description: 'Handle incoming calls - reject, message back',
},
definition: {
plainkey: false,
queue: false,
migrations: false,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: [],
controllers: [],
providers: [CallsAppService],
},
Service: CallsAppService,
};
export default CallsAppModule;
-7
View File
@@ -1,7 +0,0 @@
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
export const CallsAppExports = {
providers: [CallsAppService],
imports: [],
controllers: [],
};
+8
View File
@@ -0,0 +1,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { Type } from 'class-transformer';
export class CallsAppDto extends App<CallsAppConfig> {
@Type(() => CallsAppConfig)
config: CallsAppConfig;
}
+18 -9
View File
@@ -1,19 +1,14 @@
import { Injectable } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { CallsPlugin } from '@waha/apps/calls/services/CallsPlugin';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
@Injectable()
export class CallsAppService implements IAppService {
constructor(
@InjectPinoLogger('CallsAppService')
private readonly logger: PinoLogger,
) {}
validate(app: App<CallsAppConfig>): void {
// The DTO validation covers structure; no extra validation rules.
void app;
@@ -79,6 +74,14 @@ export class CallsAppService implements IAppService {
void app;
}
async purge(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async enrich(
manager: SessionManager,
app: App<CallsAppConfig>,
@@ -87,9 +90,15 @@ export class CallsAppService implements IAppService {
void app;
}
plugins(app: App<CallsAppConfig>, session: WhatsappSession): PluginOptions[] {
void session;
return [CallsPlugin.with(app.config, null)];
}
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
const listener = new CallsListener(app, session, this.logger);
listener.attach();
void app;
void session;
return;
}
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
@@ -1,62 +1,29 @@
import { Subscription } from 'rxjs';
import {
CallsAppChannelConfig,
CallsAppConfig,
} from '@waha/apps/calls/dto/config.dto';
import { Logger } from 'pino';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { PinoLogger } from 'nestjs-pino';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
import { SessionPlugin } from '@waha/core/abc/session.plugin';
import { PluginEvent } from '@waha/core/abc/session.plugin.events';
import { CallData } from '@waha/structures/calls.dto';
import { MessageTextRequest } from '@waha/structures/chatting.dto';
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { Observable } from 'rxjs';
export class CallsListener {
private subscription?: Subscription;
private config: CallsAppConfig;
private readonly log: Logger;
private readonly session: WhatsappSession;
constructor(
app: App<CallsAppConfig>,
session: WhatsappSession,
logger: PinoLogger,
) {
this.session = session;
this.config = app.config;
this.log = logger.logger.child({
app: 'calls',
session: app.session,
});
}
attach(): void {
this.detach();
const observable = this.session.getEventObservable(
WAHAEvents.CALL_RECEIVED,
);
if (!observable) {
this.log.warn('CALL_RECEIVED event stream is not available, skipping');
return;
}
this.subscription = observable.subscribe((payload) => {
this.handleCall(payload as CallData).catch((error) => {
this.log.error(
{ err: error, callId: (payload as any)?.id },
/**
* Rejects incoming calls and optionally sends an auto-reply message.
*/
export class CallsPlugin extends SessionPlugin<CallsAppConfig> {
@PluginEvent(WAHAEvents.CALL_RECEIVED)
onCallReceived(calls$: Observable<CallData>) {
calls$.subscribe((call: CallData) => {
this.handleCall(call).catch((error) => {
this.logger.error(
{ err: error, callId: call?.id },
'Failed to handle incoming call',
);
});
});
this.log.info('Calls app listener is attached');
}
detach(): void {
this.subscription?.unsubscribe();
this.subscription = undefined;
}
private configFor(call: CallData): CallsAppChannelConfig {
@@ -65,17 +32,17 @@ export class CallsListener {
private async handleCall(call: CallData): Promise<void> {
if (!call.from) {
this.log.warn({ call: call?.id }, 'Incoming call has no chat id');
this.logger.warn({ call: call?.id }, 'Incoming call has no chat id');
return;
}
if (!call.id) {
this.log.warn({ from: call.from }, 'Incoming call has no from');
this.logger.warn({ from: call.from }, 'Incoming call has no from');
return;
}
const config = this.configFor(call);
if (!config) {
this.log.warn({ callId: call.id }, 'No calls config found, skipping');
this.logger.warn({ callId: call.id }, 'No calls config found, skipping');
return;
}
@@ -84,7 +51,7 @@ export class CallsListener {
const shouldMessage = message.length > 0;
if (!shouldReject && !shouldMessage) {
this.log.debug(
this.logger.debug(
{ callId: call.id, chatId: call.from },
'No actions configured for this call',
);
@@ -105,16 +72,19 @@ export class CallsListener {
}
private async rejectCall(call: CallData): Promise<void> {
this.log.debug({ from: call.from, id: call.id }, 'Rejecting incoming call');
this.logger.debug(
{ from: call.from, id: call.id },
'Rejecting incoming call',
);
await this.session.rejectCall(call.from, call.id);
this.log.info({ from: call.from, id: call.id }, 'Call rejected');
this.logger.info({ from: call.from, id: call.id }, 'Call rejected');
}
private async replyWithTyping(
chatId: string,
message: string,
): Promise<void> {
this.log.info(
this.logger.info(
{ chatId: chatId },
'Sending auto-response for rejected call',
);
@@ -132,7 +102,7 @@ export class CallsListener {
try {
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
} catch (error) {
this.log.warn(
this.logger.warn(
{ err: error, chatId: chatId },
'Failed to set typing presence before reply',
);
@@ -146,7 +116,7 @@ export class CallsListener {
try {
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
} catch (error) {
this.log.warn(
this.logger.warn(
{ err: error, chatId: chatId },
'Failed to clear typing presence after reply',
);
@@ -1,6 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { sortBy } from 'lodash';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiSecurity } from '@nestjs/swagger';
import { i18n } from '@waha/apps/chatwoot/i18n';
interface LanguageResponse {
@@ -10,7 +10,6 @@ interface LanguageResponse {
@Controller('api/apps/chatwoot')
@ApiSecurity('api_key')
@ApiTags('🧩 Apps')
export class ChatwootLocalesController {
@Get('locales')
@ApiOperation({
@@ -5,7 +5,7 @@ import {
Param,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiOperation } from '@nestjs/swagger';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
@@ -13,9 +13,9 @@ import { ChatWootQueueService } from '@waha/apps/chatwoot/services/ChatWootQueue
import { SessionManager } from '@waha/core/abc/manager.abc';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
import { IsExternalEcho } from '@waha/apps/chatwoot/api/webhook.guards';
@Controller('webhooks/chatwoot/')
@ApiTags('🧩 Apps')
export class ChatwootWebhookController {
constructor(
private readonly chatWootQueueService: ChatWootQueueService,
@@ -42,8 +42,13 @@ export class ChatwootWebhookController {
}
const isCommandsChat = IsCommandsChat(body);
// Ignore private notes (most of them)
const deleted = body?.content_attributes?.deleted;
// Ignore messages that came from WhatsApp, do not send them back
if (IsExternalEcho(body) && !deleted) {
return { success: true };
}
// Ignore private notes (most of them)
if (body.private) {
// Ignore any private note in commands chats
if (isCommandsChat) {
+15
View File
@@ -0,0 +1,15 @@
import { conversation_message_create } from '@figuro/chatwoot-sdk';
/**
* Mark a message WAHA creates from a WhatsApp message, so the webhook does not send it back
*/
export function SetExternalEcho(body: conversation_message_create) {
body.content_attributes = { ...body.content_attributes, external_echo: true };
}
/**
* Message was created by WAHA from a WhatsApp message, not by an agent
*/
export function IsExternalEcho(body: any): boolean {
return Boolean(body?.content_attributes?.external_echo);
}
@@ -1,3 +1,5 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { RegisterAppQueue } from '@waha/apps/app_sdk/BullUtils';
import {
ExponentialRetriesJobOptions,
@@ -37,10 +39,15 @@ import { TaskContactsPullConsumer } from '@waha/apps/chatwoot/consumers/task/con
import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull';
import { BullModule } from '@nestjs/bullmq';
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
const IMPORTS = lodash.flatten([
HttpPathsRegistration({
prefix: '/webhooks/',
include: { authBasic: false },
}),
BullModule.registerFlowProducer({
name: FlowProducerName.MESSAGES_PULL_FLOW,
}),
@@ -160,8 +167,25 @@ const PROVIDERS = [
QueueManager,
];
export const ChatWootExports = {
providers: PROVIDERS,
imports: IMPORTS,
controllers: CONTROLLERS,
const ChatWootAppModule: AppModule = {
name: AppName.chatwoot,
openapi: {
title: 'Chatwoot',
description: 'Chatwoot integration',
},
definition: {
plainkey: true,
queue: true,
migrations: true,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: IMPORTS,
controllers: CONTROLLERS,
providers: PROVIDERS,
},
Service: ChatWootAppService,
};
export default ChatWootAppModule;
+10
View File
@@ -14,6 +14,7 @@ import {
import { ContactsPullOptions } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
import { JobsOptions } from 'bullmq';
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
import { NameUpdateMode } from '@waha/apps/chatwoot/client/ContactService';
export function AddContactsCommand(program: Command, ctx: CommandContext) {
const l = ctx.l;
@@ -39,6 +40,14 @@ export function AddContactsCommand(program: Command, ctx: CommandContext) {
.choices(['if-missing', 'update'])
.preset('if-missing'),
)
.addOption(
new Option(
'-n, --update-names <mode>',
l.r('cli.cmd.contacts.pull.option.update-names'),
)
.choices(Object.values(NameUpdateMode))
.default(NameUpdateMode.NO),
)
.option('-g, --groups', l.r('cli.cmd.contacts.pull.option.groups'))
.option('-l, --lids', l.r('cli.cmd.contacts.pull.option.lids'))
.option(
@@ -92,6 +101,7 @@ export function AddContactsCommand(program: Command, ctx: CommandContext) {
batch: opts.batch,
progress: opts.progress,
avatar: opts.avatar,
updateNames: opts.updateNames,
attributes: opts.attributes,
contacts: {
lids: opts.lids,
@@ -24,6 +24,36 @@ import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
/**
* Reuse an already fetched WhatsApp contact instead of requesting it again
*/
SetFetchedContact(contact: any): void;
/**
* Linked id - @lid, null when unknown
*/
LidId(): Promise<string | null>;
/**
* Phone number jid - @c.us, null when unknown
*/
JidId(): Promise<string | null>;
/**
* Resolved E.164 phone number, null when not applicable
*/
PhoneNumberE164(): Promise<string | null>;
/**
* Name saved in the phone book, null when not saved
*/
SavedName(): Promise<string | null>;
/**
* Name the contact set for themselves, null when unknown
*/
PushName(): Promise<string | null>;
AvatarUrl(): Promise<string | null>;
Attributes(): Promise<any>;
@@ -1,4 +1,7 @@
import { sanitizeName } from '@waha/apps/chatwoot/client/ContactService';
import {
IsRawName,
sanitizeName,
} from '@waha/apps/chatwoot/client/ContactService';
describe('sanitizeName', () => {
it('should return the same name', () => {
@@ -21,3 +24,36 @@ describe('sanitizeName', () => {
expect(sanitizeName(name)).toBe(name);
});
});
describe('IsRawName', () => {
const placeholders = [
'11111111111@c.us',
'11111111111@c.us',
'011111111111111@lid',
'+11111111111',
'+11111111111',
'Johnny',
];
const cases: Array<{ name: string; expected: boolean }> = [
{ name: '', expected: true },
{ name: ' ', expected: true },
{ name: '11111111111@c.us', expected: true },
{ name: '11111111111', expected: true },
{ name: '+11111111111', expected: true },
{ name: '011111111111111@lid', expected: true },
{ name: '011111111111111', expected: true },
{ name: 'Johnny', expected: true },
{ name: ' johnny ', expected: true },
{ name: 'John Doe', expected: false },
{ name: 'John (Sales)', expected: false },
];
it.each(cases)('$name', ({ name, expected }) => {
expect(IsRawName(name, placeholders)).toBe(expected);
});
it('treats a nameless contact with no ids as placeholder', () => {
expect(IsRawName('', [null, undefined])).toBe(true);
expect(IsRawName('John', [null, undefined])).toBe(false);
});
});
+161 -57
View File
@@ -26,6 +26,43 @@ export enum AvatarUpdateMode {
ALWAYS,
}
export enum NameUpdateMode {
// skip name updates, only new contacts get a name (phone book, push name or phone number)
NO = 'no',
// keep names set by hand, replace raw ones (phone number, push name) from the phone book
IF_RAW = 'if-raw',
// the phone book is the source of truth, overwrite any name
ALWAYS = 'always',
}
/**
* Raw name - empty or one of the ids the contact was created with, nobody typed it by hand
*/
export function IsRawName(
name: string,
placeholders: Array<string | null | undefined>,
): boolean {
const current = normalizeName(name);
if (!current) {
return true;
}
for (const placeholder of placeholders) {
if (!placeholder) {
continue;
}
const value = normalizeName(placeholder);
const bare = value.replace(/@.*$/, '').replace(/^\+/, '');
if (current === value || current === bare || current === `+${bare}`) {
return true;
}
}
return false;
}
function normalizeName(name: string): string {
return name.trim().toLowerCase();
}
export function sanitizeName(name: string) {
// 255 chars max
const limit = 255;
@@ -40,6 +77,18 @@ export function sanitizeName(name: string) {
return clean.slice(0, 255).trim();
}
function SearchClauseEqualTo(key: string, values: string[]) {
// equal_to with multiple values acts as IN
return {
attribute_key: key,
filter_operator: 'equal_to',
values: values,
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
};
}
export class ContactService {
constructor(
private config: ChatWootAPIConfig,
@@ -52,8 +101,11 @@ export class ContactService {
contactInfo: ContactInfo,
): Promise<[ContactResponse, boolean]> {
const chatId = contactInfo.ChatId();
let contact = await this.searchByAnyID(chatId);
const lid = await contactInfo.LidId();
const jid = await contactInfo.JidId();
let contact = await this.search(chatId, lid, jid);
if (contact) {
await this.upsertPhoneNumber(contact, contactInfo);
return [contact, false];
}
@@ -62,73 +114,94 @@ export class ContactService {
return [contact, true];
}
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
const phoneNumberE164 = E164Parser.fromJid(chatId);
const phone_number = phoneNumberE164.replace('+', '');
payload[payload.length - 1].query_operator = 'OR';
payload.push({
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [phone_number],
});
async search(
chatId: string,
lid: string | null,
jid: string | null,
): Promise<ContactResponse | null> {
// The chat id attribute holds the latest used address, so match any known form there
const chatIds = lodash.uniq(lodash.compact([chatId, jid, lid]));
if (chatIds.length == 0) {
return null;
}
const payload: any[] = [
SearchClauseEqualTo(AttributeKey.WA_CHAT_ID, chatIds),
SearchClauseEqualTo('identifier', chatIds),
];
if (jid) {
payload.push(SearchClauseEqualTo(AttributeKey.WA_JID, [jid]));
}
if (lid) {
payload.push(SearchClauseEqualTo(AttributeKey.WA_LID, [lid]));
}
if (jid && isJidCusFormat(jid)) {
// Search by phone - both with and without the leading '+'
const phoneNumberE164 = E164Parser.fromJid(jid);
let phones = [phoneNumberE164, phoneNumberE164.replace('+', '')];
payload.push(SearchClauseEqualTo('phone_number', phones));
}
// The terminal clause must have no query_operator
delete payload[payload.length - 1].query_operator;
const response: any = await this.accountAPI.contacts.filter({
accountId: this.config.accountId,
payload: payload as any,
});
const contacts = response.payload;
if (contacts.length == 0) {
const candidates: ContactResponse[] = [];
for (const contact of contacts) {
const inboxes = lodash.filter(contact.contact_inboxes, {
inbox: { id: this.config.inboxId },
});
if (inboxes.length == 0) {
continue;
}
candidates.push({
data: contact,
sourceId: inboxes[0].source_id,
});
}
if (candidates.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;
// Prefer a contact with a phone number over a phone-less duplicate
const withPhone = candidates.find((candidate) =>
Boolean(candidate.data.phone_number),
);
return withPhone ?? candidates[0];
}
private async upsertPhoneNumber(
contact: ContactResponse,
contactInfo: ContactInfo,
): Promise<void> {
if (contact.data.phone_number) {
return;
}
const phoneNumberE164 = await contactInfo.PhoneNumberE164();
if (!phoneNumberE164) {
return;
}
try {
await this.accountAPI.contacts.update({
id: contact.data.id,
accountId: this.config.accountId,
data: { phone_number: phoneNumberE164 },
});
contact.data.phone_number = phoneNumberE164;
this.logger.info(
`Set phone_number for contact.id: ${
contact.data.id
}, chat.id: ${contactInfo.ChatId()}`,
);
} catch (err) {
// Chatwoot returns 422 when another contact already owns the phone number
this.logger.warn(
`Error updating phone_number for contact.id: ${contact.data.id} - ${err}`,
);
}
return {
data: contact,
sourceId: inboxes[0].source_id,
};
}
public async upsertCustomAttributes(
@@ -149,6 +222,37 @@ export class ContactService {
return true;
}
/**
* Set the contact name, returns true when it changed
*/
public async updateName(
contact: ContactResponse,
name: string,
): Promise<boolean> {
name = sanitizeName(name);
const current = contact.data.name ?? '';
if (!name || current.trim() === name.trim()) {
return false;
}
try {
await this.accountAPI.contacts.update({
id: contact.data.id,
accountId: this.config.accountId,
data: { name: name },
});
} catch (err) {
this.logger.warn(
`Error updating name for contact.id: ${contact.data.id} - ${err}`,
);
return false;
}
this.logger.info(
`Renamed contact.id: ${contact.data.id}: '${current}' => '${name}'`,
);
contact.data.name = name;
return true;
}
public async create(
chatId: string,
payload: public_contact_create_update_payload,
+16 -1
View File
@@ -1,6 +1,7 @@
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';
import type { conversation_message_update } from '@figuro/chatwoot-sdk/dist/models/conversation_message_update';
import { MessageStatus, MessageType } from '@waha/apps/chatwoot/client/types';
export class Conversation {
public onError: (e: any) => void;
@@ -43,6 +44,20 @@ export class Conversation {
return this.send(data);
}
/**
* Update message status (sent/delivered/read/failed)
*/
public async updateMessageStatus(messageId: number, status: MessageStatus) {
// The SDK model has no "status" field, but the endpoint accepts only status (and external_error)
const data = { status: status } as unknown as conversation_message_update;
return this.accountAPI.messages.update({
accountId: this.accountId,
conversationId: this.conversationId,
messageId: messageId,
data: data,
});
}
public async activity(text: string) {
const data: conversation_message_create = {
content: text,
+1 -1
View File
@@ -1,7 +1,7 @@
import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
import * as lodash from 'lodash';
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
import { buildMessageId } from '@waha/core/engines/noweb/session.noweb.core';
import { buildMessageId } from '@waha/core/engines/noweb/utils';
import { isLidUser, normalizeJid } from '@waha/core/utils/jids';
export function GetJID(contact: any): string | null {
+7
View File
@@ -36,6 +36,13 @@ export enum MessageType {
ACTIVITY = 'activity',
}
export enum MessageStatus {
SENT = 'sent',
DELIVERED = 'delivered',
READ = 'read',
FAILED = 'failed',
}
export enum CustomAttributeType {
TEXT = 0,
NUMBER = 1,
@@ -38,6 +38,7 @@ import {
} from '@waha/apps/chatwoot/dto/config.dto';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { isJidGroup } from '@waha/core/utils/jids';
import { MessageStatusService } from '@waha/apps/chatwoot/services/MessageStatusService';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime-types');
@@ -65,6 +66,7 @@ export class ChatWootInboxMessageCreatedConsumer extends ChatWootInboxMessageCon
session,
container.ChatWootConfig(),
container.Locale(),
container.MessageStatusService(),
);
return await handler.handle(body);
}
@@ -77,6 +79,7 @@ export class MessageHandler {
private session: WAHASessionAPI,
private config: ChatWootConfig,
private l: Locale,
private statusService: MessageStatusService,
) {}
async handle(body: any) {
@@ -114,6 +117,7 @@ export class MessageHandler {
// Send text (Part 1 if present)
const attachments = message.attachments || [];
const sendText = content && attachments.length !== 1;
const parts = attachments.length + (sendText ? 1 : 0);
if (sendText) {
part += 1; // Text is the first possible part
const exists = await this.getMapping(message, part);
@@ -129,7 +133,7 @@ export class MessageHandler {
});
const msg = await this.sendTextMessage(chatId, text, replyTo, mentions);
results.push(msg);
await this.saveMapping(message, msg, part);
await this.saveMapping(message, msg, part, parts);
this.logger.info(`Text message sent: ${msg.id}`);
}
}
@@ -155,20 +159,42 @@ export class MessageHandler {
`File message sent: ${msg.id} - ${file.data_url} - ${file.file_type}`,
);
results.push(msg);
await this.saveMapping(message, msg, part);
await this.saveMapping(message, msg, part, parts);
}
if (this.config.conversations.syncMessageStatus) {
await this.syncStatus(message, parts);
}
return results;
}
/**
* Push acks that arrived before the last part was mapped, best effort
*/
private async syncStatus(message: any, parts: number) {
try {
await this.statusService.sync({
conversation_id: message.conversation.id,
message_id: message.id,
parts: parts,
});
} catch (err) {
this.logger.warn(
`ChatWoot => WhatsApp: error syncing status for Chatwoot message ${message.id}: ${err}`,
);
}
}
private async saveMapping(
chatwootMessage: any,
whatsappMessage: any,
part: number,
parts: number,
): Promise<void> {
const chatwoot: Omit<ChatwootMessage, 'id'> = {
timestamp: new Date(chatwootMessage.created_at),
conversation_id: chatwootMessage.conversation.id,
message_id: chatwootMessage.id,
parts: parts,
};
const whatsapp = EngineHelper.WhatsAppMessageKeys(whatsappMessage);
await this.mappingService.map(chatwoot, whatsapp, part);
@@ -221,10 +247,23 @@ export class MessageHandler {
mentions: mentions,
};
const session = this.session;
await session.readMessages(chatId);
await session.startTyping({ chatId: chatId, session: '' });
// Best effort - do not block sending on read-receipt failure
await session.readMessages(chatId).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error reading messages in chat '${chatId}': ${err}`,
);
});
await session.startTyping({ chatId: chatId, session: '' }).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error starting typing in chat '${chatId}': ${err}`,
);
});
await sleep(2000);
await session.stopTyping({ chatId: chatId, session: '' });
await session.stopTyping({ chatId: chatId, session: '' }).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error stopping typing in chat '${chatId}': ${err}`,
);
});
const msg = await session.sendText(request);
return msg;
}
@@ -35,6 +35,7 @@ export class ChatWootInboxMessageUpdatedConsumer extends ChatWootInboxMessageCon
session,
container.ChatWootConfig(),
container.Locale(),
container.MessageStatusService(),
);
return await handler.handle(body);
}
@@ -44,5 +44,7 @@ export class MessageCleanupConsumer extends ChatWootScheduledConsumer {
.MessageMappingService()
.cleanup(removeAfter);
logger.info(`Removed ${removed} mappings for messages`);
const acks = await container.MessageStatusService().cleanup(removeAfter);
logger.info(`Removed ${acks} acks for messages`);
}
}
@@ -17,6 +17,8 @@ import { SortOrder } from '@waha/structures/pagination.dto';
import {
AvatarUpdateMode,
ContactService,
IsRawName,
NameUpdateMode,
} from '@waha/apps/chatwoot/client/ContactService';
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
@@ -34,6 +36,7 @@ export interface ContactsPullOptions {
batch: number;
progress: number | null;
avatar: null | 'if-missing' | 'update';
updateNames: NameUpdateMode;
attributes: boolean;
contacts: {
groups: boolean;
@@ -88,6 +91,7 @@ export class TaskContactsPullConsumer extends ChatWootTaskConsumer {
interface Progress {
created: number;
updated: number;
renamed: number;
skipped: number;
errors: number;
avatar: {
@@ -104,6 +108,7 @@ function total(progress: Progress) {
const NullProgress: Progress = {
created: 0,
updated: 0,
renamed: 0,
skipped: 0,
errors: 0,
avatar: {
@@ -201,12 +206,13 @@ class ContactsPullHandler {
}
this.logger.debug(`Pulling ${chatId}...`);
const result = await this.pullOneContact(options, chatId);
const result = await this.pullOneContact(options, contact);
progress.created += result.created;
progress.updated += result.updated;
progress.renamed += result.renamed;
progress.avatar.updated += result.avatar.updated;
this.logger.info(
`Contact ${chatId}: created=${result.created}, updated=${result.updated}, avatar.updated=${result.avatar.updated}`,
`Contact ${chatId}: created=${result.created}, updated=${result.updated}, renamed=${result.renamed}, avatar.updated=${result.avatar.updated}`,
);
} catch (e) {
this.logger.error(`Error pulling contact ${contact.id}: ${e}`);
@@ -220,11 +226,47 @@ class ContactsPullHandler {
await this.activity.completed(progress);
}
private async pullOneContact(options: ContactsPullOptions, chatId: string) {
// Contact
const contactInfo = WhatsAppContactInfo(this.session, chatId, this.l);
private async pullOneContact(options: ContactsPullOptions, contact: any) {
const contactInfo = WhatsAppContactInfo(this.session, contact.id, this.l);
contactInfo.SetFetchedContact(contact);
let [cwContact, created] =
await this.contactService.findOrCreateContact(contactInfo);
// Name
let renamed = false;
if (!created) {
switch (options.updateNames) {
case NameUpdateMode.ALWAYS: {
const name = await contactInfo.SavedName();
if (name) {
renamed = await this.contactService.updateName(cwContact, name);
}
break;
}
case NameUpdateMode.IF_RAW: {
const name = await contactInfo.SavedName();
if (!name) {
break;
}
// Replace only raw names, nobody typed them by hand
const placeholders = [
contactInfo.ChatId(),
await contactInfo.JidId(),
await contactInfo.LidId(),
await contactInfo.PhoneNumberE164(),
cwContact.data.phone_number,
await contactInfo.PushName(),
];
const current = cwContact.data.name ?? '';
if (!IsRawName(current, placeholders)) {
break;
}
renamed = await this.contactService.updateName(cwContact, name);
break;
}
}
}
// Attributes
if (options.attributes) {
const attributes = await contactInfo.Attributes();
@@ -233,6 +275,7 @@ class ContactsPullHandler {
attributes,
);
}
// Avatar
let avatarUpdated = false;
switch (options.avatar) {
@@ -251,9 +294,11 @@ class ContactsPullHandler {
);
break;
}
return {
created: created ? 1 : 0,
updated: created ? 0 : 1,
renamed: renamed ? 1 : 0,
avatar: {
updated: avatarUpdated ? 1 : 0,
},
@@ -294,6 +294,7 @@ class MessagesPullHandler {
this.session,
container.Locale(),
container.WAHASelf(),
container.OutgoingMode(),
);
handler.force = options.force;
+13
View File
@@ -0,0 +1,13 @@
export function clearContent(attachments) {
/**
* Remove actual base64 "content" from attachment
*/
if (!attachments) {
return attachments;
}
return attachments.map((attachment) => {
attachment = { ...attachment };
attachment.content = '';
return attachment;
});
}
+41 -5
View File
@@ -36,9 +36,11 @@ import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { EnsureSeconds } from '@waha/utils/timehelper';
import { CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS } from '@waha/apps/chatwoot/env';
import {
ChatWootAppConfig,
ChatWootConfig,
ChatWootOutgoingMode,
} from '@waha/apps/chatwoot/dto/config.dto';
import { clearContent } from '@waha/apps/chatwoot/consumers/utils';
import { SetExternalEcho } from '@waha/apps/chatwoot/api/webhook.guards';
export function ListenEventsForChatWoot(config: ChatWootConfig) {
const events = [
@@ -51,7 +53,8 @@ export function ListenEventsForChatWoot(config: ChatWootConfig) {
WAHAEvents.CALL_ACCEPTED,
WAHAEvents.CALL_REJECTED,
];
if (config.conversations.markAsRead) {
const conversations = config.conversations;
if (conversations.markAsRead || conversations.syncMessageStatus) {
events.push(WAHAEvents.MESSAGE_ACK);
}
return events;
@@ -234,6 +237,7 @@ export abstract class MessageBaseHandler<
protected session: WAHASessionAPI,
protected l: Locale,
protected waha: WAHASelf,
protected outgoingMode: ChatWootOutgoingMode,
) {}
protected abstract getMessage(
@@ -319,6 +323,7 @@ export abstract class MessageBaseHandler<
this.session,
EngineHelper.ChatID(payload as any),
this.l,
EngineHelper.PhoneNumber(payload as any),
);
const conversation = await this.repo.ConversationByContact(contactInfo);
this.info.onConversationId(conversation.conversationId);
@@ -329,6 +334,8 @@ export abstract class MessageBaseHandler<
`Created message as '${message.message_type}' from WhatsApp: ${response.id}`,
);
await this.saveMapping(response, payload);
// Clear attachments content to avoid saving it in the task result
message.attachments = clearContent(message.attachments);
return message;
}
@@ -398,10 +405,35 @@ export abstract class MessageBaseHandler<
},
);
const private_ = message.private ?? payload.fromMe;
const type = private_ ? MessageType.OUTGOING : MessageType.INCOMING;
// Send the message as a private note or as a regular message?
let private_ = message.private;
if (private_ === undefined) {
switch (this.outgoingMode) {
case ChatWootOutgoingMode.MESSAGE:
// Regular outgoing message, not a note
private_ = false;
break;
case ChatWootOutgoingMode.PRIVATE_NOTE:
default: // no value - old config, treat it as PRIVATE_NOTE
// Mark my messages as private notes
private_ = Boolean(payload.fromMe);
break;
}
}
// Send the message as incoming or outgoing?
let type: MessageType = MessageType.INCOMING;
if (payload.fromMe) {
// Any message from me is outgoing, private or not
type = MessageType.OUTGOING;
}
if (private_) {
// Private notes can only be sent as outgoing messages
type = MessageType.OUTGOING;
}
content = this.finalizeContent(content, payload);
return {
const body: conversation_message_create = {
content: content,
message_type: type,
private: private_,
@@ -410,6 +442,10 @@ export abstract class MessageBaseHandler<
in_reply_to: replyTo,
},
};
if (payload.fromMe) {
SetExternalEcho(body);
}
return body;
}
async getReplyToChatWootMessageID(
@@ -59,6 +59,7 @@ export class WAHACallAcceptedConsumer extends ChatWootWAHABaseConsumer {
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
container.OutgoingMode(),
);
const msg = BuildCallMessagePayload(
event.payload,
@@ -60,6 +60,7 @@ export class WAHACallReceivedConsumer extends ChatWootWAHABaseConsumer {
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
container.OutgoingMode(),
);
const msg = BuildCallMessagePayload(
event.payload,
@@ -59,6 +59,7 @@ export class WAHACallRejectedConsumer extends ChatWootWAHABaseConsumer {
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
container.OutgoingMode(),
);
const msg = BuildCallMessagePayload(
event.payload,
@@ -14,12 +14,23 @@ import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
import {
WAHAWebhookMessageAck,
WAMessageAckBody,
} from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
import { MultipleErrors } from '@waha/utils/errors';
import {
ShouldMarkAsReadInChatWoot,
ShouldProcessAckInChatWoot,
} from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { MessageMappingService } from '@waha/apps/chatwoot/storage';
import {
ChatwootMessage,
MessageMappingService,
} from '@waha/apps/chatwoot/storage';
import { MessageStatusService } from '@waha/apps/chatwoot/services/MessageStatusService';
@Processor(QueueName.WAHA_MESSAGE_ACK, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
@@ -32,7 +43,7 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
}
ShouldProcess(event: any): boolean {
return ShouldMarkAsReadInChatWoot(event);
return ShouldProcessAckInChatWoot(event);
}
GetChatId(event: WAHAWebhookMessageAck): string {
@@ -46,13 +57,20 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookMessageAck;
const session = new WAHASessionAPI(event.session, container.WAHASelf());
const conversations = container.ChatWootConfig().conversations;
const config: MessageAckHandlerConfig = {
markAsRead: conversations.markAsRead,
syncMessageStatus: conversations.syncMessageStatus,
};
const handler = new MessageAckHandler(
config,
container.ContactConversationService(),
container.MessageMappingService(),
container.Logger(),
info,
session,
container.Locale(),
container.MessageStatusService(),
);
try {
await handler.handle(event);
@@ -64,26 +82,72 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
}
}
interface MessageAckHandlerConfig {
markAsRead: boolean;
syncMessageStatus: boolean;
}
class MessageAckHandler {
constructor(
private readonly config: MessageAckHandlerConfig,
private readonly contactConversationService: ContactConversationService,
protected mappingService: MessageMappingService,
private readonly logger: ILogger,
private readonly info: IMessageInfo,
private readonly session: WAHASessionAPI,
private readonly locale: Locale,
private readonly statusService: MessageStatusService,
) {}
async handle(event: WAHAWebhookMessageAck): Promise<void> {
const payload = event.payload;
const promises: Promise<void>[] = [];
if (this.config.syncMessageStatus) {
promises.push(this.syncMessageStatus(event));
}
if (this.config.markAsRead && ShouldMarkAsReadInChatWoot(event)) {
promises.push(this.markConversationAsRead(event));
}
const results = await Promise.allSettled(promises);
const errors = results
.filter((result) => result.status === 'rejected')
.map((result: PromiseRejectedResult) => result.reason);
if (errors.length > 0) {
throw new MultipleErrors(errors);
}
}
/**
* No chatwoot message - old message or some service ack, nothing to do
*/
private async getChatWootMessage(
payload: WAMessageAckBody,
): Promise<ChatwootMessage | null> {
const key = parseMessageIdSerialized(payload.id);
const chatwoot = await this.mappingService.getChatWootMessage({
return this.mappingService.getChatWootMessage({
chat_id: null,
message_id: key.id,
});
// No chatwoot message found, so we don't mark it as read
// Filters out old messages and some service ack messages
}
private async syncMessageStatus(event: WAHAWebhookMessageAck) {
const payload = event.payload;
const key = parseMessageIdSerialized(payload.id);
// Save the ack first, then look up the mapping.
await this.statusService.record(
key.id,
payload.ack,
new Date(event.timestamp),
);
const chatwoot = await this.getChatWootMessage(payload);
if (!chatwoot) {
return;
}
await this.statusService.sync(chatwoot);
}
private async markConversationAsRead(event: WAHAWebhookMessageAck) {
const payload = event.payload;
const chatwoot = await this.getChatWootMessage(payload);
if (!chatwoot) {
return;
}
@@ -1,9 +1,13 @@
import type { WAHAEngine, WAHAEvents } from '@waha/structures/enums.dto';
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
import {
ShouldMarkAsReadInChatWoot,
ShouldProcessAckInChatWoot,
} from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
interface TestParam {
name: string;
expected: boolean;
ShouldMarkAsReadInChatWoot: boolean;
ShouldProcessAckInChatWoot: boolean;
GOWS: any;
NOWEB: any;
WEBJS: any;
@@ -14,10 +18,11 @@ interface TestParam {
* Participant - 11111111111 (lid - 011111111111111)
* Group - 222222222222222222
*/
const ShouldMarkAsReadCases: TestParam[] = [
const TestCases: TestParam[] = [
{
name: 'Participant read My message (READ ack)',
expected: true,
ShouldMarkAsReadInChatWoot: true,
ShouldProcessAckInChatWoot: true,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
@@ -131,7 +136,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
},
{
name: 'Participant received My message (DEVICE ack)',
expected: false,
ShouldMarkAsReadInChatWoot: false,
ShouldProcessAckInChatWoot: true,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
@@ -243,7 +249,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
},
{
name: 'Me read Participant message on another device (READ ack)',
expected: false,
ShouldMarkAsReadInChatWoot: false,
ShouldProcessAckInChatWoot: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
@@ -315,7 +322,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
},
{
name: 'Participant read My Group Message (READ ack)',
expected: false,
ShouldMarkAsReadInChatWoot: false,
ShouldProcessAckInChatWoot: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
@@ -446,15 +454,16 @@ const ShouldMarkAsReadCases: TestParam[] = [
},
];
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
describe('ShouldMarkAsReadInChatWoot', () => {
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
for (const engine of engines) {
describe(engine, () => {
for (const param of ShouldMarkAsReadCases) {
const name = `[${param.expected}] ${param.name}`;
for (const param of TestCases) {
const name = `[${param.ShouldMarkAsReadInChatWoot}] ${param.name}`;
const event = param[engine];
const testfn = event ? test : test.skip;
const expected = param.expected;
const expected = param.ShouldMarkAsReadInChatWoot;
testfn(name, () => {
// Test
const result = ShouldMarkAsReadInChatWoot(event);
@@ -464,3 +473,21 @@ describe('ShouldMarkAsReadInChatWoot', () => {
});
}
});
describe('ShouldProcessAckInChatWoot', () => {
for (const engine of engines) {
describe(engine, () => {
for (const param of TestCases) {
const name = `[${param.ShouldProcessAckInChatWoot}] ${param.name}`;
const event = param[engine];
const testfn = event ? test : test.skip;
const expected = param.ShouldProcessAckInChatWoot;
testfn(name, () => {
// Test
const result = ShouldProcessAckInChatWoot(event);
expect(result).toBe(expected);
});
}
});
}
});
@@ -1,13 +1,21 @@
import type { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
import { isJidCusFormat } from '@waha/utils/wa';
import { WAMessageAck } from '@waha/structures/enums.dto';
import { isLidUser, toCusFormat } from '@waha/core/utils/jids';
import { isLidUser } from '@waha/core/utils/jids';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
export function ShouldMarkAsReadInChatWoot(
const ACKS_TO_PROCESS = new Set([
WAMessageAck.DEVICE,
WAMessageAck.READ,
WAMessageAck.PLAYED,
]);
/**
* Acks for OUR messages in DMs - the ones that can change Chatwoot message status
*/
export function ShouldProcessAckInChatWoot(
event: WAHAWebhookMessageAck,
): boolean {
// Mark as seen only if it's DM
// Ignore groups and other multiple participants chats
const chatId = EngineHelper.ChatID(event.payload);
if (!isJidCusFormat(chatId) && !isLidUser(chatId)) {
@@ -15,18 +23,27 @@ export function ShouldMarkAsReadInChatWoot(
}
const payload = event.payload;
// Only READ and PLAYED
const read = payload.ack === WAMessageAck.READ;
const played = payload.ack == WAMessageAck.PLAYED;
if (!read && !played) {
if (!ACKS_TO_PROCESS.has(payload.ack)) {
return false;
}
// Only process when OUR message (fromMe: true) was read by recipient
// Only process when OUR message (fromMe: true) was received or read by recipient
if (!payload.fromMe) {
return false;
}
// Mark ChatWoot conversation as read when recipient reads our message
return true;
}
/**
* Mark ChatWoot conversation as read when recipient reads our message
*/
export function ShouldMarkAsReadInChatWoot(
event: WAHAWebhookMessageAck,
): boolean {
if (!ShouldProcessAckInChatWoot(event)) {
return false;
}
// Only READ and PLAYED
const ack = event.payload.ack;
return ack === WAMessageAck.READ || ack === WAMessageAck.PLAYED;
}
@@ -65,6 +65,7 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
session,
container.Locale(),
container.WAHASelf(),
container.OutgoingMode(),
);
return await handler.handle(event.payload);
}
@@ -56,6 +56,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
session,
container.Locale(),
container.WAHASelf(),
container.OutgoingMode(),
);
return await handler.handle(event.payload);
}
@@ -60,6 +60,7 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
session,
container.Locale(),
container.WAHASelf(),
container.OutgoingMode(),
);
return await handler.handle(event.payload);
}
@@ -14,6 +14,30 @@ export class InboxContactInfo implements ContactInfo {
return INBOX_CONTACT_CHAT_ID;
}
SetFetchedContact(): void {
return;
}
async LidId(): Promise<string | null> {
return null;
}
async JidId(): Promise<string | null> {
return null;
}
async PhoneNumberE164(): Promise<string | null> {
return null;
}
async SavedName(): Promise<string | null> {
return null;
}
async PushName(): Promise<string | null> {
return null;
}
async AvatarUrl() {
return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render();
}
@@ -22,6 +22,9 @@ import { E164Parser } from '@waha/core/utils/PhoneJidNormalizer';
* Base WhatsApp contact info class
*/
abstract class ChatContactInfo implements ContactInfo {
// Already fetched WhatsApp contact, saves a request when known
protected contact: any = null;
constructor(
protected session: WAHASessionAPI,
protected chatId: string,
@@ -32,6 +35,30 @@ abstract class ChatContactInfo implements ContactInfo {
return this.chatId;
}
SetFetchedContact(contact: any) {
this.contact = contact;
}
async LidId(): Promise<string | null> {
return null;
}
async JidId(): Promise<string | null> {
return null;
}
async PhoneNumberE164(): Promise<string | null> {
return null;
}
async SavedName(): Promise<string | null> {
return null;
}
async PushName(): Promise<string | null> {
return null;
}
abstract AvatarUrl(): Promise<string | null>;
abstract Attributes(): Promise<any>;
@@ -53,6 +80,19 @@ class JidContactInfo extends ChatContactInfo {
return await this.session.findLIDByPN(this.chatId);
}
async LidId(): Promise<string | null> {
const lid = await this.fetchLid().catch(() => null);
return lid || null;
}
async JidId(): Promise<string | null> {
return this.chatId;
}
async PhoneNumberE164(): Promise<string | null> {
return E164Parser.fromJid(this.chatId);
}
@CacheAsync()
async Attributes() {
const attributes = {
@@ -66,8 +106,26 @@ class JidContactInfo extends ChatContactInfo {
return attributes;
}
@CacheAsync()
async fetchContact(): Promise<any> {
if (this.contact) {
return this.contact;
}
return await this.session.getContact(this.chatId).catch(() => null);
}
async SavedName(): Promise<string | null> {
const contact = await this.fetchContact();
return contact?.name || null;
}
async PushName(): Promise<string | null> {
const contact = await this.fetchContact();
return contact?.pushName || contact?.pushname || null;
}
async PublicContactCreate(): Promise<Contact> {
const contact: any = await this.session.getContact(this.chatId);
const contact: any = await this.fetchContact();
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
const phoneNumberE164 = E164Parser.fromJid(this.chatId);
@@ -89,13 +147,70 @@ class JidContactInfo extends ChatContactInfo {
* LID contact info
*/
class LidContactInfo extends ChatContactInfo {
constructor(
session: WAHASessionAPI,
chatId: string,
locale: Locale,
private pn: string | null = null,
) {
super(session, chatId, locale);
}
@CacheAsync()
async jid() {
const pn = await this.session.findPNByLid(this.chatId);
let pn = this.pn;
if (!pn) {
pn = await this.session.findPNByLid(this.chatId);
}
if (!pn) {
return null;
}
return new JidContactInfo(this.session, pn, this.locale);
const jid = new JidContactInfo(this.session, pn, this.locale);
jid.SetFetchedContact(this.contact);
return jid;
}
async LidId(): Promise<string | null> {
return this.chatId;
}
async JidId(): Promise<string | null> {
const jid = await this.jid();
return jid?.ChatId() ?? null;
}
async PhoneNumberE164(): Promise<string | null> {
const jid = await this.jid();
if (!jid) {
return null;
}
return await jid.PhoneNumberE164();
}
@CacheAsync()
async fetchContact(): Promise<any> {
if (this.contact) {
return this.contact;
}
return await this.session.getContact(this.chatId).catch(() => null);
}
async SavedName(): Promise<string | null> {
const jid = await this.jid();
if (jid) {
return await jid.SavedName();
}
const contact = await this.fetchContact();
return contact?.name || null;
}
async PushName(): Promise<string | null> {
const jid = await this.jid();
if (jid) {
return await jid.PushName();
}
const contact = await this.fetchContact();
return contact?.pushName || contact?.pushname || null;
}
async AvatarUrl(): Promise<string | null> {
@@ -123,10 +238,13 @@ class LidContactInfo extends ChatContactInfo {
if (jid) {
result = await jid.PublicContactCreate();
} else {
const contact: any = await this.fetchContact();
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
result = {
inbox_id: 0,
identifier: this.chatId,
name: this.chatId,
name: name,
};
}
result.custom_attributes = await this.Attributes();
@@ -261,6 +379,7 @@ export function WhatsAppContactInfo(
session: WAHASessionAPI,
chatId: string,
locale: Locale,
pn: string | null = null,
): ContactInfo {
if (isJidGroup(chatId)) {
return new GroupContactInfo(session, chatId, locale);
@@ -271,7 +390,7 @@ export function WhatsAppContactInfo(
} else if (isJidBroadcast(chatId)) {
return new BroadcastContactInfo(session, chatId, locale);
} else if (isLidUser(chatId)) {
return new LidContactInfo(session, normalizeJid(chatId), locale);
return new LidContactInfo(session, normalizeJid(chatId), locale, pn);
} else if (isPnUser(chatId)) {
return new JidContactInfo(session, chatId, locale);
} else {
+23
View File
@@ -10,6 +10,7 @@ import { ChatWootInboxAPI } from '@waha/apps/chatwoot/client/interfaces';
import {
ChatWootAppConfig,
ChatWootConfig,
ChatWootOutgoingMode,
DEFAULT_LOCALE,
LinkPreview,
} from '@waha/apps/chatwoot/dto/config.dto';
@@ -20,8 +21,10 @@ import {
ChatwootMessageRepository,
MessageMappingRepository,
MessageMappingService,
WhatsAppAckRepository,
WhatsAppMessageRepository,
} from '@waha/apps/chatwoot/storage';
import { MessageStatusService } from '@waha/apps/chatwoot/services/MessageStatusService';
import { Job } from 'bullmq';
import { Knex } from 'knex';
import { i18n } from '@waha/apps/chatwoot/i18n';
@@ -119,6 +122,10 @@ export class DIContainer {
);
}
public OutgoingMode(): ChatWootOutgoingMode {
return this.ChatWootConfig().conversations.outgoing;
}
@CacheSync()
public ConversationSelector() {
const config = this.ChatWootConfig();
@@ -185,6 +192,20 @@ export class DIContainer {
);
}
@CacheSync()
private WhatsAppAckRepository(): WhatsAppAckRepository {
return new WhatsAppAckRepository(this.Knex(), this.AppPk());
}
@CacheSync()
public MessageStatusService(): MessageStatusService {
return new MessageStatusService(
this.MessageMappingService(),
this.WhatsAppAckRepository(),
this.ContactConversationService(),
);
}
public ChatWootErrorReporter(job: Job): ChatWootErrorReporter {
return new ChatWootErrorReporter(this.Logger(), job, this.Locale());
}
@@ -229,6 +250,8 @@ export function ChatWootConfigDefaults(
sort: ConversationSort.created_newest,
status: null,
markAsRead: true,
syncMessageStatus: false,
outgoing: ChatWootOutgoingMode.PRIVATE_NOTE,
},
};
return lodash.defaultsDeep({}, config, defaults);
+8
View File
@@ -0,0 +1,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { Type } from 'class-transformer';
export class ChatWootAppDto extends App<ChatWootAppConfig> {
@Type(() => ChatWootAppConfig)
config: ChatWootAppConfig;
}
+25
View File
@@ -30,6 +30,11 @@ export enum LinkPreview {
HQ = 'HG',
}
export enum ChatWootOutgoingMode {
PRIVATE_NOTE = 'private-note',
MESSAGE = 'message',
}
export class ChatWootConversationsConfig {
@IsEnum(ConversationSort)
sort: ConversationSort;
@@ -46,6 +51,26 @@ export class ChatWootConversationsConfig {
@IsOptional()
@IsBoolean()
markAsRead?: boolean = true;
@ApiPropertyOptional({
description:
'Update Chatwoot message status (delivered/read) for messages sent from Chatwoot using WhatsApp acks. Disabled by default',
default: false,
})
@IsOptional()
@IsBoolean()
syncMessageStatus?: boolean = false;
@ApiPropertyOptional({
description:
'How to show messages sent from WhatsApp (not from ChatWoot): ' +
"'private-note' (default) or 'message' - a regular outgoing message, as if an agent sent it.",
enum: ChatWootOutgoingMode,
default: ChatWootOutgoingMode.PRIVATE_NOTE,
})
@IsOptional()
@IsEnum(ChatWootOutgoingMode)
outgoing?: ChatWootOutgoingMode = ChatWootOutgoingMode.PRIVATE_NOTE;
}
export interface ChatWootConfig {
+13 -22
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
اختر كيفية سحب الصور الرمزية: `skip` أو `if-missing` أو `update`.
cli.cmd.contacts.pull.option.update-names: |-
كيفية تحديث أسماء جهات الاتصال الموجودة: `no` - عدم تغيير الأسماء، جهات الاتصال الجديدة فقط تحصل على اسم (دفتر الهاتف أو push name أو رقم الهاتف). `if-raw` - الاحتفاظ بالأسماء المدخلة يدويًا، واستبدال الأسماء المؤقتة (رقم الهاتف، push name) من دفتر الهاتف. `always` - دفتر الهاتف هو المصدر الموثوق، استبدال أي اسم.
cli.cmd.contacts.pull.option.groups: |-
قم بتضمين جهات اتصال مجموعات واتساب في المزامنة.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**الخيارات العامة:**
task.contacts.status: |-
👤 سحب جهات الاتصال: **{{state}}**
- ✅ الناجحة: {{{progress.ok}}} جهة اتصال
- ⚪ تم تخطيها: {{{progress.skipped}}} جهة اتصال
- ⚠️ الأخطاء: {{{progress.errors}}} جهة اتصال
👤 جهات الاتصال: **{{state}}**
- 🆕 تم الإنشاء: {{{progress.created}}} جهة اتصال
- 🔄 تم التحديث: {{{progress.updated}}} جهة اتصال
- 🏷️ تمت إعادة التسمية: {{{progress.renamed}}} جهة اتصال
- ⚪ تم التخطي: {{{progress.skipped}}} جهة اتصال
- 🔴 أخطاء: {{{progress.errors}}} جهة اتصال
- 🖼️ تم تحديث الصورة الرمزية: {{{progress.avatar.updated}}} جهة اتصال
{{#job}}
⌛ الجدول الزمني:
- في قائمة الانتظار: {{{timestamp}}}
{{#processedOn}}
- وقت البدء: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- وقت الانتهاء: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
التفاصيل:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
جارٍ سحب جهات الاتصال...
task.contacts.progress: |-
سحب جهات الاتصال: ✅ {{progress.ok}}، ⚪ {{progress.skipped}}، ⚠️ {{progress.errors}}
👤⏳ جهات الاتصال: 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
أرسل **help** للحصول على قائمة الأوامر المتاحة.
@@ -638,10 +631,8 @@ cli.cmd.contacts.pull.option.progress:
الإبلاغ عن التقدم كل عدد N من جهات الاتصال. القيمة 0 لتعطيل التقارير.
cli.cmd.contacts.pull.option.lids:
تضمين جهات اتصال واتساب LID (مجهولة) في المزامنة.
task.contacts.completed:
👤✅ تم إنهاء جهات الاتصال → 🆕 {{progress.created}}، 🔄 {{progress.updated}}،
⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️
{{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ تم إنهاء جهات الاتصال → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ جارٍ سحب الرسائل 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
أرسل "{{{prefix}}}messages status" للاطلاع على المزيد من التفاصيل.
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
কনট্যাক্টের অ্যাভাটার কীভাবে টানবেন নির্ধারণ করুন: `skip`, `if-missing`, অথবা `update`।
cli.cmd.contacts.pull.option.update-names: |-
বিদ্যমান কন্টাক্টের নাম কীভাবে আপডেট করবেন: `no` - নাম স্পর্শ করবেন না, শুধু নতুন কন্টাক্ট নাম পাবে (ফোন বুক, push name বা ফোন নম্বর)। `if-raw` - হাতে দেওয়া নাম রাখুন, প্লেসহোল্ডার (ফোন নম্বর, push name) ফোন বুক থেকে সমাধান করুন। `always` - ফোন বুকই সত্যের উৎস, যেকোনো নাম প্রতিস্থাপন করুন।
cli.cmd.contacts.pull.option.groups: |-
সিঙ্কে WhatsApp গ্রুপ কনট্যাক্ট অন্তর্ভুক্ত করুন।
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**গ্লোবাল বিকল্পসমূহ:**
task.contacts.status: |-
👤 কনট্যাক্ট টানা: **{{state}}**
- ✅ সফল: {{{progress.ok}}} কনট্যাক্ট
- ⚪ এড়িয়ে গেছে: {{{progress.skipped}}} কনট্যাক্ট
- ⚠️ ত্রুটি: {{{progress.errors}}} কনট্যাক্ট
👤 কন্টাক্ট: **{{state}}**
- 🆕 তৈরি: {{{progress.created}}} কন্টাক্ট
- 🔄 আপডেট: {{{progress.updated}}} কন্টাক্ট
- 🏷️ নাম পরিবর্তিত: {{{progress.renamed}}} কন্টাক্ট
- ⚪ বাদ: {{{progress.skipped}}} কন্টাক্ট
- 🔴 ত্রুটি: {{{progress.errors}}} কন্টাক্ট
- 🖼️ অ্যাভাটার আপডেট: {{{progress.avatar.updated}}} কন্টাক্ট
{{#job}}
⌛ সময়রেখা:
- কিউ হয়েছে: {{{timestamp}}}
{{#processedOn}}
- শুরু হয়েছে: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- শেষ হয়েছে: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
বিস্তারিত:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
কনট্যাক্ট টানা হচ্ছে...
task.contacts.progress: |-
কনট্যাক্ট টানা: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ কন্টাক্ট: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
উপলব্ধ কমান্ডগুলির তালিকা পেতে **help** পাঠান।
@@ -643,9 +636,8 @@ cli.cmd.contacts.pull.option.progress:
প্রতি N কন্টাক্টে অগ্রগতি জানান। রিপোর্ট বন্ধ করতে 0 সেট করুন।
cli.cmd.contacts.pull.option.lids:
সিঙ্কে WhatsApp LID (অনামা) কন্টাক্ট অন্তর্ভুক্ত করুন।
task.contacts.completed:
👤✅ কন্টাক্ট সম্পন্ন → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ কন্টাক্ট সম্পন্ন → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ বার্তা টানা হচ্ছে 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: আরও জানতে "{{{prefix}}}messages status" পাঠান।
task.messages.progress:
+12 -21
View File
@@ -197,6 +197,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Legen Sie fest, wie Avatare geladen werden: `skip`, `if-missing` oder `update`.
cli.cmd.contacts.pull.option.update-names: |-
Wie Namen vorhandener Kontakte aktualisiert werden: `no` - Namen nicht anfassen, nur neue Kontakte erhalten einen (Telefonbuch, Push-Name oder Telefonnummer). `if-raw` - manuell gesetzte Namen behalten, Platzhalter (Telefonnummer, Push-Name) aus dem Telefonbuch auflösen. `always` - das Telefonbuch ist die Quelle der Wahrheit, jeden Namen ersetzen.
cli.cmd.contacts.pull.option.groups: |-
WhatsApp-Gruppenkontakte in die Synchronisierung einbeziehen.
@@ -248,32 +251,22 @@ cli.help.globalOptions.title: |-
**Globale Optionen:**
task.contacts.status: |-
👤 Kontakte abrufen: **{{state}}**
- ✅ Erfolgreich: {{{progress.ok}}} Kontakte
👤 Kontakte: **{{state}}**
- 🆕 Erstellt: {{{progress.created}}} Kontakte
- 🔄 Aktualisiert: {{{progress.updated}}} Kontakte
- 🏷️ Umbenannt: {{{progress.renamed}}} Kontakte
- ⚪ Übersprungen: {{{progress.skipped}}} Kontakte
- ⚠️ Fehler: {{{progress.errors}}} Kontakte
- 🔴 Fehler: {{{progress.errors}}} Kontakte
- 🖼️ Avatar aktualisiert: {{{progress.avatar.updated}}} Kontakte
{{#job}}
⌛ Zeitplan:
- Eingereiht: {{{timestamp}}}
{{#processedOn}}
- Start: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Ende: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Details:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Kontakte werden abgerufen...
task.contacts.progress: |-
Kontakte abrufen: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Kontakte: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Senden Sie **help**, um die Liste der verfügbaren Befehle zu erhalten.
@@ -662,10 +655,8 @@ cli.cmd.contacts.pull.option.progress:
Meldet den Fortschritt nach N Kontakten. 0 deaktiviert die Meldungen.
cli.cmd.contacts.pull.option.lids:
Bezieht WhatsApp-LID-Kontakte (anonyme Kontakte) in die Synchronisierung ein.
task.contacts.completed:
👤✅ Kontakte abgeschlossen → 🆕 {{progress.created}}, 🔄
{{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
{{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Kontakte abgeschlossen → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started:
⏳ Nachrichten werden abgerufen 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
+6 -2
View File
@@ -547,6 +547,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Choose how to pull avatars for contacts.
cli.cmd.contacts.pull.option.update-names: |-
How to update names of existing contacts: `no` - do not touch names, only new contacts get one (phone book, push name or phone number). `if-raw` - keep names set by hand, replace raw names (phone number, push name) from the phone book. `always` - the phone book is the source of truth, replace any name.
cli.cmd.contacts.pull.option.groups: |-
Include WhatsApp group contacts in the sync.
@@ -604,15 +607,16 @@ task.contacts.started: |-
👤⏳ Pulling contacts... Send "contacts status" for more details.
task.contacts.progress: |-
👤⏳ Contacts: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
👤⏳ Contacts: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Contacts Completed → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
👤✅ Contacts Completed → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.status: |-
👤 Contacts: **{{state}}**
- 🆕 Created: {{{progress.created}}} contacts
- 🔄 Updated: {{{progress.updated}}} contacts
- 🏷️ Renamed: {{{progress.renamed}}} contacts
- ⚪ Skipped: {{{progress.skipped}}} contacts
- 🔴 Errors: {{{progress.errors}}} contacts
- 🖼️ Avatar Updated: {{{progress.avatar.updated}}} contacts
+12 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Elige cómo obtener los avatares de los contactos: `skip`, `if-missing` o `update`.
cli.cmd.contacts.pull.option.update-names: |-
Cómo actualizar los nombres de contactos existentes: `no` - no tocar los nombres, solo los contactos nuevos reciben uno (agenda, push name o número de teléfono). `if-raw` - conservar los nombres puestos a mano, resolver los de relleno (número de teléfono, push name) desde la agenda. `always` - la agenda es la fuente de verdad, reemplazar cualquier nombre.
cli.cmd.contacts.pull.option.groups: |-
Incluir contactos de grupos de WhatsApp en la sincronización.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**Opciones globales:**
task.contacts.status: |-
👤 Extracción de contactos: **{{state}}**
- ✅ Correctos: {{{progress.ok}}} contactos
👤 Contactos: **{{state}}**
- 🆕 Creados: {{{progress.created}}} contactos
- 🔄 Actualizados: {{{progress.updated}}} contactos
- 🏷️ Renombrados: {{{progress.renamed}}} contactos
- ⚪ Omitidos: {{{progress.skipped}}} contactos
- ⚠️ Errores: {{{progress.errors}}} contactos
- 🔴 Errores: {{{progress.errors}}} contactos
- 🖼️ Avatar actualizado: {{{progress.avatar.updated}}} contactos
{{#job}}
⌛ Cronología:
- En cola: {{{timestamp}}}
{{#processedOn}}
- Inicio: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Finalizó: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Detalles:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Extrayendo contactos...
task.contacts.progress: |-
Extracción de contactos: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Contactos: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Envía **help** para obtener la lista de comandos disponibles.
@@ -660,10 +653,8 @@ cli.cmd.contacts.pull.option.progress:
informes de progreso.
cli.cmd.contacts.pull.option.lids:
Incluye contactos LID de WhatsApp (contactos anónimos) en la sincronización.
task.contacts.completed:
👤✅ Contactos completados → 🆕 {{progress.created}}, 🔄 {{progress.updated}},
⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
{{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Contactos completados → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ Extrayendo mensajes 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
Envía "{{{prefix}}}messages status" para ver más detalles.
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
نحوه دریافت آواتار مخاطبان را انتخاب کنید: `skip`، `if-missing` یا `update`.
cli.cmd.contacts.pull.option.update-names: |-
نحوه به‌روزرسانی نام مخاطبان موجود: `no` - نام‌ها دست نخورده بمانند، فقط مخاطبان جدید نام می‌گیرند (دفترچه تلفن، push name یا شماره تلفن). `if-raw` - نام‌های دستی حفظ شوند، نام‌های جایگزین (شماره تلفن، push name) از دفترچه تلفن تعیین شوند. `always` - دفترچه تلفن منبع اصلی است، هر نامی جایگزین شود.
cli.cmd.contacts.pull.option.groups: |-
مخاطبان گروه‌های واتساپ را در همگام‌سازی قرار دهید.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**گزینه‌های عمومی:**
task.contacts.status: |-
👤 دریافت مخاطبان: **{{state}}**
- ✅ موفق: {{{progress.ok}}} مخاطب
- ⚪ نادیده‌گرفته‌شده: {{{progress.skipped}}} مخاطب
- ⚠️ خطا: {{{progress.errors}}} مخاطب
👤 مخاطبان: **{{state}}**
- 🆕 ایجاد شده: {{{progress.created}}} مخاطب
- 🔄 به‌روزرسانی شده: {{{progress.updated}}} مخاطب
- 🏷️ تغییر نام داده شده: {{{progress.renamed}}} مخاطب
- ⚪ رد شده: {{{progress.skipped}}} مخاطب
- 🔴 خطاها: {{{progress.errors}}} مخاطب
- 🖼️ آواتار به‌روزرسانی شده: {{{progress.avatar.updated}}} مخاطب
{{#job}}
⌛ جدول زمانی:
- در صف: {{{timestamp}}}
{{#processedOn}}
- زمان شروع: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- زمان پایان: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
جزئیات:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
در حال دریافت مخاطبان...
task.contacts.progress: |-
دریافت مخاطبان: ✅ {{progress.ok}}، ⚪ {{progress.skipped}}، ⚠️ {{progress.errors}}
👤⏳ مخاطبان: 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
برای دریافت لیست دستورات موجود، **help** را ارسال کنید.
@@ -648,9 +641,8 @@ cli.cmd.contacts.pull.option.progress:
پیشرفت را هر N مخاطب اعلام می‌کند. مقدار 0 گزارش‌ها را غیرفعال می‌کند.
cli.cmd.contacts.pull.option.lids:
مخاطبین ناشناس واتساپ (LID) را در همگام‌سازی وارد می‌کند.
task.contacts.completed:
👤✅ مخاطبین تکمیل شدند → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، ⚪
{{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ مخاطبین تکمیل شدند → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ در حال دریافت پیام‌ها 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
برای جزئیات بیشتر "{{{prefix}}}messages status" را ارسال کنید.
+12 -20
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Choisissez comment charger les avatars des contacts : `skip`, `if-missing` ou `update`.
cli.cmd.contacts.pull.option.update-names: |-
Comment mettre à jour les noms des contacts existants : `no` - ne pas toucher aux noms, seuls les nouveaux contacts en reçoivent un (carnet d'adresses, push name ou numéro de téléphone). `if-raw` - conserver les noms saisis à la main, résoudre les noms génériques (numéro de téléphone, push name) depuis le carnet d'adresses. `always` - le carnet d'adresses fait foi, remplacer tout nom.
cli.cmd.contacts.pull.option.groups: |-
Inclure les contacts de groupes WhatsApp dans la synchronisation.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**Options globales :**
task.contacts.status: |-
👤 Import de contacts : **{{state}}**
- ✅ Réussis : {{{progress.ok}}} contacts
👤 Contacts : **{{state}}**
- 🆕 Créés : {{{progress.created}}} contacts
- 🔄 Mis à jour : {{{progress.updated}}} contacts
- 🏷️ Renommés : {{{progress.renamed}}} contacts
- ⚪ Ignorés : {{{progress.skipped}}} contacts
- ⚠️ Erreurs : {{{progress.errors}}} contacts
- 🔴 Erreurs : {{{progress.errors}}} contacts
- 🖼️ Avatar mis à jour : {{{progress.avatar.updated}}} contacts
{{#job}}
⌛ Chronologie :
- En file d'attente : {{{timestamp}}}
{{#processedOn}}
- Début : {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Fin : {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Détails :
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Importation des contacts...
task.contacts.progress: |-
Import de contacts : ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Contacts : 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Envoyez **help** pour obtenir la liste des commandes disponibles.
@@ -660,9 +653,8 @@ cli.cmd.contacts.pull.option.progress:
rapports.
cli.cmd.contacts.pull.option.lids:
Inclut les contacts LID WhatsApp (contacts anonymes) dans la synchronisation.
task.contacts.completed:
👤✅ Contacts terminés → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Contacts terminés → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started:
⏳ Récupération des messages 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
בחר כיצד למשוך תמונות פרופיל: `skip`, `if-missing` או `update`.
cli.cmd.contacts.pull.option.update-names: |-
כיצד לעדכן שמות של אנשי קשר קיימים: `no` - לא לגעת בשמות, רק אנשי קשר חדשים מקבלים שם (ספר טלפונים, push name או מספר טלפון). `if-raw` - לשמור שמות שהוזנו ידנית, לפתור שמות זמניים (מספר טלפון, push name) מספר הטלפונים. `always` - ספר הטלפונים הוא מקור האמת, להחליף כל שם.
cli.cmd.contacts.pull.option.groups: |-
כלול בסנכרון אנשי קשר של קבוצות WhatsApp.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**אפשרויות גלובליות:**
task.contacts.status: |-
👤 משיכת אנשי קשר: **{{state}}**
- ✅ הצלחות: {{{progress.ok}}} אנשי קשר
- ⚪ שדלגו עליהם: {{{progress.skipped}}} אנשי קשר
- ⚠️ שגיאות: {{{progress.errors}}} אנשי קשר
👤 אנשי קשר: **{{state}}**
- 🆕 נוצרו: {{{progress.created}}} אנשי קשר
- 🔄 עודכנו: {{{progress.updated}}} אנשי קשר
- 🏷️ שונה שמם: {{{progress.renamed}}} אנשי קשר
- ⚪ דולגו: {{{progress.skipped}}} אנשי קשר
- 🔴 שגיאות: {{{progress.errors}}} אנשי קשר
- 🖼️ תמונת פרופיל עודכנה: {{{progress.avatar.updated}}} אנשי קשר
{{#job}}
⌛ ציר זמן:
- נכנסה לתור: {{{timestamp}}}
{{#processedOn}}
- התחלה: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- סיום: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
פרטים:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
אנשי קשר נמשכים...
task.contacts.progress: |-
משיכת אנשי קשר: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ אנשי קשר: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
שלח **help** כדי לקבל את רשימת הפקודות הזמינות.
@@ -633,9 +626,8 @@ cli.cmd.contacts.pull.option.progress:
דיווח על התקדמות כל N אנשי קשר. ערך 0 מבטל את הדוחות.
cli.cmd.contacts.pull.option.lids:
הכללת אנשי קשר אנונימיים של WhatsApp (LID) בסנכרון.
task.contacts.completed:
👤✅ השלמת אנשי קשר → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ השלמת אנשי קשר → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ מושכים הודעות 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: שלחו "{{{prefix}}}messages status" לעוד פרטים.
task.messages.progress:
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff. Show more