diff --git a/package-lock.json b/package-lock.json index 4dec792f62..80e1af9877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,10 @@ "@sentry/node": "^10.70.0", "amqplib": "^0.10.5", "audio-decode": "^2.2.3", - "axios": "^1.19.0", + "axios": "^1.20.0", "baileys": "7.0.0-rc14", "class-validator": "^0.14.1", - "compression": "^1.7.5", + "compression": "^1.8.2", "cors": "^2.8.5", "dayjs": "^1.11.13", "dotenv": "^16.4.7", @@ -48,7 +48,7 @@ "mediainfo.js": "^0.3.4", "mime": "^4.0.0", "mime-types": "^2.1.35", - "multer": "^2.3.0", + "multer": "^2.4.0", "nats": "^2.29.1", "node-cache": "^5.1.2", "node-cron": "^4.6.0", @@ -61,12 +61,12 @@ "qrcode-terminal": "^0.12.0", "redis": "^4.7.0", "rxjs": "^7.8.2", - "sharp": "^0.35.4", + "sharp": "^0.35.5", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "socks-proxy-agent": "^8.0.5", "swagger-ui-express": "^5.0.1", - "undici": "^7.29.0", + "undici": "^7.30.0", "uuid": "^13.0.2" }, "devDependencies": { @@ -1665,9 +1665,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1683,13 +1683,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1705,20 +1705,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1728,9 +1728,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1744,9 +1744,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1760,12 +1760,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1776,12 +1779,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1792,12 +1798,15 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1808,12 +1817,15 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1824,12 +1836,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1840,12 +1855,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1856,12 +1874,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1872,12 +1893,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1888,12 +1912,15 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1906,16 +1933,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1928,16 +1958,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1950,16 +1983,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1972,16 +2008,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1994,16 +2033,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2016,16 +2058,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2038,16 +2083,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2060,13 +2108,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2080,16 +2128,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2099,9 +2147,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -2118,9 +2166,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -2137,9 +2185,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -2750,7 +2798,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2772,7 +2819,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2788,7 +2834,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", @@ -2839,7 +2884,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", @@ -3040,7 +3084,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3856,7 +3899,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4006,7 +4048,6 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -4303,7 +4344,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4653,7 +4693,6 @@ "integrity": "sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==", "deprecated": "Renamed to @audio/decode — same API; this name remains a thin alias. npm i @audio/decode", "license": "MIT", - "peer": true, "dependencies": { "@wasm-audio-decoders/flac": "^0.2.4", "@wasm-audio-decoders/ogg-vorbis": "^0.1.15", @@ -4703,9 +4742,9 @@ } }, "node_modules/axios": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", - "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -4960,12 +4999,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, "node_modules/buffer-more-ints": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", @@ -5510,14 +5543,15 @@ } }, "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", + "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", @@ -5525,6 +5559,10 @@ }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/compression/node_modules/debug": { @@ -5549,21 +5587,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -5690,7 +5713,6 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -6278,16 +6300,15 @@ } }, "node_modules/engine.io": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", - "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", - "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", @@ -6545,7 +6566,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6616,7 +6636,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6673,7 +6692,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7263,7 +7281,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8637,9 +8654,9 @@ } }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { "node": ">= 12" @@ -9177,7 +9194,6 @@ "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", @@ -9419,7 +9435,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -9479,7 +9494,6 @@ "resolved": "https://registry.npmjs.org/link-preview-js/-/link-preview-js-5.0.0.tgz", "integrity": "sha512-+/R92+k6wwhkdJa7tSXp2WqLaRjyo2eTNbgxNyuZV9fKZ6zLcM9ZSd2ZdzsvrZwAvu4sk3vSfxEmj0xwfOVe7Q==", "license": "MIT", - "peer": true, "dependencies": { "cheerio": "1.0.0-rc.11", "undici": "^8.6.0" @@ -9489,9 +9503,9 @@ } }, "node_modules/link-preview-js/node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -10426,14 +10440,13 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", - "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", - "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { @@ -10445,9 +10458,9 @@ } }, "node_modules/music-metadata": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.14.0.tgz", - "integrity": "sha512-RyOSq98kuVfXB1emJ+NjBF0av8Ph3oBuqNy+Z5sFFfLhjYrkBQEB53V8u+U0RNTVwNo20WoPUwNkfKwZfrOqmQ==", + "version": "11.16.1", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.16.1.tgz", + "integrity": "sha512-uR/mHK6eyfl8h3jVCobF5b38Mfg1IDJMiBxIVbmm5F+G1OXEFcUpQHveO84tJBAqvY93GCRY2R4kDyx7s06Rug==", "funding": [ { "type": "github", @@ -10462,7 +10475,7 @@ "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "file-type": "^21.3.4", "media-typer": "^2.0.0", @@ -10476,9 +10489,9 @@ } }, "node_modules/music-metadata/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -11326,7 +11339,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -11627,7 +11639,6 @@ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11657,7 +11668,6 @@ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -11717,9 +11727,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -11727,6 +11737,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-addr/node_modules/ipaddr.js": { @@ -12051,6 +12065,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -12619,11 +12634,10 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -12636,31 +12650,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -12962,6 +12976,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -13490,7 +13505,6 @@ "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -14126,19 +14140,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14186,9 +14193,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -14265,6 +14272,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/utils-merge": { diff --git a/package.json b/package.json index 90f2a564c0..854e5a317c 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "dev:server": "tsx watch ./src/main.ts", "test": "tsx watch ./test/all.test.ts", "test:xmacna-compat": "node --test test/xmacna-baileys-compat.test.cjs", - "test:xmacna-inbox": "tsx --test test/xmacna-s3-storage.test.ts test/xmacna-inbound-inbox.test.ts test/xmacna-inbound-sink-dispatch.test.ts test/xmacna-inbound-service-contracts.test.ts test/xmacna-baileys-message-processor.test.ts test/xmacna-chatbot-debounce.test.ts test/xmacna-outgoing-lid.test.ts", - "test:xmacna-inbox:postgres": "tsx --test test/xmacna-inbound-inbox.postgres.test.ts", + "test:xmacna-inbox": "tsx --test test/xmacna-s3-storage.test.ts test/xmacna-inbound-inbox.test.ts test/xmacna-inbound-sink-dispatch.test.ts test/xmacna-inbound-service-contracts.test.ts test/xmacna-baileys-message-processor.test.ts test/xmacna-chatbot-debounce.test.ts test/xmacna-outgoing-lid.test.ts test/xmacna-inbound-identity.test.ts test/xmacna-inbound-media-upload.test.ts test/xmacna-inbound-media-guard.test.ts test/xmacna-get-conversation-message.test.ts", + "test:xmacna-inbox:postgres": "tsx --test test/xmacna-inbound-inbox.postgres.test.ts test/xmacna-inbound-identity.postgres.test.ts", "inbound-inbox:maintenance": "tsx src/cli/inboundInboxMaintenance.ts", "lint": "eslint --fix --ext .ts src", "lint:check": "eslint --ext .ts src", @@ -84,10 +84,10 @@ "@sentry/node": "^10.70.0", "amqplib": "^0.10.5", "audio-decode": "^2.2.3", - "axios": "^1.19.0", + "axios": "^1.20.0", "baileys": "7.0.0-rc14", "class-validator": "^0.14.1", - "compression": "^1.7.5", + "compression": "^1.8.2", "cors": "^2.8.5", "dayjs": "^1.11.13", "dotenv": "^16.4.7", @@ -111,7 +111,7 @@ "mediainfo.js": "^0.3.4", "mime": "^4.0.0", "mime-types": "^2.1.35", - "multer": "^2.3.0", + "multer": "^2.4.0", "nats": "^2.29.1", "node-cache": "^5.1.2", "node-cron": "^4.6.0", @@ -124,12 +124,12 @@ "qrcode-terminal": "^0.12.0", "redis": "^4.7.0", "rxjs": "^7.8.2", - "sharp": "^0.35.4", + "sharp": "^0.35.5", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "socks-proxy-agent": "^8.0.5", "swagger-ui-express": "^5.0.1", - "undici": "^7.29.0", + "undici": "^7.30.0", "uuid": "^13.0.2" }, "overrides": { diff --git a/prisma/mysql-migrations/20260927090000_add_lid_phone_alias/migration.sql b/prisma/mysql-migrations/20260927090000_add_lid_phone_alias/migration.sql new file mode 100644 index 0000000000..57f1c1bce7 --- /dev/null +++ b/prisma/mysql-migrations/20260927090000_add_lid_phone_alias/migration.sql @@ -0,0 +1,8 @@ +CREATE TABLE `LidPhoneAlias` ( + `instanceScope` VARCHAR(67) NOT NULL, + `lidJid` VARCHAR(100) NOT NULL, + `phoneJid` VARCHAR(100) NULL, + `ambiguous` BOOLEAN NOT NULL DEFAULT false, + `updatedAt` DATETIME(3) NOT NULL, + PRIMARY KEY (`instanceScope`,`lidJid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/mysql-schema.prisma b/prisma/mysql-schema.prisma index b6b4dc554d..7aae723ddb 100644 --- a/prisma/mysql-schema.prisma +++ b/prisma/mysql-schema.prisma @@ -216,6 +216,16 @@ model InboundReceipt { @@index([instanceScope, contactScope, createdAt]) } +model LidPhoneAlias { + instanceScope String @db.VarChar(67) + lidJid String @db.VarChar(100) + phoneJid String? @db.VarChar(100) + ambiguous Boolean @default(false) + updatedAt DateTime @updatedAt + + @@id([instanceScope, lidJid]) +} + model MessageUpdate { id String @id @default(cuid()) keyId String @db.VarChar(100) diff --git a/prisma/postgresql-migrations/20260927090000_add_lid_phone_alias/migration.sql b/prisma/postgresql-migrations/20260927090000_add_lid_phone_alias/migration.sql new file mode 100644 index 0000000000..4c5b0cd7c7 --- /dev/null +++ b/prisma/postgresql-migrations/20260927090000_add_lid_phone_alias/migration.sql @@ -0,0 +1,8 @@ +CREATE TABLE "LidPhoneAlias" ( + "instanceScope" VARCHAR(67) NOT NULL, + "lidJid" VARCHAR(100) NOT NULL, + "phoneJid" VARCHAR(100), + "ambiguous" BOOLEAN NOT NULL DEFAULT false, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "LidPhoneAlias_pkey" PRIMARY KEY ("instanceScope","lidJid") +); diff --git a/prisma/postgresql-schema.prisma b/prisma/postgresql-schema.prisma index 590f1ed35f..fdeb07991d 100644 --- a/prisma/postgresql-schema.prisma +++ b/prisma/postgresql-schema.prisma @@ -215,6 +215,17 @@ model InboundReceipt { @@index([instanceScope, contactScope, createdAt]) } +// Confirmed PN for a LID. The name-derived scope survives instance row recreation. +model LidPhoneAlias { + instanceScope String @db.VarChar(67) + lidJid String @db.VarChar(100) + phoneJid String? @db.VarChar(100) + ambiguous Boolean @default(false) + updatedAt DateTime @updatedAt @db.Timestamp + + @@id([instanceScope, lidJid]) +} + model MessageUpdate { id String @id @default(cuid()) keyId String @db.VarChar(100) diff --git a/prisma/psql_bouncer-schema.prisma b/prisma/psql_bouncer-schema.prisma index 6161c32a4d..4a42897f96 100644 --- a/prisma/psql_bouncer-schema.prisma +++ b/prisma/psql_bouncer-schema.prisma @@ -215,6 +215,16 @@ model InboundReceipt { @@index([instanceScope, contactScope, createdAt]) } +model LidPhoneAlias { + instanceScope String @db.VarChar(67) + lidJid String @db.VarChar(100) + phoneJid String? @db.VarChar(100) + ambiguous Boolean @default(false) + updatedAt DateTime @updatedAt @db.Timestamp + + @@id([instanceScope, lidJid]) +} + model MessageUpdate { id String @id @default(cuid()) keyId String @db.VarChar(100) diff --git a/src/api/integrations/channel/whatsapp/inboundIdentity.ts b/src/api/integrations/channel/whatsapp/inboundIdentity.ts new file mode 100644 index 0000000000..2b984dc372 --- /dev/null +++ b/src/api/integrations/channel/whatsapp/inboundIdentity.ts @@ -0,0 +1,119 @@ +import { PrismaRepository } from '@api/repository/repository.service'; +import { Prisma } from '@prisma/client'; + +import { normalizeInstanceScope } from './inboundInbox'; + +const PHONE_JID = /^[1-9][0-9]{5,19}@s\.whatsapp\.net$/; +const LID_JID = /^[1-9][0-9]{1,24}@lid$/; + +export class UnresolvedInboundLidError extends Error { + constructor(reason: 'unknown' | 'ambiguous') { + super(`Inbound LID alias ${reason}; phone identity unavailable`); + this.name = 'UnresolvedInboundLidError'; + } +} + +export type InboundAddress = { + remoteJid: string; + remoteJidAlt?: string | null; +}; + +export type ResolvedInboundAddress = { + remoteJid: string; + senderLid?: string; +}; + +export interface LidPhoneAliasStore { + confirm(instanceScope: string, lidJid: string, phoneJid: string): Promise; + find(instanceScope: string, lidJid: string): Promise; +} + +/** + * A conflicting confirmed pair poisons the alias instead of replacing it. + * The serializable transaction also prevents two workers from silently choosing + * different phones for the same LID while recording a concurrent observation. + */ +export class PrismaLidPhoneAliasStore implements LidPhoneAliasStore { + constructor(private readonly repository: PrismaRepository) {} + + async confirm(instanceScope: string, lidJid: string, phoneJid: string): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + const alias = await this.repository.$transaction( + async (tx) => { + const where = { instanceScope_lidJid: { instanceScope, lidJid } }; + const existing = await tx.lidPhoneAlias.findUnique({ where }); + if (!existing) { + return tx.lidPhoneAlias.create({ data: { instanceScope, lidJid, phoneJid } }); + } + if (existing.ambiguous || existing.phoneJid !== phoneJid) { + if (!existing.ambiguous && existing.phoneJid !== phoneJid) { + console.warn( + `Inbound LID alias conflict scope=${instanceScope} lid=${lidJid} stored=${existing.phoneJid} observed=${phoneJid}`, + ); + } + return tx.lidPhoneAlias.update({ + where, + data: { ambiguous: true, phoneJid: null }, + }); + } + return existing; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + if (alias.ambiguous || !alias.phoneJid) throw new UnresolvedInboundLidError('ambiguous'); + return alias.phoneJid; + } catch (error) { + if (error instanceof UnresolvedInboundLidError) throw error; + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + !['P2002', 'P2034'].includes(error.code) || + attempt === 7 + ) { + throw error; + } + } + } + throw new Error('LID alias transaction retry exhausted'); + } + + async find(instanceScope: string, lidJid: string): Promise { + const alias = await this.repository.lidPhoneAlias.findUnique({ + where: { instanceScope_lidJid: { instanceScope, lidJid } }, + }); + if (alias?.ambiguous) throw new UnresolvedInboundLidError('ambiguous'); + return alias?.phoneJid ?? null; + } +} + +/** Never derive a phone number from the LID digits. */ +export async function resolveInboundAddress( + instanceName: string, + address: InboundAddress, + aliases: LidPhoneAliasStore, + getPhoneForLid?: (lidJid: string) => Promise, +): Promise { + const primary = address.remoteJid; + const alternate = address.remoteJidAlt ?? undefined; + const lid = LID_JID.test(primary) ? primary : LID_JID.test(alternate ?? '') ? alternate : undefined; + const primaryPhone = PHONE_JID.test(primary); + if (!lid || (!primaryPhone && !LID_JID.test(primary))) return { remoteJid: primary }; + + const scope = normalizeInstanceScope(instanceName); + const alternatePhone = PHONE_JID.test(alternate ?? '') ? alternate : undefined; + const confirmedPhone = primaryPhone ? primary : alternatePhone; + if (confirmedPhone) { + const remoteJid = await aliases.confirm(scope, lid, confirmedPhone); + return { remoteJid, senderLid: LID_JID.test(primary) ? lid : undefined }; + } + + // Baileys' signal repository is tied to this instance's authenticated socket. + const baileysPhone = await getPhoneForLid?.(lid); + if (baileysPhone && PHONE_JID.test(baileysPhone)) { + const remoteJid = await aliases.confirm(scope, lid, baileysPhone); + return { remoteJid, senderLid: lid }; + } + const storedPhone = await aliases.find(scope, lid); + if (!storedPhone || !PHONE_JID.test(storedPhone)) throw new UnresolvedInboundLidError('unknown'); + return { remoteJid: storedPhone, senderLid: lid }; +} diff --git a/src/api/integrations/channel/whatsapp/inboundMediaUpload.ts b/src/api/integrations/channel/whatsapp/inboundMediaUpload.ts new file mode 100644 index 0000000000..9c076ccab9 --- /dev/null +++ b/src/api/integrations/channel/whatsapp/inboundMediaUpload.ts @@ -0,0 +1,88 @@ +import { MessageSubtype } from '@api/types/wa.types'; + +type MediaUploadContext = { instance: string; messageId: string }; + +const MEDIA_WRAPPERS = [ + 'viewOnceMessage', + 'viewOnceMessageV2', + 'viewOnceMessageV2Extension', + 'ephemeralMessage', +] as const; + +/** + * Unwraps the inbound media envelope (view-once/ephemeral) for guard checks. + * Baileys delivers such media as `{ viewOnceMessageV2: { message: { videoMessage } } }`, + * so the top-level `isMedia`/`isVideo` guards miss it and skip the S3 block entirely + * (MAR-414). The persisted shape is left untouched; only detection uses the inner content. + */ +export function unwrapInboundMediaContent(message: any): any { + let content = message; + for (let i = 0; i < 5 && content; i++) { + const inner = MEDIA_WRAPPERS.map((wrapper) => content?.[wrapper]?.message).find(Boolean); + if (!inner) break; + content = inner; + } + return content; +} + +/** + * Own-key copy of a received message for `getBase64FromMediaMessage`. Its + * `'messageContextInfo' in msg.message` guard is true through the prototype of a + * raw Baileys protobuf, so media without its own `messageContextInfo` (whatsmeow + * clients, or the inner message of a wrapper such as `documentWithCaptionMessage`) + * was dropped before the S3 upload (MAR-304). The send path already passes a plain + * object built by `prepareMessage`, so the shared guard stays unchanged. `received` + * itself is not mutated; media fields are shared by reference, as in the Chatwoot path. + */ +export function ownKeyMediaMessage(received: T): T { + const copy = (message: any) => { + if (!message) return message; + const own = { ...message }; + for (const subtype of MessageSubtype) { + if (own[subtype]?.message) own[subtype] = { ...own[subtype], message: copy(own[subtype].message) }; + } + return own; + }; + return { ...received, message: copy(received.message) }; +} +type MediaUploadLogger = { warn: (message: string) => void }; + +/** Keep media failures visible without logging message bodies, URLs, or media keys. */ +export async function runInboundMediaUpload( + context: MediaUploadContext, + logger: MediaUploadLogger, + operations: { + download: () => Promise; + upload: (media: TMedia) => Promise; + createMedia: (media: TMedia, uploaded: TUpload) => Promise; + updateMessage: (media: TMedia, uploaded: TUpload) => Promise; + }, +): Promise { + let stage = 'download'; + try { + const media = await operations.download(); + if (!media) { + logger.warn(JSON.stringify({ event: 'inbound_media_upload_skipped', ...context, stage, reason: 'empty_media' })); + return false; + } + stage = 's3_upload'; + const uploaded = await operations.upload(media); + // The S3 storage wrapper returns Error on failure instead of throwing it. + if (uploaded instanceof Error) throw uploaded; + stage = 'media_create'; + await operations.createMedia(media, uploaded); + stage = 'message_update'; + await operations.updateMessage(media, uploaded); + return true; + } catch (error) { + logger.warn( + JSON.stringify({ + event: 'inbound_media_upload_failed', + ...context, + stage, + errorType: error instanceof Error ? error.name : typeof error, + }), + ); + return false; + } +} diff --git a/src/api/integrations/channel/whatsapp/inboundSinkDispatch.ts b/src/api/integrations/channel/whatsapp/inboundSinkDispatch.ts index 487d475e3f..9c02c5efd7 100644 --- a/src/api/integrations/channel/whatsapp/inboundSinkDispatch.ts +++ b/src/api/integrations/channel/whatsapp/inboundSinkDispatch.ts @@ -29,3 +29,19 @@ export async function attemptDurableInboundSink(options: { await options.mark(options.sink, state); return undefined; } + +/** Keep legacy LID delivery in off/shadow; only an enforce receipt can retry it. */ +export async function dispatchInboundChatbot(options: { + unresolvedLid?: Error; + receiptMode?: 'off' | 'shadow' | 'enforce'; + attempt: (operation: () => Promise<'sent'>) => Promise; + durable: () => Promise; + bestEffort: () => Promise; +}): Promise { + const delivered = await options.attempt(async () => { + if (options.unresolvedLid && options.receiptMode === 'enforce') throw options.unresolvedLid; + await options.durable(); + return 'sent'; + }); + if (delivered) await options.bestEffort(); +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index f740f45c0f..47c867945c 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -154,6 +154,7 @@ import { PassThrough, Readable } from 'stream'; import { v4 } from 'uuid'; import { BaileysMessageProcessor } from './baileysMessage.processor'; +import { PrismaLidPhoneAliasStore, resolveInboundAddress, UnresolvedInboundLidError } from './inboundIdentity'; import { classifyInboundMessage, inboundPayloadHash, @@ -162,7 +163,8 @@ import { PrismaInboundInbox, resolveInboundMode, } from './inboundInbox'; -import { attemptDurableInboundSink, DurableInboundSinkFailure } from './inboundSinkDispatch'; +import { ownKeyMediaMessage, runInboundMediaUpload, unwrapInboundMediaContent } from './inboundMediaUpload'; +import { attemptDurableInboundSink, dispatchInboundChatbot, DurableInboundSinkFailure } from './inboundSinkDispatch'; import { enrichOutgoingMessageKey, remoteJidQueryFilters } from './outgoingLid'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; @@ -243,6 +245,7 @@ const xmacnaConnectStates: Map = export class BaileysStartupService extends ChannelStartupService { private messageProcessor = new BaileysMessageProcessor(); private readonly inboundInbox: PrismaInboundInbox; + private readonly lidPhoneAliases: PrismaLidPhoneAliasStore; private inboundWorkerTimer?: NodeJS.Timeout; private inboundWorkerRunning = false; private readonly inboundWorkerOwner = `baileys-${v4()}`; @@ -262,6 +265,7 @@ export class BaileysStartupService extends ChannelStartupService { onMessageReceive: this.messageHandle['messages.upsert'].bind(this), // Bind the method to the current context }); this.inboundInbox = new PrismaInboundInbox(this.prismaRepository); + this.lidPhoneAliases = new PrismaLidPhoneAliasStore(this.prismaRepository); this.authStateProvider = new AuthStateProvider(this.providerFiles); } @@ -922,6 +926,17 @@ export class BaileysStartupService extends ChannelStartupService { }); const recoveredChatbot = await attempt('chatbot', item.chatbotState, async () => { + const recoveredAddress = await resolveInboundAddress( + this.instance.name, + messageRaw.key, + this.lidPhoneAliases, + (lid) => this.client.signalRepository.lidMapping.getPNForLID(lid), + ); + if (recoveredAddress.senderLid) { + messageRaw.key.senderLid = recoveredAddress.senderLid; + messageRaw.key.remoteJidAlt = recoveredAddress.remoteJid; + } + messageRaw.key.remoteJid = recoveredAddress.remoteJid; await chatbotController.emitDurableInbound({ instance: { instanceName: this.instance.name, instanceId: this.instanceId }, remoteJid: messageRaw.key.remoteJid, @@ -1368,7 +1383,7 @@ export class BaileysStartupService extends ChannelStartupService { activeInboundMode = inboundMode; const inboundClassification = classifyInboundMessage(received); const inboundMessageId = received.key?.id; - const inboundContactScope = normalizeContactScope( + let inboundContactScope = normalizeContactScope( (received.key as any)?.remoteJidAlt || received.key?.remoteJid, ); const inboundIdentity = inboundMessageId @@ -1540,6 +1555,34 @@ export class BaileysStartupService extends ChannelStartupService { ...cachedJids.flatMap((cached) => [cached.remoteJid, ...cached.jidOptions]), ]); } + let unresolvedInboundLid: UnresolvedInboundLidError | undefined; + if (!received.key.fromMe) { + try { + const resolvedAddress = await resolveInboundAddress( + this.instance.name, + messageRaw.key, + this.lidPhoneAliases, + (lid) => this.client.signalRepository.lidMapping.getPNForLID(lid), + ); + inboundContactScope = normalizeContactScope(resolvedAddress.remoteJid); + if (resolvedAddress.senderLid) { + messageRaw.key.senderLid = resolvedAddress.senderLid; + messageRaw.key.remoteJidAlt = resolvedAddress.remoteJid; + } + } catch (error) { + if (!(error instanceof UnresolvedInboundLidError)) throw error; + unresolvedInboundLid = error; + inboundContactScope = normalizeContactScope(messageRaw.key.remoteJid); + this.logger.warn( + JSON.stringify({ + event: 'unresolved_inbound_lid', + instance: this.instance.name, + inboxMode: inboundMode, + reason: error.message, + }), + ); + } + } let durableMessageRecordId: string | undefined; if (inboundMode !== 'off' && inboundIdentity && inboundClassification === 'real') { @@ -1696,16 +1739,18 @@ export class BaileysStartupService extends ChannelStartupService { } } + const mediaContent = unwrapInboundMediaContent(received?.message); + const isMedia = - received?.message?.imageMessage || - received?.message?.videoMessage || - received?.message?.stickerMessage || - received?.message?.documentMessage || - received?.message?.documentWithCaptionMessage || - received?.message?.ptvMessage || - received?.message?.audioMessage; + mediaContent?.imageMessage || + mediaContent?.videoMessage || + mediaContent?.stickerMessage || + mediaContent?.documentMessage || + mediaContent?.documentWithCaptionMessage || + mediaContent?.ptvMessage || + mediaContent?.audioMessage; - const isVideo = received?.message?.videoMessage; + const isVideo = mediaContent?.videoMessage; if (this.localSettings.readMessages && received.key.id !== 'status@broadcast') { await this.client.readMessages([received.key]); @@ -1827,45 +1872,66 @@ export class BaileysStartupService extends ChannelStartupService { const hasRealMedia = this.hasValidMediaContent(message); if (!hasRealMedia) { - this.logger.warn('Message detected as media but contains no valid media content'); + this.logger.warn( + JSON.stringify({ + event: 'inbound_media_upload_skipped', + instance: this.instance.name, + messageId: received.key.id, + stage: 'media_validation', + reason: 'invalid_media', + }), + ); } else { - const media = await this.getBase64FromMediaMessage({ message }, true); - - if (!media) { - this.logger.verbose('No valid media to upload (messageContextInfo only), skipping MinIO'); - } else { - const { buffer, mediaType, fileName, size } = media; - const mimetype = mimeTypes.lookup(fileName).toString(); - const fullName = join( - `${this.instance.id}`, - received.key.remoteJid, - mediaType, - `${Date.now()}_${fileName}`, - ); - await s3Service.uploadFile(fullName, buffer, size.fileLength?.low, { - 'Content-Type': mimetype, - }); - - await this.prismaRepository.media.create({ - data: { - messageId: msg.id, - instanceId: this.instanceId, - type: mediaType, - fileName: fullName, - mimetype, + await runInboundMediaUpload( + { instance: this.instance.name, messageId: received.key.id }, + this.logger, + { + download: () => + this.getBase64FromMediaMessage({ message: ownKeyMediaMessage(message) }, true), + upload: async (media) => { + const { buffer, mediaType, fileName, size } = media; + const mimetype = mimeTypes.lookup(fileName).toString(); + const fullName = join( + `${this.instance.id}`, + received.key.remoteJid, + mediaType, + `${Date.now()}_${fileName}`, + ); + const result = await s3Service.uploadFile(fullName, buffer, size.fileLength?.low, { + 'Content-Type': mimetype, + }); + return result instanceof Error ? result : { fullName, mediaType, mimetype }; }, - }); - - const mediaUrl = await s3Service.getObjectUrl(fullName); - - messageRaw.message.mediaUrl = mediaUrl; - - await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw }); - } + createMedia: async (_media, { fullName, mediaType, mimetype }) => { + await this.prismaRepository.media.create({ + data: { + messageId: msg.id, + instanceId: this.instanceId, + type: mediaType, + fileName: fullName, + mimetype, + }, + }); + }, + updateMessage: async (_media, { fullName }) => { + const mediaUrl = await s3Service.getObjectUrl(fullName); + messageRaw.message.mediaUrl = mediaUrl; + await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw }); + }, + }, + ); } } } catch (error) { - this.logger.error(['Error on upload file to minio', error?.message, error?.stack]); + this.logger.warn( + JSON.stringify({ + event: 'inbound_media_upload_failed', + instance: this.instance.name, + messageId: received.key.id, + stage: 'pre_upload', + errorType: error instanceof Error ? error.name : typeof error, + }), + ); } } } @@ -1905,7 +1971,7 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.verbose(messageRaw); sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); - if (messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) { + if (!unresolvedInboundLid && messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) { messageRaw.key.remoteJid = messageRaw.key.remoteJidAlt; } console.log(messageRaw); @@ -1919,23 +1985,25 @@ export class BaileysStartupService extends ChannelStartupService { return 'sent'; }); - const chatbotDelivered = await attemptActiveSink('chatbot', async () => { - await chatbotController.emitDurableInbound({ - instance: { instanceName: this.instance.name, instanceId: this.instanceId }, - remoteJid: messageRaw.key.remoteJid, - msg: messageRaw, - pushName: messageRaw.pushName, - }); - return 'sent'; + await dispatchInboundChatbot({ + unresolvedLid: unresolvedInboundLid, + receiptMode: activeReceipt ? activeInboundMode : undefined, + attempt: (operation) => attemptActiveSink('chatbot', operation), + durable: () => + chatbotController.emitDurableInbound({ + instance: { instanceName: this.instance.name, instanceId: this.instanceId }, + remoteJid: messageRaw.key.remoteJid, + msg: messageRaw, + pushName: messageRaw.pushName, + }), + bestEffort: () => + chatbotController.emitBestEffortInbound({ + instance: { instanceName: this.instance.name, instanceId: this.instanceId }, + remoteJid: messageRaw.key.remoteJid, + msg: messageRaw, + pushName: messageRaw.pushName, + }), }); - if (chatbotDelivered) { - await chatbotController.emitBestEffortInbound({ - instance: { instanceName: this.instance.name, instanceId: this.instanceId }, - remoteJid: messageRaw.key.remoteJid, - msg: messageRaw, - pushName: messageRaw.pushName, - }); - } const contact = await this.prismaRepository.contact.findFirst({ where: { remoteJid: received.key.remoteJid, instanceId: this.instanceId }, @@ -2921,17 +2989,19 @@ export class BaileysStartupService extends ChannelStartupService { } } + const mediaContent = unwrapInboundMediaContent(messageSent?.message); + const isMedia = - messageSent?.message?.imageMessage || - messageSent?.message?.videoMessage || - messageSent?.message?.stickerMessage || - messageSent?.message?.ptvMessage || - messageSent?.message?.documentMessage || - messageSent?.message?.documentWithCaptionMessage || - messageSent?.message?.ptvMessage || - messageSent?.message?.audioMessage; - - const isVideo = messageSent?.message?.videoMessage; + mediaContent?.imageMessage || + mediaContent?.videoMessage || + mediaContent?.stickerMessage || + mediaContent?.ptvMessage || + mediaContent?.documentMessage || + mediaContent?.documentWithCaptionMessage || + mediaContent?.ptvMessage || + mediaContent?.audioMessage; + + const isVideo = mediaContent?.videoMessage; if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled && !isIntegration) { this.chatwootService.eventWhatsapp( diff --git a/src/api/integrations/chatbot/base-chatbot.controller.ts b/src/api/integrations/chatbot/base-chatbot.controller.ts index e7e92d5d74..7f204cf650 100644 --- a/src/api/integrations/chatbot/base-chatbot.controller.ts +++ b/src/api/integrations/chatbot/base-chatbot.controller.ts @@ -7,7 +7,7 @@ import { Events } from '@api/types/wa.types'; import { Logger } from '@config/logger.config'; import { BadRequestException } from '@exceptions'; import { TriggerOperator, TriggerType } from '@prisma/client'; -import { getConversationMessage } from '@utils/getConversationMessage'; +import { getConversationMessage, hasConversationContent } from '@utils/getConversationMessage'; import { BaseChatbotDto } from './base-chatbot.dto'; import { ChatbotController, ChatbotControllerInterface, EmitData } from './chatbot.controller'; @@ -820,11 +820,17 @@ export abstract class BaseChatbotController 0) { const flag = argv.shift(); @@ -63,6 +67,8 @@ function parseArgs(argv: string[]): Options { else if (flag === '--target-scope') options.targetScope = argv.shift(); else if (flag === '--input') options.input = argv.shift(); else if (flag === '--output') options.output = argv.shift(); + else if (flag === '--lid') options.lid = argv.shift(); + else if (flag === '--snapshot-out') options.snapshotOut = argv.shift(); else if (flag === '--batch-size') options.batchSize = Number.parseInt(argv.shift() || '', 10); else usage(); } @@ -70,9 +76,50 @@ function parseArgs(argv: string[]): Options { if (command === 'backfill' && (!options.sourceCluster || !options.instanceName)) usage(); if (command === 'export' && (!options.sourceCluster || !options.instanceName || !options.output)) usage(); if (command === 'import' && (!options.input || !options.targetCluster || !options.targetScope)) usage(); + if (command === 'alias-list' && !options.instanceName) usage(); + if (command === 'alias-reset' && (!options.instanceName || !options.lid || (options.live && !options.snapshotOut))) + usage(); return options; } +export async function listAmbiguousAliases(prisma: PrismaClient, options: Options): Promise { + const instanceScope = normalizeInstanceScope(assertSafeName(options.instanceName!, 'instance-name')); + const rows = await prisma.lidPhoneAlias.findMany({ + where: { instanceScope, ambiguous: true }, + orderBy: { lidJid: 'asc' }, + select: { lidJid: true, updatedAt: true }, + }); + console.log(JSON.stringify({ instanceScope, count: rows.length, aliases: rows })); +} + +export async function resetAmbiguousAlias(prisma: PrismaClient, options: Options): Promise { + const instanceScope = normalizeInstanceScope(assertSafeName(options.instanceName!, 'instance-name')); + const lidJid = options.lid!; + if (!/^[1-9][0-9]{1,24}@lid$/.test(lidJid)) throw new Error('lid must be a full numeric @lid JID'); + const where = { instanceScope_lidJid: { instanceScope, lidJid } }; + const current = await prisma.lidPhoneAlias.findUnique({ where }); + if (!current?.ambiguous) throw new Error('Ambiguous alias not found for this instance and LID'); + if (!options.live) { + console.log(JSON.stringify({ mode: 'dry-run', instanceScope, lidJid, wouldReset: true })); + return; + } + const fd = openSync(options.snapshotOut!, 'wx', 0o600); + try { + writeFileSync( + fd, + `${JSON.stringify({ schemaVersion: 1, capturedAt: new Date().toISOString(), alias: current }, null, 2)}\n`, + 'utf8', + ); + } finally { + closeSync(fd); + } + const deleted = await prisma.lidPhoneAlias.deleteMany({ where: { instanceScope, lidJid, ambiguous: true } }); + if (deleted.count !== 1) throw new Error('Alias changed after snapshot; no reset performed'); + const readback = await prisma.lidPhoneAlias.findUnique({ where }); + if (readback) throw new Error('Alias reset readback failed'); + console.log(JSON.stringify({ mode: 'live', instanceScope, lidJid, snapshot: options.snapshotOut, deleted: 1 })); +} + function assertSafeName(value: string, label: string): string { const normalized = value.normalize('NFKC').trim(); if (!normalized || normalized.length > 255 || [...normalized].some((character) => character.charCodeAt(0) < 32)) { @@ -228,7 +275,9 @@ async function main(): Promise { await prisma.$connect(); if (options.command === 'backfill') await backfill(prisma, options); else if (options.command === 'export') await exportTombstones(prisma, options); - else await importTombstones(prisma, options); + else if (options.command === 'import') await importTombstones(prisma, options); + else if (options.command === 'alias-list') await listAmbiguousAliases(prisma, options); + else await resetAmbiguousAlias(prisma, options); } finally { await prisma.$disconnect(); } diff --git a/src/utils/getConversationMessage.ts b/src/utils/getConversationMessage.ts index eca23b4547..3d8a58072f 100644 --- a/src/utils/getConversationMessage.ts +++ b/src/utils/getConversationMessage.ts @@ -60,7 +60,12 @@ const getTypeMessage = (msg: any) => { }; const getMessageContent = (types: any) => { - const typeKey = Object.keys(types).find((key) => key !== 'externalAdReplyBody' && types[key] !== undefined); + // XMACNA_CONTENTLESS_696: `messageType` is metadata, not content. Without this exclusion an unmapped + // message (reaction, sticker, poll...) resolved to the literal 'unknown' and reached the chatbot as + // if the lead had typed it (xmacna/elysium#696). + const typeKey = Object.keys(types).find( + (key) => key !== 'externalAdReplyBody' && key !== 'messageType' && types[key] !== undefined, + ); let result = typeKey ? types[typeKey] : undefined; @@ -78,3 +83,7 @@ export const getConversationMessage = (msg: any) => { return messageContent; }; + +/** XMACNA_CONTENTLESS_696: true only when the chatbot has something the lead actually sent. */ +export const hasConversationContent = (content: unknown): content is string => + typeof content === 'string' && content.length > 0; diff --git a/test/xmacna-baileys-compat.test.cjs b/test/xmacna-baileys-compat.test.cjs index 76cea9ce3f..beee5b91bc 100644 --- a/test/xmacna-baileys-compat.test.cjs +++ b/test/xmacna-baileys-compat.test.cjs @@ -110,22 +110,26 @@ test('all auth-state providers persist arbitrary rc14 key categories', () => { }); test('security overrides resolve to the audited runtime versions', () => { - assert.equal(require('axios/package.json').version, '1.19.0'); + assert.equal(require('axios/package.json').version, '1.20.0'); assert.equal(require('link-preview-js/package.json').version, '5.0.0'); const chatwootRoot = require.resolve('@figuro/chatwoot-sdk'); const chatwootAxiosPackage = require.resolve('axios/package.json', { paths: [chatwootRoot] }); - assert.equal(require(chatwootAxiosPackage).version, '1.19.0'); + assert.equal(require(chatwootAxiosPackage).version, '1.20.0'); const baileysRoot = require.resolve('baileys'); const baileysLinkPreviewPackage = require.resolve('link-preview-js/package.json', { paths: [baileysRoot] }); assert.equal(require(baileysLinkPreviewPackage).version, '5.0.0'); - assert.equal(installedPackage('multer').version, '2.3.0'); - assert.equal(installedPackage('sharp').version, '0.35.4'); + assert.equal(installedPackage('multer').version, '2.4.0'); + assert.equal(installedPackage('sharp').version, '0.35.5'); + assert.equal(installedPackage('compression').version, '1.8.2'); + const baileysRuntime = installedPackage('baileys'); + assert.equal(installedPackage('music-metadata', baileysRuntime.dir).version, '11.16.1'); const express = installedPackage('express'); const bodyParser = installedPackage('body-parser', express.dir); + assert.equal(installedPackage('proxy-addr', express.dir).version, '2.0.8'); assert.equal(installedPackage('qs', express.dir).version, '6.16.0'); assert.equal(installedPackage('qs', bodyParser.dir).version, '6.16.0'); diff --git a/test/xmacna-get-conversation-message.test.ts b/test/xmacna-get-conversation-message.test.ts new file mode 100644 index 0000000000..86d037bd02 --- /dev/null +++ b/test/xmacna-get-conversation-message.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +process.env.S3_ENABLED = 'true'; + +const conversationMessage = import('../src/utils/getConversationMessage'); + +// MAR-304: the `media_unavailable` marker waits until the n8n missing-audio fallback +// covers the fleet; until then the webhook keeps the legacy `|undefined` format. +test('audio without mediaUrl keeps the legacy wire format', async () => { + const { getConversationMessage } = await conversationMessage; + const content = getConversationMessage({ key: { id: 'local-message' }, message: { audioMessage: {} } }); + assert.equal(content, 'audioMessage|undefined'); +}); + +test('audio with mediaUrl preserves the existing wire format', async () => { + const { getConversationMessage } = await conversationMessage; + const content = getConversationMessage({ + key: { id: 'local-message' }, + message: { audioMessage: {}, mediaUrl: 'https://local.invalid/audio.ogg' }, + }); + assert.equal(content, 'audioMessage|https://local.invalid/audio.ogg'); +}); + +// xmacna/elysium#696: an unmapped message used to come back as the literal 'unknown' (the +// `messageType` metadata leaking as content) and the n8n bot answered it as if the lead typed it. +test('unmapped messages have no content instead of the literal unknown', async () => { + const { getConversationMessage, hasConversationContent } = await conversationMessage; + const unmapped = [ + { reactionMessage: { key: { id: 'BOT-MSG' }, text: '👍' } }, + { stickerMessage: { mimetype: 'image/webp' } }, + { pollUpdateMessage: { pollCreationMessageKey: { id: 'POLL' } } }, + {}, + ]; + for (const message of unmapped) { + const content = getConversationMessage({ key: { id: 'local-message' }, message }); + assert.equal(content, undefined, JSON.stringify(Object.keys(message))); + assert.equal(hasConversationContent(content), false); + } +}); + +test('text, emoji typed as text and ad-only messages keep their content', async () => { + const { getConversationMessage, hasConversationContent } = await conversationMessage; + const cases: Array<[any, string]> = [ + [{ key: { id: 'a' }, message: { conversation: '👍' } }, '👍'], + [{ key: { id: 'b' }, message: { extendedTextMessage: { text: 'ok, pode ser' } } }, 'ok, pode ser'], + [{ key: { id: 'c' }, message: {}, contextInfo: { externalAdReply: { body: 'AnĂșncio' } } }, 'externalAdReplyBody|AnĂșncio'], + ]; + for (const [msg, expected] of cases) { + const content = getConversationMessage(msg); + assert.equal(content, expected); + assert.equal(hasConversationContent(content), true); + } +}); diff --git a/test/xmacna-inbound-identity.postgres.test.ts b/test/xmacna-inbound-identity.postgres.test.ts new file mode 100644 index 0000000000..2eccf77f33 --- /dev/null +++ b/test/xmacna-inbound-identity.postgres.test.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { PrismaClient } from '@prisma/client'; + +import { buildChatbotDebounceKey, processChatbotDebounce } from '../src/api/integrations/chatbot/chatbotDebounce'; +import { PrismaLidPhoneAliasStore, resolveInboundAddress } from '../src/api/integrations/channel/whatsapp/inboundIdentity'; +import { inboundPayloadHash, normalizeContactScope, normalizeInstanceScope, PrismaInboundInbox } from '../src/api/integrations/channel/whatsapp/inboundInbox'; +import { attemptDurableInboundSink } from '../src/api/integrations/channel/whatsapp/inboundSinkDispatch'; +import { resetAmbiguousAlias } from '../src/cli/inboundInboxMaintenance'; + +const enabled = process.env.XMACNA_INBOX_INTEGRATION === '1'; +const phone = '5511999999999@s.whatsapp.net'; +const lid = '118837617901692@lid'; + +test('515/517/525: stable receipts, one phone session, 525 retained and LID reply destination after process restart', { skip: !enabled }, async () => { + const firstProcess = new PrismaClient(); + const aliases = new PrismaLidPhoneAliasStore(firstProcess as any); + const inbox = new PrismaInboundInbox(firstProcess as any); + const suffix = randomUUID(); + const instanceName = `mar229-${suffix}`; + const instanceId = `mar229-${suffix}`; + const instanceScope = normalizeInstanceScope(instanceName); + const sourceCluster = 'mar229-local'; + const deliveries = [ + { id: 'wamid-515', remoteJid: lid, remoteJidAlt: phone, content: '515' }, + { id: 'wamid-515', remoteJid: phone, remoteJidAlt: lid, content: '515' }, + { id: 'wamid-517', remoteJid: phone, remoteJidAlt: lid, content: '517' }, + { id: 'wamid-525', remoteJid: lid, remoteJidAlt: undefined, content: '525' }, + ]; + const sessions = new Set(); + const dispatches: Array<{ id: string; phone: string; destination?: string; debounceKey: string }> = []; + const debounceStore = {}; + const flushed: string[] = []; + let lastFlush: Promise | undefined; + + try { + await firstProcess.$connect(); + await firstProcess.instance.create({ data: { id: instanceId, name: instanceName, inboundInboxMode: 'enforce' } }); + + for (const [index, delivery] of deliveries.entries()) { + // Recreate both the Prisma client and alias store before 525 to prove + // the alias is in PostgreSQL, not in a module or socket cache. + const process = index === 3 ? new PrismaClient() : firstProcess; + if (index === 3) await process.$connect(); + try { + const resolver = index === 3 ? new PrismaLidPhoneAliasStore(process as any) : aliases; + const resolved = await resolveInboundAddress(instanceName, delivery, resolver, async () => null); + const received = { + key: { id: delivery.id, remoteJid: delivery.remoteJid, remoteJidAlt: delivery.remoteJidAlt, fromMe: false }, + message: { conversation: delivery.content }, + }; + const receipt = await inbox.claim({ + sourceCluster, + instanceId, + instanceScope, + contactScope: normalizeContactScope(resolved.remoteJid), + messageId: delivery.id, + classification: 'real', + payloadHash: inboundPayloadHash(received as any), + leaseOwner: `worker-${index}`, + messageData: { + key: { ...received.key, senderLid: resolved.senderLid }, + message: received.message, + messageType: 'conversation', + messageTimestamp: index + 1, + source: 'unknown', + instanceId, + }, + }, 'enforce'); + if (!receipt.shouldDispatch) continue; + sessions.add(`bot_${resolved.remoteJid.split('@')[0]}`); + const debounceKey = buildChatbotDebounceKey(instanceName, resolved.remoteJid); + dispatches.push({ id: delivery.id, phone: resolved.remoteJid, destination: resolved.senderLid, debounceKey }); + lastFlush = processChatbotDebounce( + debounceStore, + delivery.content, + debounceKey, + 1, + async (content) => { flushed.push(content); }, + ).flushed; + } finally { + if (index === 3) await process.$disconnect(); + } + } + await lastFlush; + assert.deepEqual(dispatches.map(({ id }) => id), ['wamid-515', 'wamid-517', 'wamid-525']); + assert.equal(new Set(dispatches.map(({ debounceKey }) => debounceKey)).size, 1); + assert.deepEqual([...sessions], ['bot_5511999999999']); + assert.equal(dispatches.at(-1)?.destination, lid); + assert.deepEqual(flushed, ['515\n517\n525']); + assert.equal(await firstProcess.inboundReceipt.count({ where: { sourceCluster, instanceScope } }), 3); + } finally { + await firstProcess.inboundReceipt.deleteMany({ where: { sourceCluster, instanceScope } }); + await firstProcess.message.deleteMany({ where: { instanceId } }); + await firstProcess.lidPhoneAlias.deleteMany({ where: { instanceScope } }); + await firstProcess.instance.deleteMany({ where: { id: instanceId } }); + await firstProcess.$disconnect(); + } +}); + +test('concurrent conflicting confirmations poison an instance alias durably', { skip: !enabled }, async () => { + const client = new PrismaClient(); + const scope = normalizeInstanceScope(`mar229-conflict-${randomUUID()}`); + const store = new PrismaLidPhoneAliasStore(client as any); + try { + await client.$connect(); + const results = await Promise.allSettled([ + store.confirm(scope, lid, phone), + store.confirm(scope, lid, '5511888888888@s.whatsapp.net'), + ]); + assert.equal(results.filter((result) => result.status === 'rejected').length >= 1, true); + await assert.rejects(store.find(scope, lid), /ambiguous/); + const row = await client.lidPhoneAlias.findUniqueOrThrow({ + where: { instanceScope_lidJid: { instanceScope: scope, lidJid: lid } }, + }); + assert.equal(row.ambiguous, true); + assert.equal(row.phoneJid, null); + } finally { + await client.lidPhoneAlias.deleteMany({ where: { instanceScope: scope } }); + await client.$disconnect(); + } +}); + +test('unknown LID keeps human sinks and receipt, then replays chatbot after confirmation', { skip: !enabled }, async () => { + const client = new PrismaClient(); + const instanceName = `mar229-unresolved-${randomUUID()}`; + const instanceId = instanceName; + const instanceScope = normalizeInstanceScope(instanceName); + const sourceCluster = 'mar229-local'; + const inbox = new PrismaInboundInbox(client as any); + const aliases = new PrismaLidPhoneAliasStore(client as any); + const delivered: string[] = []; + try { + await client.$connect(); + await client.instance.create({ data: { id: instanceId, name: instanceName, inboundInboxMode: 'enforce' } }); + const first = { key: { id: 'wamid-unknown', remoteJid: lid, fromMe: false }, message: { conversation: 'first' } }; + const unresolved = await resolveInboundAddress(instanceName, first.key, aliases, async () => null).catch((error) => error); + assert.match(unresolved.message, /unknown/); + const claim = await inbox.claim({ + sourceCluster, instanceScope, instanceId, messageId: first.key.id, + contactScope: normalizeContactScope(lid), classification: 'real', payloadHash: inboundPayloadHash(first as any), + leaseOwner: 'first-worker', messageData: { + key: first.key, message: first.message, messageType: 'conversation', messageTimestamp: 1, + source: 'unknown', instanceId, + }, + }, 'enforce'); + assert.equal(claim.shouldDispatch, true); + const mark = (sink: 'webhook' | 'chatwoot' | 'chatbot', state: 'sent' | 'skipped' | 'failed') => + inbox.markSink(claim.receiptId, sink, state, 'first-worker', claim.leaseToken!).then((ok) => { assert.equal(ok, true); }); + for (const sink of ['chatwoot', 'webhook'] as const) { + const failure = await attemptDurableInboundSink({ sink, operation: async () => { delivered.push(sink); return 'sent'; }, mark }); + assert.equal(failure, undefined); + } + const failed = await attemptDurableInboundSink({ + sink: 'chatbot', operation: async () => { throw unresolved; }, mark, + }); + assert.equal(failed?.sink, 'chatbot'); + assert.deepEqual(delivered, ['chatwoot', 'webhook']); + assert.equal(await inbox.markFailed(claim.receiptId, 'first-worker', claim.leaseToken!, failed?.error, 10, 0), true); + + // A second message in the same batch can still be claimed and delivered. + const second = await inbox.claim({ + sourceCluster, instanceScope, instanceId, messageId: 'wamid-next', + contactScope: normalizeContactScope(lid), classification: 'real', payloadHash: inboundPayloadHash({ message: { conversation: 'next' } } as any), + leaseOwner: 'second-worker', messageData: { + key: { id: 'wamid-next', remoteJid: lid, fromMe: false }, message: { conversation: 'next' }, + messageType: 'conversation', messageTimestamp: 2, source: 'unknown', instanceId, + }, + }, 'enforce'); + assert.equal(second.shouldDispatch, true); + for (const sink of ['chatwoot', 'webhook'] as const) { + assert.equal(await inbox.markSink(second.receiptId, sink, 'sent', 'second-worker', second.leaseToken!), true); + } + assert.equal(await inbox.markSink(second.receiptId, 'chatbot', 'failed', 'second-worker', second.leaseToken!), true); + assert.equal(await inbox.markFailed(second.receiptId, 'second-worker', second.leaseToken!, unresolved, 10, 0), true); + + await aliases.confirm(instanceScope, lid, phone); + const replay = await inbox.leaseNext(sourceCluster, instanceScope, 'replay-worker', 30, 10); + assert.equal(replay?.receiptId, claim.receiptId); + assert.equal(replay?.chatwootState, 'sent'); + assert.equal(replay?.webhookState, 'sent'); + assert.equal(replay?.chatbotState, 'failed'); + const resolved = await resolveInboundAddress(instanceName, (replay!.message as any).key, aliases, async () => null); + assert.deepEqual(resolved, { remoteJid: phone, senderLid: lid }); + assert.equal(await inbox.markSink(replay!.receiptId, 'chatbot', 'sent', 'replay-worker', replay!.leaseToken), true); + assert.equal(await inbox.markDone(replay!.receiptId, 'replay-worker', replay!.leaseToken), true); + const secondReplay = await inbox.leaseNext(sourceCluster, instanceScope, 'replay-worker', 30, 10); + assert.equal(secondReplay?.receiptId, second.receiptId); + assert.deepEqual(await resolveInboundAddress(instanceName, (secondReplay!.message as any).key, aliases), { remoteJid: phone, senderLid: lid }); + assert.equal(await inbox.markSink(secondReplay!.receiptId, 'chatbot', 'sent', 'replay-worker', secondReplay!.leaseToken), true); + assert.equal(await inbox.markDone(secondReplay!.receiptId, 'replay-worker', secondReplay!.leaseToken), true); + assert.equal(await client.inboundReceipt.count({ where: { sourceCluster, instanceScope, state: 'done' } }), 2); + } finally { + await client.inboundReceipt.deleteMany({ where: { sourceCluster, instanceScope } }); + await client.message.deleteMany({ where: { instanceId } }); + await client.lidPhoneAlias.deleteMany({ where: { instanceScope } }); + await client.instance.deleteMany({ where: { id: instanceId } }); + await client.$disconnect(); + } +}); + +test('ambiguous alias reset is dry-run by default and snapshots before local deletion', { skip: !enabled }, async () => { + const client = new PrismaClient(); + const instanceName = `mar229-reset-${randomUUID()}`; + const instanceScope = normalizeInstanceScope(instanceName); + const folder = mkdtempSync(join(tmpdir(), 'mar229-alias-')); + const snapshotOut = join(folder, 'alias.json'); + try { + await client.$connect(); + await client.lidPhoneAlias.create({ data: { instanceScope, lidJid: lid, ambiguous: true } }); + const options = { command: 'alias-reset' as const, instanceName, lid, snapshotOut, batchSize: 1, live: false }; + await resetAmbiguousAlias(client, options); + assert.equal((await client.lidPhoneAlias.findUnique({ where: { instanceScope_lidJid: { instanceScope, lidJid: lid } } }))?.ambiguous, true); + await resetAmbiguousAlias(client, { ...options, live: true }); + assert.equal(await client.lidPhoneAlias.count({ where: { instanceScope, lidJid: lid } }), 0); + assert.equal(JSON.parse(readFileSync(snapshotOut, 'utf8')).alias.lidJid, lid); + assert.equal(statSync(snapshotOut).mode & 0o777, 0o600); + } finally { + await client.lidPhoneAlias.deleteMany({ where: { instanceScope } }); + await client.$disconnect(); + rmSync(folder, { recursive: true, force: true }); + } +}); diff --git a/test/xmacna-inbound-identity.test.ts b/test/xmacna-inbound-identity.test.ts new file mode 100644 index 0000000000..90c2033d06 --- /dev/null +++ b/test/xmacna-inbound-identity.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + LidPhoneAliasStore, + resolveInboundAddress, + UnresolvedInboundLidError, +} from '../src/api/integrations/channel/whatsapp/inboundIdentity'; +import { normalizeInstanceScope } from '../src/api/integrations/channel/whatsapp/inboundInbox'; +import { dispatchInboundChatbot } from '../src/api/integrations/channel/whatsapp/inboundSinkDispatch'; + +class MemoryAliases implements LidPhoneAliasStore { + private rows = new Map(); + private key(scope: string, lid: string) { + return JSON.stringify([scope, lid]); + } + async confirm(scope: string, lid: string, phone: string) { + const key = this.key(scope, lid); + if (this.rows.has(key) && this.rows.get(key) !== phone) this.rows.set(key, null); + else if (!this.rows.has(key)) this.rows.set(key, phone); + const result = this.rows.get(key); + if (!result) throw new UnresolvedInboundLidError('ambiguous'); + return result; + } + async find(scope: string, lid: string) { + const result = this.rows.get(this.key(scope, lid)); + if (result === null) throw new UnresolvedInboundLidError('ambiguous'); + return result ?? null; + } +} + +const phone = '5511999999999@s.whatsapp.net'; +const lid = '118837617901692@lid'; + +test('a confirmed remoteJidAlt persists for a later LID-only event and keeps the reply destination', async () => { + const aliases = new MemoryAliases(); + const first = await resolveInboundAddress('TaubatĂ©', { remoteJid: lid, remoteJidAlt: phone }, aliases); + const restartedProcess = await resolveInboundAddress('TaubatĂ©', { remoteJid: lid }, aliases, async () => null); + assert.deepEqual(first, { remoteJid: phone, senderLid: lid }); + assert.deepEqual(restartedProcess, first); + assert.equal(await aliases.find(normalizeInstanceScope('TaubatĂ©'), lid), phone); +}); + +test('Baileys contact is accepted only when it returns a full phone JID', async () => { + const aliases = new MemoryAliases(); + assert.deepEqual( + await resolveInboundAddress('TaubatĂ©', { remoteJid: lid }, aliases, async () => phone), + { remoteJid: phone, senderLid: lid }, + ); + await assert.rejects( + resolveInboundAddress('Other', { remoteJid: lid }, aliases, async () => '5511999999999'), + /unknown/, + ); +}); + +test('unknown, conflicting and cross-instance aliases fail without deriving digits from the LID', async () => { + const aliases = new MemoryAliases(); + await assert.rejects(resolveInboundAddress('TaubatĂ©', { remoteJid: lid }, aliases), /unknown/); + await resolveInboundAddress('TaubatĂ©', { remoteJid: lid, remoteJidAlt: phone }, aliases); + await assert.rejects(resolveInboundAddress('Other', { remoteJid: lid }, aliases), /unknown/); + await assert.rejects( + resolveInboundAddress( + 'TaubatĂ©', + { remoteJid: lid, remoteJidAlt: '5511888888888@s.whatsapp.net' }, + aliases, + ), + /ambiguous/, + ); + await assert.rejects(resolveInboundAddress('TaubatĂ©', { remoteJid: lid }, aliases), /ambiguous/); +}); + +test('a phone event with alternate LID confirms the alias for a later LID event', async () => { + const aliases = new MemoryAliases(); + assert.deepEqual( + await resolveInboundAddress('TaubatĂ©', { remoteJid: phone, remoteJidAlt: lid }, aliases), + { remoteJid: phone, senderLid: undefined }, + ); + assert.deepEqual(await resolveInboundAddress('TaubatĂ©', { remoteJid: lid }, aliases), { + remoteJid: phone, + senderLid: lid, + }); +}); + +test('group addresses do not become a member phone through an alternate LID', async () => { + const aliases = new MemoryAliases(); + assert.deepEqual( + await resolveInboundAddress('TaubatĂ©', { remoteJid: '123456@g.us', remoteJidAlt: lid }, aliases), + { remoteJid: '123456@g.us' }, + ); +}); + +test('unknown LID reaches the bot with its original JID in off and shadow', async () => { + for (const receiptMode of [undefined, 'shadow'] as const) { + const aliases = new MemoryAliases(); + const calls: string[] = []; + let unresolved: UnresolvedInboundLidError | undefined; + try { + await resolveInboundAddress('TaubatĂ©', { remoteJid: lid }, aliases, async () => null); + } catch (error) { + assert.ok(error instanceof UnresolvedInboundLidError); + unresolved = error; + } + await dispatchInboundChatbot({ + unresolvedLid: unresolved, + receiptMode, + attempt: async (operation) => { await operation(); return true; }, + durable: async () => { calls.push(`n8n:${lid}`); }, + bestEffort: async () => { calls.push(`best-effort:${lid}`); }, + }); + assert.deepEqual(calls, [`n8n:${lid}`, `best-effort:${lid}`]); + } +}); + +test('enforce receipt retains unknown LID and retries after alias confirmation', async () => { + const aliases = new MemoryAliases(); + const calls: string[] = []; + let unresolved: UnresolvedInboundLidError | undefined; + try { + await resolveInboundAddress('TaubatĂ©', { remoteJid: lid }, aliases, async () => null); + } catch (error) { + assert.ok(error instanceof UnresolvedInboundLidError); + unresolved = error; + } + let failed = false; + await dispatchInboundChatbot({ + unresolvedLid: unresolved, + receiptMode: 'enforce', + attempt: async (operation) => { + try { await operation(); return true; } + catch (error) { assert.equal(error, unresolved); failed = true; return false; } + }, + durable: async () => { calls.push('n8n'); }, + bestEffort: async () => { calls.push('best-effort'); }, + }); + assert.equal(failed, true); + assert.deepEqual(calls, []); + + await resolveInboundAddress('TaubatĂ©', { remoteJid: lid, remoteJidAlt: phone }, aliases); + const retry = await resolveInboundAddress('TaubatĂ©', { remoteJid: lid }, aliases, async () => null); + await dispatchInboundChatbot({ + receiptMode: 'enforce', + attempt: async (operation) => { await operation(); return true; }, + durable: async () => { calls.push(`n8n:${retry.remoteJid}`); }, + bestEffort: async () => { calls.push(`best-effort:${retry.remoteJid}`); }, + }); + assert.deepEqual(calls, [`n8n:${phone}`, `best-effort:${phone}`]); +}); + +test('the same message ID with and without alternate JID stays in one phone session', async () => { + const aliases = new MemoryAliases(); + const deliveries = [ + { id: 'wamid-mirtes', remoteJid: lid, remoteJidAlt: phone }, + { id: 'wamid-mirtes', remoteJid: lid }, + ]; + const sessions = new Set(); + for (const delivery of deliveries) { + const resolved = await resolveInboundAddress('TaubatĂ©', delivery, aliases, async () => null); + sessions.add(`bot_${resolved.remoteJid.split('@')[0]}`); + } + assert.deepEqual([...sessions], ['bot_5511999999999']); +}); diff --git a/test/xmacna-inbound-media-guard.test.ts b/test/xmacna-inbound-media-guard.test.ts new file mode 100644 index 0000000000..9ecc4367cd --- /dev/null +++ b/test/xmacna-inbound-media-guard.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { proto } from 'baileys'; + +import { ownKeyMediaMessage, unwrapInboundMediaContent } from '../src/api/integrations/channel/whatsapp/inboundMediaUpload'; +import { BaileysStartupService } from '../src/api/integrations/channel/whatsapp/whatsapp.baileys.service'; + +// MAR-304: received media is a raw Baileys protobuf. getBase64FromMediaMessage skips it +// when `'messageContextInfo' in message` (true through the prototype) and it has a single +// own key. These tests feed decoded protobufs, as Baileys delivers them, to the real method. + +const PAST_GUARD = 'PAST_MESSAGE_CONTEXT_GUARD'; +const SKIPPED = 'verbose:Message contains only messageContextInfo, skipping media processing'; +const key = { id: '3EB0LOCALTEST', remoteJid: '5500000000000@s.whatsapp.net', fromMe: false }; +const bytes = () => new Uint8Array(32); +const audio = { + url: 'https://mmg.whatsapp.net/local-test', + mimetype: 'audio/ogg; codecs=opus', + fileLength: 1024, + seconds: 3, + ptt: true, + mediaKey: bytes(), + directPath: '/local-test', + fileSha256: bytes(), + fileEncSha256: bytes(), +}; +const document = { ...audio, mimetype: 'application/pdf', fileName: 'local.pdf', caption: 'local', ptt: undefined }; +const video = { ...audio, mimetype: 'video/mp4', fileName: 'local.mp4', seconds: 5, ptt: undefined }; +const image = { ...audio, mimetype: 'image/jpeg', fileName: 'local.jpg', ptt: undefined }; +const contextInfo = { messageSecret: bytes() }; + +const decode = (message: object) => + proto.Message.decode(proto.Message.encode(proto.Message.fromObject(message)).finish()); + +// The first media-field read after the guard is `mediaMessage.mediaKey`; a throwing getter +// proves the method went past the guard without touching the network. +const armMediaKey = (media: object) => + Object.defineProperty(media, 'mediaKey', { + get: () => { + throw new Error(PAST_GUARD); + }, + }); + +const getBase64 = async (message: object) => { + const logs: string[] = []; + const self = { + logger: { + verbose: (value: unknown) => logs.push(`verbose:${value}`), + error: () => undefined, + info: () => undefined, + }, + client: { updateMediaMessage: async () => undefined }, + getMessage: async () => null, + mapMediaType: async () => 'audio', + }; + const outcome = await (BaileysStartupService.prototype as any).getBase64FromMediaMessage + .call(self, { message }, true) + .then((value: unknown) => (value === null ? 'skipped' : 'returned')) + .catch((error: unknown) => (String((error as Error)?.message ?? error).includes(PAST_GUARD) ? 'past_guard' : 'threw')); + return { outcome, logs }; +}; + +test('wacli shape (only audioMessage) is dropped raw and reaches media processing as own-key copy', async () => { + const message = decode({ audioMessage: audio }); + armMediaKey(message.audioMessage); + const received = { key, message }; + + const raw = await getBase64(received); + assert.equal(raw.outcome, 'skipped'); + assert.deepEqual(raw.logs, [SKIPPED]); + + const own = await getBase64(ownKeyMediaMessage(received)); + assert.equal(own.outcome, 'past_guard'); + assert.deepEqual(own.logs, []); +}); + +test('phone shape (audioMessage + messageContextInfo) reaches media processing either way', async () => { + const message = decode({ audioMessage: audio, messageContextInfo: contextInfo }); + armMediaKey(message.audioMessage); + const received = { key, message }; + + assert.equal((await getBase64(received)).outcome, 'past_guard'); + assert.equal((await getBase64(ownKeyMediaMessage(received))).outcome, 'past_guard'); +}); + +test('messageContextInfo-only message is still skipped with the own-key copy', async () => { + const received = { key, message: decode({ messageContextInfo: contextInfo }) }; + + const own = await getBase64(ownKeyMediaMessage(received)); + assert.equal(own.outcome, 'skipped'); + assert.deepEqual(own.logs, [SKIPPED]); +}); + +test('document with caption: the unwrapped inner protobuf is dropped raw and kept as own-key copy', async () => { + const message = decode({ + documentWithCaptionMessage: { message: { documentMessage: document } }, + messageContextInfo: contextInfo, + }); + armMediaKey(message.documentWithCaptionMessage.message.documentMessage); + + assert.equal((await getBase64({ key, message })).outcome, 'skipped'); + assert.equal((await getBase64(ownKeyMediaMessage({ key, message }))).outcome, 'past_guard'); +}); + +test('ephemeral wacli audio reaches media processing and the received message is not mutated', async () => { + const message = decode({ ephemeralMessage: { message: { audioMessage: audio } } }); + armMediaKey(message.ephemeralMessage.message.audioMessage); + const received = { key, message }; + + assert.equal((await getBase64(ownKeyMediaMessage(received))).outcome, 'past_guard'); + assert.equal(received.message, message); + assert.deepEqual(Object.keys(received.message), ['ephemeralMessage']); + assert.ok(received.message.ephemeralMessage.message instanceof proto.Message); +}); + +test('send path shape (prepareMessage output) already passes the guard without the own-key copy', async () => { + const sent = { key: { ...key, fromMe: true }, message: decode({ audioMessage: audio }), messageTimestamp: 1 }; + const service = BaileysStartupService.prototype as any; + const self = { instanceId: 'local-instance', deserializeMessageBuffers: service.deserializeMessageBuffers }; + self.deserializeMessageBuffers = self.deserializeMessageBuffers.bind(self); + const messageRaw = service.prepareMessage.call(self, sent); + armMediaKey(messageRaw.message.audioMessage); + + assert.equal((await getBase64(messageRaw)).outcome, 'past_guard'); +}); + +// MAR-414: view-once/ephemeral media arrives wrapped, so the top-level isMedia/isVideo +// guards skipped the S3 block entirely. unwrapInboundMediaContent must expose the inner +// media (and videoMessage specifically) without mutating the persisted envelope. +test('viewOnceMessageV2 video: the unwrapped content is the inner videoMessage (media+video true)', () => { + const message = decode({ viewOnceMessageV2: { message: { videoMessage: video } } }); + + const content = unwrapInboundMediaContent(message); + assert.ok(content.videoMessage); + assert.equal(content.videoMessage, message.viewOnceMessageV2.message.videoMessage); + assert.equal(message.viewOnceMessageV2.message.videoMessage.mimetype, 'video/mp4'); +}); + +test('viewOnceMessage video and ephemeral image unwrap through their envelopes', () => { + const viewOnce = decode({ viewOnceMessage: { message: { videoMessage: video } } }); + const ephemeral = decode({ ephemeralMessage: { message: { imageMessage: image } } }); + + assert.ok(unwrapInboundMediaContent(viewOnce).videoMessage); + assert.ok(unwrapInboundMediaContent(ephemeral).imageMessage); +}); + +test('viewOnceMessageV2Extension video unwraps (extended variant, MAR-414)', () => { + const message = decode({ viewOnceMessageV2Extension: { message: { videoMessage: video } } }); + + assert.ok(unwrapInboundMediaContent(message).videoMessage); +}); + +test('plain and nested envelopes unwrap recursively to the innermost media', () => { + const plain = decode({ videoMessage: video }); + const nested = decode({ ephemeralMessage: { message: { viewOnceMessageV2: { message: { videoMessage: video } } } } }); + + assert.ok(unwrapInboundMediaContent(plain).videoMessage); + assert.ok(unwrapInboundMediaContent(nested).videoMessage); +}); + +test('envelope without media keeps detection false and does not change the envelope', () => { + const message = decode({ viewOnceMessageV2: { message: { extendedTextMessage: { text: 'local' } } } }); + const content = unwrapInboundMediaContent(message); + + assert.ok(!(content.imageMessage || content.videoMessage || content.audioMessage || content.documentMessage)); + assert.deepEqual(Object.keys(message), ['viewOnceMessageV2']); +}); diff --git a/test/xmacna-inbound-media-upload.test.ts b/test/xmacna-inbound-media-upload.test.ts new file mode 100644 index 0000000000..812361ea1f --- /dev/null +++ b/test/xmacna-inbound-media-upload.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { runInboundMediaUpload } from '../src/api/integrations/channel/whatsapp/inboundMediaUpload'; + +const context = { instance: 'test-instance', messageId: 'test-message' }; + +test('empty media emits a warning with instance and ID and stops before S3', async () => { + const warnings: string[] = []; + let uploads = 0; + const result = await runInboundMediaUpload(context, { warn: (value) => warnings.push(value) }, { + download: async () => null, + upload: async () => { uploads++; return {}; }, + createMedia: async () => undefined, + updateMessage: async () => undefined, + }); + assert.equal(result, false); + assert.equal(uploads, 0); + assert.deepEqual(JSON.parse(warnings[0]), { + event: 'inbound_media_upload_skipped', ...context, stage: 'download', reason: 'empty_media', + }); +}); + +test('download exception emits a stage warning and does not create Media', async () => { + const warnings: string[] = []; + let creates = 0; + const result = await runInboundMediaUpload(context, { warn: (value) => warnings.push(value) }, { + download: async (): Promise => { throw new Error('private upstream URL'); }, + upload: async () => ({}), + createMedia: async () => { creates++; }, + updateMessage: async () => undefined, + }); + assert.equal(result, false); + assert.equal(creates, 0); + assert.deepEqual(JSON.parse(warnings[0]), { + event: 'inbound_media_upload_failed', ...context, stage: 'download', errorType: 'Error', + }); + assert.equal(warnings[0].includes('private upstream URL'), false); +}); + +test('S3 returned Error stops Media creation and identifies the failing stage', async () => { + const warnings: string[] = []; + let creates = 0; + const result = await runInboundMediaUpload(context, { warn: (value) => warnings.push(value) }, { + download: async () => ({}), + upload: async () => new Error('S3 rejected'), + createMedia: async () => { creates++; }, + updateMessage: async () => undefined, + }); + assert.equal(result, false); + assert.equal(creates, 0); + assert.equal(JSON.parse(warnings[0]).stage, 's3_upload'); +}); + +test('successful upload creates Media and updates the message in order', async () => { + const calls: string[] = []; + const result = await runInboundMediaUpload(context, { warn: () => assert.fail('unexpected warning') }, { + download: async () => { calls.push('download'); return { bytes: 1 }; }, + upload: async () => { calls.push('s3'); return { key: 'local-only' }; }, + createMedia: async (_media, uploaded) => { assert.equal(uploaded.key, 'local-only'); calls.push('media'); }, + updateMessage: async () => { calls.push('message'); }, + }); + assert.equal(result, true); + assert.deepEqual(calls, ['download', 's3', 'media', 'message']); +}); diff --git a/test/xmacna-inbound-service-contracts.test.ts b/test/xmacna-inbound-service-contracts.test.ts index 2a3fb5b9e7..601f377f6c 100644 --- a/test/xmacna-inbound-service-contracts.test.ts +++ b/test/xmacna-inbound-service-contracts.test.ts @@ -74,10 +74,10 @@ test('durable n8n delivery is isolated from best-effort chatbot effects', async assert.deepEqual(failures, ['dify:dify unavailable']); const serviceSource = readFileSync('src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts', 'utf8'); - assert.match(serviceSource, /attempt\('chatbot'[\s\S]{0,500}emitDurableInbound/); + assert.match(serviceSource, /attempt\('chatbot'[\s\S]{0,1000}emitDurableInbound/); assert.match(serviceSource, /recoveredChatbot === 'succeeded'[\s\S]{0,300}emitBestEffortInbound/); - assert.match(serviceSource, /attemptActiveSink\('chatbot'[\s\S]{0,500}emitDurableInbound/); - assert.match(serviceSource, /if \(chatbotDelivered\)[\s\S]{0,300}emitBestEffortInbound/); + assert.match(serviceSource, /dispatchInboundChatbot\(\{[\s\S]{0,500}attemptActiveSink\('chatbot'[\s\S]{0,500}emitDurableInbound/); + assert.match(serviceSource, /dispatchInboundChatbot\(\{[\s\S]{0,1200}emitBestEffortInbound/); }); test('Chatwoot edit revisions have stable identities distinct from the original WAID', () => {