Spaces:
Runtime error
Runtime error
File size: 103,252 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 | import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
HttpException,
HttpStatus,
OnModuleDestroy,
OnModuleInit,
OnApplicationBootstrap,
Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository, InjectDataSource } from '@nestjs/typeorm';
import { Repository, In, Not, IsNull, DataSource, FindManyOptions } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm';
import { Session, SessionStatus } from './entities/session.entity';
import { Message, MessageDirection, MessageStatus } from '../message/entities/message.entity';
import { MessageBatch } from '../message/entities/message-batch.entity';
import { Webhook } from '../webhook/entities/webhook.entity';
import { Template } from '../template/entities/template.entity';
import { BaileysStoredMessage } from '../../engine/adapters/baileys-stored-message.entity';
import { CreateSessionDto } from './dto';
import { EngineFactory } from '../../engine/engine.factory';
import { resolveAuthTimeoutMs } from '../../engine/adapters/whatsapp-web-js.adapter';
import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service';
import { userPart } from '../../engine/identity/wa-id';
import { paginate, ListOptions, resolveListWindow } from '../../common/utils/paginate';
import { isUniqueConstraintError } from '../../common/utils/unique-constraint.util';
import { resolveFeatureFlags } from '../../config/feature-flags';
import {
IWhatsAppEngine,
EngineStatus,
ChatSummary,
ChatState,
DeliveryStatus,
IncomingMessage,
ReactionEvent,
EditedMessage,
GroupEvent,
IncomingCallEvent,
} from '../../engine/interfaces/whatsapp-engine.interface';
import { createLogger } from '../../common/services/logger.service';
import { ShutdownService } from '../../common/services/shutdown.service';
import {
incrementSessionReconnectAttempts,
incrementSessionReconnectLoopAlerts,
} from '../../common/metrics/session-reconnect-metrics';
import { EventsGateway } from '../events/events.gateway';
import { WebhookService } from '../webhook/webhook.service';
import { HookManager } from '../../core/hooks';
import {
deliveryStatusToMessageStatus,
deliveryStatusToAck,
ackStatusTransitionFrom,
} from '../message/message-status.util';
// Message types that carry downloadable media. Any persisted row of these types must have a media
// marker in metadata β never NULL β or the dashboard renders an empty bubble (no placeholder) and the
// by-type stats filter skips the row. Sources that lack the payload (wwjs own-send echo, media-free
// history sync) get the omitted marker synthesized at the persistence chokepoints.
const MEDIA_MESSAGE_TYPES = new Set(['image', 'video', 'audio', 'voice', 'sticker', 'document']);
interface ReconnectState {
attempts: number;
timer: NodeJS.Timeout | null;
maxAttempts: number;
baseDelay: number;
/** When the last attempt was scheduled (epoch ms) β feeds the stability reset in scheduleReconnect. */
lastAttemptAt?: number;
}
// Reconnect-backoff bounds. An OPERATOR-supplied session.config feeds this math, so the values
// are coerced + clamped: a non-numeric value would otherwise make the delay NaN (setTimeout fires
// at 0 β relaunch storm) and the terminal guard `attempts >= NaN` always false (unbounded loop).
const RECONNECT_BASE_DELAY_MIN_MS = 1000;
const RECONNECT_BASE_DELAY_MAX_MS = 300_000;
const RECONNECT_MAX_ATTEMPTS_CAP = 20;
const RECONNECT_DELAY_CAP_MS = 3_600_000;
/**
* A reconnect-attempt budget covers one CONTINUOUS bad stretch: once this much time has passed
* since the last scheduled attempt the session demonstrably stayed up, so `attempts` resets to 0.
* Without it a long-lived session would slowly accrue attempts toward an explicit cap across
* unrelated transient drops and one day wedge FAILED for no current reason.
*/
const RECONNECT_STABILITY_RESET_MS = 300_000;
/**
* A reconnect-loop alert fires once per this many CONSECUTIVE attempts of a session β one signal per
* ongoing episode, not spam per attempt. A broken-forever setup retries without limit (by design), so
* the 5th/10th/15th⦠scheduled attempt is the operator-facing tell; the streak resets via the
* stability window above (or onReady), so a later episode re-arms the alert from attempt 5 again.
*/
const RECONNECT_LOOP_ALERT_INTERVAL_ATTEMPTS = 5;
/**
* Session liveness watchdog. The engine layer is event-driven, so an engine that dies WITHOUT
* firing an event (a silent Chromium crash) is never noticed: the row sits READY forever and never
* reconnects. Every INTERVAL the watchdog actively probes each READY engine (feature-detected
* `probeLiveness`, raced against TIMEOUT); MAX_FAILURES consecutive failures route the session
* through the exact engine-disconnect path.
*/
export const SESSION_WATCHDOG_INTERVAL_MS = 60_000;
export const SESSION_WATCHDOG_PROBE_TIMEOUT_MS = 15_000;
export const SESSION_WATCHDOG_MAX_FAILURES = 2;
/**
* Delay before retrying an ack UPDATE that matched 0 rows. A fast delivered/read ack can arrive before
* the send's 2nd save (which writes waMessageId) has committed, so the first UPDATE finds no row. One
* retry after this delay closes that race; the forward-only transition guard keeps it idempotent.
*/
export const ACK_RECONCILE_DELAY_MS = 750;
const clampNumber = (n: number, min: number, max: number): number => Math.min(Math.max(n, min), max);
/** Coerce + clamp the untyped session.config reconnect knobs to finite, bounded values. Defaults are
* a 5000ms base delay and UNLIMITED attempts (`Infinity`): a long-lived session must keep retrying
* (the backoff parks at the 1h cap) instead of dying permanently after ~2.5 minutes. An EXPLICIT
* `maxReconnectAttempts: 0` (disable) is preserved, and 1..20 clamps as before. */
export function resolveReconnectConfig(
config: { maxReconnectAttempts?: unknown; reconnectBaseDelay?: unknown } | null,
): { maxAttempts: number; baseDelay: number } {
const baseRaw = Number(config?.reconnectBaseDelay);
const baseDelay = clampNumber(
Number.isFinite(baseRaw) ? baseRaw : 5000,
RECONNECT_BASE_DELAY_MIN_MS,
RECONNECT_BASE_DELAY_MAX_MS,
);
const attemptsRaw = Number(config?.maxReconnectAttempts);
const maxAttempts = Number.isFinite(attemptsRaw)
? Math.floor(clampNumber(attemptsRaw, 0, RECONNECT_MAX_ATTEMPTS_CAP))
: Number.POSITIVE_INFINITY;
return { maxAttempts, baseDelay };
}
/** Clamp a computed backoff delay finite and within setTimeout's safe range (a huge value would
* overflow its 32-bit ms field and fire immediately). */
export function clampReconnectDelay(rawDelay: number, baseDelay: number): number {
return clampNumber(Number.isFinite(rawDelay) ? rawDelay : baseDelay, 0, RECONNECT_DELAY_CAP_MS);
}
export function resolveMaxConcurrentSessions(configService?: Pick<ConfigService, 'get'>): number | null {
const configured = configService?.get<number>('sessions.maxConcurrent', 0) ?? 0;
if (!Number.isFinite(configured) || configured <= 0) return null;
return Math.floor(configured);
}
/**
* Distinguishes a wedged-initialization timeout from a real engine.initialize() rejection. Only the
* timeout case is handled inside initializeEngine(); real rejections must propagate untouched so the
* caller's catch (start() β FAILED+reason, executeReconnect() β retry) keeps the behavior #600/#631
* established. See initializeEngine().
*/
export class EngineInitTimeoutError extends Error {
constructor(readonly timeoutMs: number) {
super(`engine.initialize() timed out after ${timeoutMs}ms`);
this.name = 'EngineInitTimeoutError';
}
}
/**
* whatsapp-web.js throws this primitive STRING (not an Error) from its inject() auth poll when WA Web's
* login bootstrap doesn't complete within authTimeoutMs (default 30s). Match it defensively as both the
* bare string and an Error carrying the same message, since the library's throw shape isn't contracted.
*/
const ENGINE_AUTH_TIMEOUT = 'auth timeout';
/**
* Diagnostic surfaced when the engine's internal auth-timeout fires (#733): points at the usual cause
* (the session proxy / network egress / firewall blocking WhatsApp so no QR is ever delivered) and the
* WWEBJS_AUTH_TIMEOUT_MS knob for legitimately slow first boots.
*/
const ENGINE_AUTH_TIMEOUT_MESSAGE =
'WhatsApp Web authentication timed out. Verify the session proxy URL and network egress can reach ' +
'WhatsApp; for slow first boots, raise WWEBJS_AUTH_TIMEOUT_MS.';
function isAuthTimeoutRejection(err: unknown): boolean {
return err === ENGINE_AUTH_TIMEOUT || (err instanceof Error && err.message === ENGINE_AUTH_TIMEOUT);
}
@Injectable()
export class SessionService implements OnModuleDestroy, OnModuleInit, OnApplicationBootstrap {
private readonly logger = createLogger('SessionService');
// In-memory map of active engine instances
private engines: Map<string, IWhatsAppEngine> = new Map();
// Bounded cache for inline @lid -> phone resolution (#263), keyed `${sessionId}:${lid}`. Caches
// misses (null) too, so a chatty unmapped sender isn't re-queried on every message (which also
// reduces engine rate-limit pressure). Best-effort feature, so staleness is acceptable.
private readonly lidPhoneCache = new Map<string, string | null>();
private static readonly LID_PHONE_CACHE_MAX = 5000;
// Transient, human-readable reason for the most recent terminal engine failure,
// keyed by session id. Surfaced on read so the dashboard can explain a FAILED
// status; cleared when the session re-initializes or becomes ready.
private sessionErrors: Map<string, string> = new Map();
// Reconnection state per session
private reconnectStates: Map<string, ReconnectState> = new Map();
// Consecutive liveness-probe failures per session (watchdog). Reset on a successful probe, on a
// non-READY tick, on onReady, and when the threshold routes the session to the disconnect path.
private readonly livenessFailures = new Map<string, number>();
// The single watchdog interval handle. Unref'd so it never keeps the process alive on its own;
// cleared (and nulled) in onModuleDestroy, so teardown stays idempotent.
private watchdogTimer: NodeJS.Timeout | null = null;
// Last session.status value broadcast per session. Some engines signal one transition via BOTH
// onStateChanged and a dedicated callback (onQRCode/onDisconnected), so this guards both the WS emit
// and the webhook POST against firing the same status twice. Cleared on delete().
private readonly lastDispatchedStatus = new Map<string, SessionStatus>();
// Sessions currently being stopped/deleted. An in-flight executeReconnect awaits
// engine init, so a stop/delete during that window could re-register an engine AFTER
// teardown (orphan). stop()/delete() add the id here; executeReconnect checks it after its
// awaits and destroys any engine it just created; start() clears it (intentional restart).
private stoppingSessions: Set<string> = new Set();
// Sessions whose engine is mid-initialization (a start() is in flight). Reserved synchronously
// in start() so a near-simultaneous second start() can't pass the engines.has() check during the
// awaited hook and orphan an engine the lifecycle could never destroy.
private initializingSessions: Set<string> = new Set();
// Serializes stored-message mutations per `${sessionId}:${waMessageId}`. Reactions perform a
// read-modify-write and rapid edits must remain latest-write-wins; sharing one chain also preserves
// order when different mutation kinds for the same message arrive together.
private messageMutationChains: Map<string, Promise<void>> = new Map();
constructor(
@InjectRepository(Session, 'data')
private readonly sessionRepository: Repository<Session>,
@InjectRepository(Message, 'data')
private readonly messageRepository: Repository<Message>,
@InjectDataSource('data')
private readonly dataSource: DataSource,
private readonly engineFactory: EngineFactory,
private readonly eventsGateway: EventsGateway,
private readonly webhookService: WebhookService,
private readonly hookManager: HookManager,
@Optional()
private readonly configService?: ConfigService,
// Shared lid<->phone table (global). Used to persist an inbound @lid sender's resolved phone so
// an inbound-only migrated contact's `@lid` and `@c.us` rows bridge in the read-path (#583 R3 Ph2).
@Optional()
private readonly lidMappingStore?: LidMappingStoreService,
// Draining flag (set on a termination signal or an admin restart). Used to suppress a mid-shutdown
// reconnect that would launch a fresh Chromium racing onModuleDestroy's teardown. @Optional so the
// service degrades to today's behaviour if it is ever constructed without the (global) LoggerModule.
@Optional()
private readonly shutdownService?: ShutdownService,
) {}
/**
* On backend startup, reset all active session statuses to disconnected
* because the engines are not running yet after restart
*/
async onModuleInit(): Promise<void> {
const activeStatuses = [
SessionStatus.READY,
SessionStatus.INITIALIZING,
SessionStatus.QR_READY,
SessionStatus.AUTHENTICATING,
];
const result = await this.sessionRepository.update(
{ status: In(activeStatuses) },
{ status: SessionStatus.DISCONNECTED },
);
if (result.affected && result.affected > 0) {
this.logger.log(`Reset ${result.affected} session(s) to disconnected on startup`, {
action: 'startup_reset',
affected: result.affected,
});
}
}
async onApplicationBootstrap(): Promise<void> {
// Start the liveness watchdog FIRST: it must run even when auto-start is disabled (sessions can
// be started via the API at any time), so it can't sit behind the auto-start early-return below.
this.startWatchdog();
if (!resolveFeatureFlags(this.configService).autoStartSessions) return;
const sessions = await this.sessionRepository.find({
where: { phone: Not(IsNull()), status: SessionStatus.DISCONNECTED },
});
if (sessions.length === 0) return;
this.logger.log(`Auto-starting ${sessions.length} previously authenticated session(s)`, {
action: 'auto_start',
count: sessions.length,
});
for (let i = 0; i < sessions.length; i++) {
const session = sessions[i];
try {
await this.start(session.id);
this.logger.log(`Auto-started session: ${session.name}`, {
sessionId: session.id,
action: 'auto_start_success',
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Auto-start failed for session: ${session.name}`, errorMessage, {
sessionId: session.id,
action: 'auto_start_failed',
});
}
// Throttle between sequential Chromium launches; no need to wait after the last one.
if (i < sessions.length - 1) {
await this.delay(2000);
}
}
}
async onModuleDestroy(): Promise<void> {
// Stop the watchdog FIRST (before any teardown below can hang): no new probe/disconnect handling
// may start mid-shutdown. Nulling the handle keeps a second onModuleDestroy call safe.
if (this.watchdogTimer) {
clearInterval(this.watchdogTimer);
this.watchdogTimer = null;
}
this.livenessFailures.clear();
// Stop reconnect timers FIRST so nothing reschedules mid-teardown, and so this always runs even
// if an engine.destroy() below hangs or throws.
for (const [, state] of this.reconnectStates) {
if (state.timer) {
clearTimeout(state.timer);
}
}
this.reconnectStates.clear();
// Destroy engines in parallel, each isolated + time-bounded, so one stuck Chromium can neither
// stall the shutdown nor abort teardown of the other sessions.
await Promise.allSettled(
[...this.engines].map(([sessionId, engine]) => this.destroyEngineSafely(sessionId, engine)),
);
this.engines.clear();
}
/** Destroy one engine, isolating + time-bounding failures so shutdown can't be stalled or aborted. */
private async destroyEngineSafely(sessionId: string, engine: IWhatsAppEngine): Promise<void> {
this.logger.log(`Destroying engine for session ${sessionId}`, { sessionId, action: 'shutdown' });
await this.teardownEngineSafely(sessionId, engine, e => e.destroy(), 'destroy');
}
/**
* Run an engine teardown (destroy/disconnect), isolating + time-bounding failures so a stuck
* Chromium/socket can neither hang nor abort the caller. Always resolves β the caller is then free
* to reconcile the engines Map and proceed with DB cleanup regardless of teardown outcome.
*/
private async teardownEngineSafely(
sessionId: string,
engine: IWhatsAppEngine,
teardown: (e: IWhatsAppEngine) => Promise<void>,
label: 'destroy' | 'disconnect' | 'force-destroy',
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
teardown(engine),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`engine.${label}() timed out`)), 10_000);
}),
]);
} catch (err) {
this.logger.error(`Failed to ${label} engine for session ${sessionId}`, String(err), {
sessionId,
action: `engine_${label}_failed`,
});
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Evict a terminally-failed or abandoned engine from the map and SIGKILL its browser process
* (best-effort, time-bounded via teardownEngineSafely). An engine left in the map keeps holding a
* concurrency slot and makes a later start() see the session as "already started"; forceDestroy()
* (not the graceful destroy()) is used because such an engine's browser/CDP connection is typically
* already broken, so a graceful close would only time out before the process is reaped.
*/
private evictAndForceDestroy(id: string, engine: IWhatsAppEngine): void {
this.engines.delete(id);
void this.teardownEngineSafely(id, engine, e => e.forceDestroy(), 'force-destroy');
}
async create(dto: CreateSessionDto): Promise<Session> {
// Check if session with same name exists
const existing = await this.sessionRepository.findOne({
where: { name: dto.name },
});
if (existing) {
throw new ConflictException(`Session with name '${dto.name}' already exists`);
}
const session = this.sessionRepository.create({
name: dto.name,
config: dto.config || {},
proxyUrl: dto.proxyUrl || null,
proxyType: dto.proxyType || null,
status: SessionStatus.CREATED,
});
// The findOne pre-check above is a fast path for the common case, but it's a check-then-insert
// TOCTOU: two concurrent same-name creates both pass it, then one hits the name UNIQUE constraint.
// Translate that violation to a 409 (matching the pre-check) instead of leaking a raw 500.
let saved: Session;
try {
saved = await this.dataSource.transaction(async manager => {
return await manager.save(session);
});
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new ConflictException(`Session with name '${dto.name}' already exists`);
}
throw err;
}
this.logger.log(`Session created: ${saved.name}`, {
sessionId: saved.id,
action: 'create',
});
// Execute hook after session created (outside transaction since hooks do external I/O)
await this.hookManager.execute('session:created', saved, {
sessionId: saved.id,
source: 'SessionService',
});
return saved;
}
async findAll(allowedSessions?: string[] | null, opts: ListOptions = {}): Promise<Session[]> {
// A session-restricted key only lists its own sessions; an unrestricted key (null/empty
// allowlist) lists all β mirroring the ApiKeyGuard allowedSessions model so a scoped key
// cannot enumerate every session through this aggregate route.
const { limit, offset } = resolveListWindow(opts.limit, opts.offset);
const options: FindManyOptions<Session> = { order: { createdAt: 'DESC' }, take: limit, skip: offset };
if (allowedSessions && allowedSessions.length > 0) {
options.where = { id: In(allowedSessions) };
}
const sessions = await this.sessionRepository.find(options);
return sessions.map(session => this.attachLastError(session));
}
async findOne(id: string): Promise<Session> {
const session = await this.sessionRepository.findOne({ where: { id } });
if (!session) {
throw new NotFoundException(`Session with id '${id}' not found`);
}
return this.attachLastError(session);
}
/**
* Populate the transient `lastError` field from the in-memory error map. Only a
* FAILED session carries an error; any other status clears it so a recovered
* session never shows a stale failure reason.
*/
private attachLastError(session: Session): Session {
session.lastError = session.status === SessionStatus.FAILED ? this.sessionErrors.get(session.id) : undefined;
return session;
}
async findByName(name: string): Promise<Session> {
const session = await this.sessionRepository.findOne({ where: { name } });
if (!session) {
throw new NotFoundException(`Session with name '${name}' not found`);
}
return session;
}
async delete(id: string): Promise<void> {
const session = await this.findOne(id);
// Mark as tearing down BEFORE cleanup so an in-flight reconnect can't resurrect it.
this.stoppingSessions.add(id);
// Cancel any reconnection attempts
this.cancelReconnect(id);
try {
// Stop engine if running β time-bounded + isolated so a stuck Chromium can't wedge the delete;
// the Map is reconciled and the DB removal proceeds regardless of the outcome. Use forceDestroy()
// (SIGKILL) rather than a graceful destroy(): the session is being removed permanently, so there is
// no session state worth saving, and a wedged Chromium must be reaped, not left to time out.
const engine = this.engines.get(id);
if (engine) {
await this.teardownEngineSafely(id, engine, e => e.forceDestroy(), 'force-destroy');
if (this.isLiveEngine(id, engine)) this.engines.delete(id);
}
// Execute hook BEFORE delete so plugins can access session data
await this.hookManager.execute(
'session:deleted',
{
id: session.id,
name: session.name,
phone: session.phone,
pushName: session.pushName,
},
{
sessionId: id,
source: 'SessionService',
},
);
// DB removal is NOT best-effort: a genuine failure must surface (500) rather than be swallowed.
// Delete every child row explicitly, in one transaction, children before the parent. messages/
// message_batches carry a plain sessionId with no FK. webhooks/templates/baileys_stored_messages
// DO declare an ON DELETE CASCADE FK, but the default `data` engine (SQLite) runs with
// foreign_keys OFF, so that cascade never fires there β a session delete would otherwise orphan
// them forever (webhooks in particular retain the signing secret + custom headers). Deleting them
// explicitly is engine-agnostic (redundant-but-harmless on Postgres, where the cascade finds
// nothing left) and mirrors the restore path's explicit-clear ordering.
await this.dataSource.transaction(async manager => {
await manager.delete(Message, { sessionId: id });
await manager.delete(MessageBatch, { sessionId: id });
await manager.delete(Webhook, { sessionId: id });
await manager.delete(Template, { sessionId: id });
await manager.delete(BaileysStoredMessage, { sessionId: id });
await manager.remove(session);
});
this.logger.log(`Session deleted: ${session.name}`, {
sessionId: id,
action: 'delete',
});
// Purge the engine's persistent on-disk auth/store dir. It's keyed by session NAME and lives
// independently of the (now torn-down, and on delete often never-loaded) engine instance, so the
// teardown above doesn't touch it. Without this, recreating a session under the same name reloads
// a stale store. Best-effort inside the factory β never fails an otherwise-successful delete.
await this.engineFactory.purgeSessionData(session.name);
} finally {
// Always clear the teardown mark so a later recreate/start with this id isn't suppressed.
this.stoppingSessions.delete(id);
this.lastDispatchedStatus.delete(id);
// Drop the FAILED-reason entry too: it's keyed by a now-deleted UUID that can never be read
// again, so leaving it would grow the map without bound across create/fail/delete churn.
this.sessionErrors.delete(id);
}
}
async start(id: string): Promise<Session> {
const session = await this.findOne(id);
// Reserve the slot SYNCHRONOUSLY (same tick as the has() check) so two near-simultaneous
// start() calls can't both pass the check and orphan an engine β the has() -> engines.set()
// window spans the awaited hook below. The second caller is rejected; the finally clears the
// reservation on success AND failure so a failed start never wedges at "already starting".
if (this.engines.has(id)) {
throw new BadRequestException('Session is already started');
}
if (this.initializingSessions.has(id)) {
throw new BadRequestException('Session is already starting');
}
const maxConcurrentSessions = resolveMaxConcurrentSessions(this.configService);
if (maxConcurrentSessions !== null) {
// Count each session once. A session mid-initialization is transiently in BOTH `engines` (set at
// the start of initializeEngine) and `initializingSessions` (until start()'s finally), so summing
// the two sizes would double-count it and falsely reject new starts at ~half the configured cap.
const activeCount = new Set<string>([...this.engines.keys(), ...this.initializingSessions]).size;
if (activeCount >= maxConcurrentSessions) {
throw new BadRequestException(`Maximum concurrent sessions reached (${maxConcurrentSessions})`);
}
}
this.initializingSessions.add(id);
try {
// A fresh start intentionally (re-)creates the engine β clear any stale stop/delete mark.
this.stoppingSessions.delete(id);
// Cancel any reconnect timer a prior failed executeReconnect left pending, BEFORE the awaited
// session:starting hook and engine init β otherwise the stale timer can fire during that I/O
// and destroy/replace the engine this start() is about to create (or orphan the Chromium
// process). Idempotent: a no-op when no reconnect state exists (the common fresh-start case).
this.cancelReconnect(id);
// Execute hook before starting
await this.hookManager.execute(
'session:starting',
{ sessionId: id },
{
sessionId: id,
source: 'SessionService',
},
);
// Initialize reconnect state from the (untrusted) opaque session.config β coerced + clamped
// so a poisoned value can't drive a NaN/immediate-relaunch storm or an unbounded loop.
const { maxAttempts, baseDelay } = resolveReconnectConfig(session.config);
this.reconnectStates.set(id, { attempts: 0, timer: null, maxAttempts, baseDelay });
try {
await this.initializeEngine(id, session);
} catch (err) {
// engine.initialize() failed AFTER the engine was registered (initializeEngine sets it before
// initializing). Evict + tear it down so the session doesn't wedge at "already started" with a
// leaked Chromium/socket permanently holding a concurrency slot. initializingSessions serializes
// start(), so the engine in the map here is the one this start just created.
//
// Use forceDestroy(), not destroy(): initialize() failing usually means the underlying
// browser/CDP connection is already broken (e.g. a "Target closed" crash mid-injection), so
// a graceful destroy() has nothing live to talk to β it can only time out via
// teardownEngineSafely's race, after which the orphaned Chromium process is never actually
// killed. forceDestroy() SIGKILLs the OS process directly, the same recovery force-kill uses
// for a wedged engine, which is exactly the state this catch block is handling.
const orphan = this.engines.get(id);
if (orphan) {
this.engines.delete(id);
this.sessionErrors.set(id, err instanceof Error ? err.message : String(err));
await this.teardownEngineSafely(id, orphan, e => e.forceDestroy(), 'force-destroy');
await this.updateStatus(id, SessionStatus.FAILED).catch(() => undefined);
}
throw err;
}
// A stop()/delete() may have landed while we awaited engine.initialize() β if so, tear down the
// engine we just registered so the session isn't resurrected to READY (mirrors the post-init
// guard in executeReconnect; initialize()'s callbacks can also fire async after this returns).
// delete() clears its teardown mark before this slow init resolves, so re-check the session row
// exists, not just the mark; the findOne below then surfaces a deleted session as NotFound.
if (await this.isSessionRetired(id)) {
const resurrected = this.engines.get(id);
if (resurrected) {
await this.teardownEngineSafely(id, resurrected, e => e.destroy(), 'destroy');
if (this.isLiveEngine(id, resurrected)) this.engines.delete(id);
}
}
return this.findOne(id);
} finally {
this.initializingSessions.delete(id);
}
}
/**
* True only while `engine` is still the live engine registered for `id`. Each callback below
* captures its own engine instance; once the session is stopped (engine removed from the map) or
* restarted/reconnected (engine replaced), a late callback from the superseded engine must not
* mutate the session that now belongs to a different β or no β engine. `this.engines` is the
* single source of truth for the active engine, so identity comparison closes both the
* post-stop and the stale-generation (stopβstart / reconnect-replace) windows the one-shot
* post-init guard does not cover.
*/
private isLiveEngine(id: string, engine: IWhatsAppEngine): boolean {
return this.engines.get(id) === engine;
}
/**
* Persist pre-connection history into the `messages` table for the chat view, without webhook/hook/ws
* dispatch (it predates the live session). De-duplicated by `waMessageId` so re-syncs never duplicate.
*/
private async persistHistoryMessages(id: string, messages: IncomingMessage[]): Promise<void> {
const storeEphemeralMessages = resolveFeatureFlags(this.configService).storeEphemeralMessages;
const byId = new Map<string, IncomingMessage>();
for (const m of messages) {
// Need an id to de-dup; chatId/from/to are NOT NULL; status/story posts aren't chats.
if (!m.id || m.isStatusBroadcast || !m.chatId || !m.from || !m.to) {
continue;
}
// Mirror the live onMessage guard: skip disappearing messages when the operator opted out, so a
// history backfill can't bypass STORE_EPHEMERAL_MESSAGES=false. No-op when the flag is at its
// default (true); only a message with a positive timer is dropped, never a regular one.
if (!storeEphemeralMessages && (m.ephemeralDuration ?? 0) > 0) {
continue;
}
byId.set(m.id, m);
}
if (byId.size === 0) {
return;
}
// Chunk the dedup query: a batch can be thousands, past SQLite's bound-variable limit for IN (...).
const ids = [...byId.keys()];
const CHUNK = 400;
let inserted = 0;
for (let i = 0; i < ids.length; i += CHUNK) {
const chunkIds = ids.slice(i, i + CHUNK);
const existing = await this.messageRepository.find({
where: { sessionId: id, waMessageId: In(chunkIds) },
select: { waMessageId: true },
});
const seen = new Set(existing.map(r => r.waMessageId));
const rows = chunkIds
.filter(x => !seen.has(x))
.map(x => {
const m = byId.get(x)!;
const metadata: Record<string, unknown> = {};
if (m.media) {
metadata.media = m.media;
} else if (MEDIA_MESSAGE_TYPES.has(m.type)) {
// History sync maps messages media-free (footprint). Without the marker the row renders
// as an empty bubble β the DB copy wins over the engine-history placeholder in the
// dashboard merge β and the by-type stats filter would skip it.
metadata.media = { mimetype: '', omitted: true };
}
if (m.quotedMessage) metadata.quotedMessage = m.quotedMessage;
if (m.call) metadata.call = m.call;
const row = this.messageRepository.create({
sessionId: id,
waMessageId: m.id,
chatId: m.chatId,
from: m.from,
to: m.to,
body: m.body,
type: m.type,
direction: m.fromMe ? MessageDirection.OUTGOING : MessageDirection.INCOMING,
timestamp: m.timestamp,
status: MessageStatus.SENT,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
});
// The chat panel orders by createdAt; stamp the real time so history sorts correctly.
if (m.timestamp) {
row.createdAt = new Date(m.timestamp * 1000);
}
return row;
});
if (rows.length) {
// Insert-or-ignore: a live onMessage insert can land between the `seen` SELECT above and this
// write, colliding on UNIQUE(sessionId, waMessageId). orIgnore skips the collision instead of
// throwing and aborting the whole batch (history is best-effort, persist-never-dispatch).
await this.messageRepository
.createQueryBuilder()
.insert()
.values(rows as unknown as QueryDeepPartialEntity<Message>[])
.orIgnore()
.execute();
inserted += rows.length;
}
}
if (inserted) {
this.logger.log(`Persisted ${inserted} history message(s)`, {
sessionId: id,
inserted,
action: 'history_messages_persisted',
});
}
}
private async initializeEngine(id: string, session: Session): Promise<void> {
this.logger.log(`Initializing engine for session: ${session.name}`, {
sessionId: id,
action: 'engine_init',
proxyEnabled: !!session.proxyUrl,
});
const engine = this.engineFactory.create({
sessionId: session.name,
dbSessionId: id,
proxyUrl: session.proxyUrl || undefined,
proxyType: session.proxyType || undefined,
});
this.engines.set(id, engine);
// Clear any prior failure reason before a fresh start.
this.sessionErrors.delete(id);
// Mark INITIALIZING before engine.initialize(): the engine drives status forward
// (QR_READY -> AUTHENTICATING -> READY) through the callbacks below while it
// initializes, so writing INITIALIZING afterwards would clobber that progress.
await this.updateStatus(id, SessionStatus.INITIALIZING);
const initPromise = engine.initialize({
onQRCode: (qr: string): void => {
if (!this.isLiveEngine(id, engine)) return;
this.logger.log('QR code generated', {
sessionId: id,
action: 'qr_generated',
});
void this.webhookService.dispatch(id, 'session.qr', { sessionId: id, qr });
// Push the QR to subscribed dashboard clients over the WebSocket (the `session.qr` event is
// advertised + consumed there, so clients can render it live instead of polling GET /qr).
this.eventsGateway.emitQRCode(id, qr);
// Execute hook for QR event
void this.hookManager.execute(
'session:qr',
{ sessionId: id },
{
sessionId: id,
source: 'Engine',
},
);
void this.updateStatus(id, SessionStatus.QR_READY);
},
onReady: (phone: string, pushName: string): void => {
if (!this.isLiveEngine(id, engine)) return;
this.logger.log(`Session ready: ${phone}`, {
sessionId: id,
phone,
pushName,
action: 'ready',
});
void this.webhookService.dispatch(id, 'session.authenticated', { sessionId: id, phone, pushName });
this.eventsGateway.emitSessionAuthenticated(id, { phone, pushName });
// Execute hook for ready event
void this.hookManager.execute(
'session:ready',
{ phone, pushName },
{
sessionId: id,
source: 'Engine',
},
);
// Reset reconnect attempts and clear any stale failure reason on success
const reconnectState = this.reconnectStates.get(id);
if (reconnectState) {
reconnectState.attempts = 0;
}
// A fresh READY stretch starts the watchdog's failure budget clean too.
this.livenessFailures.delete(id);
this.sessionErrors.delete(id);
void this.sessionRepository
.update(id, {
status: SessionStatus.READY,
phone,
pushName,
connectedAt: new Date(),
lastActiveAt: new Date(),
})
.catch(err =>
this.logger.warn('Failed to persist session ready state', {
sessionId: id,
error: err instanceof Error ? err.message : String(err),
}),
);
},
onMessage: (message): void => {
if (!this.isLiveEngine(id, engine)) return;
// Status/Story posts arrive via the inbound path for some engines; don't persist or webhook them.
// Mirrors the isStatusBroadcast guard in onMessageCreate below.
if (message.isStatusBroadcast) {
return;
}
// Ephemeral/disappearing messages: skip persist + dispatch when the operator opted out.
// A message is ephemeral when its chat has a disappearing-messages timer (ephemeralDuration > 0).
if (
!resolveFeatureFlags(this.configService).storeEphemeralMessages &&
message.ephemeralDuration &&
message.ephemeralDuration > 0
) {
this.logger.debug('Skipping ephemeral message', {
sessionId: id,
messageId: message.id,
chatId: message.chatId,
ephemeralDuration: message.ephemeralDuration,
});
return;
}
this.logger.debug(`Message received from ${message.from}`, {
sessionId: id,
messageId: message.id,
from: message.from,
action: 'message_received',
});
// Update last active timestamp
void this.sessionRepository.update(id, { lastActiveAt: new Date() }).catch(() => undefined);
// Convert IncomingMessage to plain object for dispatch
const messageData = { ...message };
// Execute hook for message received - plugins can modify or stop processing
void this.hookManager
.execute('message:received', messageData, {
sessionId: id,
source: 'Engine',
})
.then(async ({ data: finalMessage }) => {
// `continue: false` is deliberately NOT read here. It means "stop the handler chain", which
// HookManager has already done β the plugins after the one that returned it never ran. It
// does not mean "this message never happened".
//
// Honouring it here used to skip everything below: the message was never written to the
// messages table, never dispatched to webhooks, and never emitted over the websocket. An
// auto-reply plugin returning `false` for its ordinary purpose β keeping other bots from
// answering the same message β silently erased the customer's message from the operator's
// own history, leaving a thread of bot replies answering nothing. Nothing in the hook
// contract (`HookResult.continue`, docs/19) or the webhook contract (`message.received`
// fires when "an inbound message arrives", docs/06) hinted at that, and a sandboxed
// marketplace plugin could swallow a session's entire inbound traffic with no audit trail.
//
// The message has already arrived at WhatsApp. A plugin can stop other plugins from acting
// on it; it cannot make the gateway forget it. Pre-action hooks are where a veto belongs β
// `message:sending` blocks a send that has not happened yet (core/hooks/sending-gate.ts).
// Persist the incoming message so the dashboard chats view can render history.
const incoming: IncomingMessage = finalMessage;
// Inline @lid -> phone resolution (#263), opt-in via RESOLVE_LID_TO_PHONE. Best-effort:
// attaches senderPhone (digits or null) before persist/dispatch so webhook/ws consumers
// get it in a single pass. Only for privacy-id senders, so no lookup for normal numbers.
if (resolveFeatureFlags(this.configService).resolveLidToPhone && incoming.isLidSender && !incoming.fromMe) {
incoming.senderPhone = await this.resolveSenderPhone(id, incoming.author ?? incoming.from);
}
const metadata: Record<string, unknown> = {};
if (incoming.media) {
metadata.media = incoming.media;
}
if (incoming.quotedMessage) {
metadata.quotedMessage = incoming.quotedMessage;
}
if (incoming.call) {
metadata.call = incoming.call;
}
const chatName = incoming.contact?.pushName ?? incoming.contact?.name ?? undefined;
const dbMessage = this.messageRepository.create({
sessionId: id,
// Mirror saveOutgoingMessage's chokepoint: an engine that received a message but could
// not read its id back reports the empty sentinel, and NULL is what the non-partial
// (sessionId, waMessageId) unique index exempts β `''` would collide the second such
// message and lose the row.
waMessageId: incoming.id || undefined,
chatId: incoming.chatId,
chatName,
from: incoming.from,
to: incoming.to,
body: incoming.body,
type: incoming.type,
direction: incoming.fromMe ? MessageDirection.OUTGOING : MessageDirection.INCOMING,
timestamp: incoming.timestamp,
status: MessageStatus.SENT,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
});
// The hook chain above is async; a delete()/teardown can retire this engine while it
// awaits. Re-check liveness so a late continuation can't persist an orphan messages row
// (the row has no FK, so a session-delete cleanup would never reap it) or dispatch for a
// session that no longer exists. Mirrors the synchronous isLiveEngine gate at entry.
if (!this.isLiveEngine(id, engine)) return;
// De-duplicate at the source: the engine can re-fire `message` for one inbound message
// (#464). UNIQUE(sessionId, waMessageId) makes the insert the atomic dedup oracle β a
// near-simultaneous re-fire loses the race and is skipped here, so persist + webhook + WS
// happen exactly once. Fail-open: a non-conflict DB error still dispatches, so a real
// message is never dropped by a transient DB failure.
let isNewMessage = true;
let persisted = false;
try {
// `insert()` (not `save()`) is load-bearing: the UNIQUE(sessionId, waMessageId) constraint
// makes a duplicate insert throw, which is the atomic dedup oracle for #464 re-fires.
// Unlike `save()`, `insert()` does NOT merge DB-generated columns (@PrimaryGeneratedColumn,
// @CreateDateColumn) back onto the entity instance β so merge them explicitly here, before
// the `message:persisted` emit. `identifiers[0]` always carries the PK on both SQLite and
// Postgres; `generatedMaps[0]` adds createdAt where the driver returns it (Postgres yes;
// SQLite historically does not β acceptable; the PK is the load-bearing field for plugins).
const result = await this.messageRepository.insert(
dbMessage as unknown as QueryDeepPartialEntity<Message>,
);
Object.assign(dbMessage, result.identifiers[0] ?? {}, result.generatedMaps?.[0] ?? {});
persisted = true;
} catch (err) {
if (isUniqueConstraintError(err)) {
isNewMessage = false;
} else {
this.logger.error(`Failed to save incoming message ${incoming.id} to database`, String(err));
}
}
if (!isNewMessage) {
return; // duplicate re-fire β the original already persisted and dispatched
}
// Fire-and-forget: a plugin handler must never break the receive path. Both engine adapters
// (wwjs `message` and Baileys `upsert`) converge on this persist, so one emit covers inbound.
// The built-in FTS search provider is DB-synced and does NOT consume this; it exists for
// plugin providers (Spec 2) + general use.
// Gate ONLY the hook on `persisted`: on a non-unique insert error (transient SQLITE_BUSY /
// lock-timeout / connection drop) the row was never stored and `dbMessage.id` is undefined,
// so emitting `message:persisted` would hand plugins an id-less payload for a row that isn't
// in the DB. The webhook/WS dispatch below stays fail-open β a real inbound message must
// never be dropped on a transient DB failure; only the hook requires a durable row.
if (persisted) {
void this.hookManager
.execute(
'message:persisted',
{ sessionId: id, message: dbMessage },
{ sessionId: id, source: 'SessionService' },
)
.catch(() => undefined);
}
// Dispatch to webhooks with potentially modified message
void this.webhookService.dispatch(id, 'message.received', finalMessage);
// Emit real-time event to WebSocket clients
this.eventsGateway.emitMessage(id, finalMessage);
})
.catch(err => this.logger.error(`onMessage handler failed for ${id}`, String(err)));
},
onHistoryMessages: (messages): void => {
if (!this.isLiveEngine(id, engine)) return;
// Persist for the chat view only; no dispatch (these predate the live session).
void this.persistHistoryMessages(id, messages).catch(err =>
this.logger.error(`Failed to persist history messages for ${id}`, String(err)),
);
},
onMessageCreate: (message): void => {
if (!this.isLiveEngine(id, engine)) return;
// `message_create` fires for every message the account creates, including sends composed on a
// linked phone β which the `message`/`onMessage` event never delivers. Incoming messages are
// already handled by `onMessage`, so only outgoing (`fromMe`) ones produce `message.sent` here.
if (!message.fromMe) {
return;
}
// Status/Story posts are account-created but not real conversations; don't emit `message.sent`
// for them. The adapter flags these (the engine-specific pseudo-JID stays out of this layer).
if (message.isStatusBroadcast) {
return;
}
this.logger.debug(`Message sent to ${message.to}`, {
sessionId: id,
messageId: message.id,
to: message.to,
action: 'message_sent',
});
// Update last active timestamp
void this.sessionRepository.update(id, { lastActiveAt: new Date() }).catch(() => undefined);
const messageData = { ...message };
// Execute hook for message sent - plugins can modify or stop processing
void this.hookManager
.execute('message:sent', messageData, {
sessionId: id,
source: 'Engine',
})
.then(async ({ data: finalMessage }) => {
// `continue: false` is not read here, for the same reason as the message:received path
// above: the send has already happened, so a plugin can stop the handler chain but cannot
// un-send it. Skipping the persist below dropped the operator's own outgoing message from
// history and from `message.sent` webhooks.
// Persist the outgoing message so local history reflects sends composed on a linked phone
// (message_create is the ONLY event those produce). It also fires for API-originated sends,
// which the REST send path persists itself β the UNIQUE(sessionId, waMessageId) index is
// the atomic dedup oracle between the two writers: the loser skips its insert, and
// persistSentState additionally drops its redundant PENDING row when the echo won. The
// webhook/WS dispatch below is identical whether the insert won, lost, or failed β the
// message.sent contract is unchanged.
const outgoing: IncomingMessage = finalMessage;
const metadata: Record<string, unknown> = {};
if (outgoing.media) {
metadata.media = outgoing.media;
} else if (MEDIA_MESSAGE_TYPES.has(outgoing.type)) {
// The wwjs own-send echo carries no media field at all (Baileys emits an omitted
// marker); synthesize it so the dashboard renders the π placeholder instead of an
// empty bubble, and the row stays countable in the by-type stats.
metadata.media = { mimetype: '', omitted: true };
}
if (outgoing.quotedMessage) {
metadata.quotedMessage = outgoing.quotedMessage;
}
if (outgoing.call) {
metadata.call = outgoing.call;
}
// The ephemeral opt-out gates STORAGE only (mirrors onMessage); the live dispatch below
// is today's contract and stays.
const mayPersist =
resolveFeatureFlags(this.configService).storeEphemeralMessages ||
!(outgoing.ephemeralDuration && outgoing.ephemeralDuration > 0);
if (mayPersist) {
const dbMessage = this.messageRepository.create({
sessionId: id,
// Mirror onMessage's chokepoint: an unreadable id is the empty sentinel, stored as
// NULL β `''` would collide on the second such message.
waMessageId: outgoing.id || undefined,
chatId: outgoing.chatId,
from: outgoing.from,
to: outgoing.to,
body: outgoing.body,
type: outgoing.type,
direction: MessageDirection.OUTGOING,
timestamp: outgoing.timestamp,
status: MessageStatus.SENT,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
});
// The hook chain above is async; a delete()/teardown can retire this engine while it
// awaits. Re-check liveness so a late continuation can't persist an orphan row
// (mirrors onMessage).
if (!this.isLiveEngine(id, engine)) return;
let persisted = false;
try {
const result = await this.messageRepository.insert(
dbMessage as unknown as QueryDeepPartialEntity<Message>,
);
Object.assign(dbMessage, result.identifiers[0] ?? {}, result.generatedMaps?.[0] ?? {});
persisted = true;
} catch (err) {
// Unique violation = the REST send path already persisted this API-originated send β
// the dedup oracle working as intended, not an error. Anything else is a real DB
// failure; fail open so a real send is never dropped on a transient DB fault.
if (!isUniqueConstraintError(err)) {
this.logger.error(`Failed to save outgoing message ${outgoing.id} to database`, String(err));
}
}
if (persisted) {
// Fire-and-forget, mirroring onMessage: plugin providers (search etc.) see phone-
// composed sends exactly like API sends.
void this.hookManager
.execute(
'message:persisted',
{ sessionId: id, message: dbMessage },
{ sessionId: id, source: 'SessionService' },
)
.catch(() => undefined);
}
}
void this.webhookService.dispatch(id, 'message.sent', finalMessage);
// Emit real-time event to WebSocket clients (as message.sent, not message.received)
this.eventsGateway.emitMessageSent(id, finalMessage);
})
.catch(err => this.logger.error(`onMessageCreate handler failed for ${id}`, String(err)));
},
onMessageAck: (messageId, status: DeliveryStatus): void => {
if (!this.isLiveEngine(id, engine)) return;
this.logger.debug(`Message ack: ${messageId} -> ${status}`, {
sessionId: id,
messageId,
status,
action: 'message_ack',
});
// Reflect real delivery state on the stored message (#220): delivered/read/failed advance the
// stored status; pending/sent carry no upgrade (it's already SENT β visibly "not delivered").
// The UPDATE is guarded to the allowed prior statuses so delivery state only ADVANCES: an
// out-of-order/late ack cannot downgrade a higher status, which also makes these
// fire-and-forget writes race-safe at the DB level.
const messageStatus = deliveryStatusToMessageStatus(status);
if (messageStatus) {
// Scope by sessionId: waMessageId is unique per account/chat, not global β an ack on one
// session must never advance a same-id row in another session. The In() guard makes the
// UPDATE forward-only (a late/out-of-order ack can't downgrade) and idempotent on retry.
const advanceAck = (): Promise<number> =>
this.messageRepository
.update(
{ sessionId: id, waMessageId: messageId, status: In(ackStatusTransitionFrom(messageStatus)) },
{ status: messageStatus },
)
.then(result => result.affected ?? 0);
const logNoop = (): void =>
this.logger.debug(`Message ack ${messageId}: no status row advanced to ${messageStatus} (${status})`, {
sessionId: id,
messageId,
status,
action: 'message_ack_noop',
});
const onAckError = (err: unknown): void =>
this.logger.error(`Failed to advance ack for ${messageId}`, String(err));
void advanceAck()
.then(affected => {
if (affected > 0) return;
// affected:0 β most likely the send's 2nd save (which writes waMessageId) hasn't committed
// yet, so the row isn't matchable. Each ack is one-shot (WhatsApp won't necessarily resend),
// so retry ONCE after a short delay to close that race rather than leave it stuck at SENT.
const timer = setTimeout(() => {
void advanceAck()
.then(retried => {
if (retried === 0) logNoop();
})
.catch(onAckError);
}, ACK_RECONCILE_DELAY_MS);
timer.unref?.();
})
.catch(onAckError);
}
// One ack payload, emitted identically over the socket and the webhook so a client coded
// against either channel sees the same shape. `id` mirrors the field every other message.*
// event carries (and the idempotency-key resolver reads). `ack` is a deprecated legacy field
// kept for backward compatibility β new consumers should read the neutral `status`.
const ackPayload = { id: messageId, messageId, status, ack: deliveryStatusToAck(status) };
// Push the live delivery/read tick to the dashboard over the websocket.
this.eventsGateway.emitMessageAck(id, ackPayload);
// Dispatch the delivery/read receipt to webhooks (#155). Outgoing `message.sent` is handled
// solely by `onMessageCreate`, so the ack path deliberately does NOT emit `message.sent`.
void this.webhookService.dispatch(id, 'message.ack', ackPayload);
// Surface delivery failures actively so consumers don't have to poll for them (#220). Use a
// distinct object (not the shared ackPayload) so this separate event can't be perturbed by an
// in-place payload mutation in the concurrent message.ack dispatch's webhook:before hook.
if (status === 'failed') {
void this.webhookService.dispatch(id, 'message.failed', { ...ackPayload });
}
// Notify plugins of the delivery/read receipt. The `message:ack` hook event was declared in
// the HookEvent union but never emitted, so any plugin registered for it silently never fired.
// Fire-and-forget: an ack is a notification with nothing downstream to cancel, so the hook's
// `continue` flag is moot. Delivery failures surface here as status `failed` β `message:failed`
// stays reserved for send-time send failures, which carry a distinct `{ error, input }` payload.
void this.hookManager.execute(
'message:ack',
{ messageId, status, ack: deliveryStatusToAck(status) },
{ sessionId: id, source: 'Engine' },
);
},
onMessageRevoked: (message): void => {
if (!this.isLiveEngine(id, engine)) return;
this.logger.debug(`Message revoked: ${message.id}`, {
sessionId: id,
messageId: message.id,
action: 'message_revoked',
});
// Flag the stored message as revoked (best-effort; the message may not be in the
// DB). The dashboard renders the localized "message deleted" text, so no display
// string is persisted here.
//
// Match on `revokedId` (the ORIGINAL deleted message's id) when present: on wwebjs
// `message.id` is the revocation notification, which never matches a stored row.
// `revokedId` falls back to `id` (Baileys, where the two are the same).
const revokedWaMessageId = message.revokedId ?? message.id;
void this.messageRepository
.update({ sessionId: id, waMessageId: revokedWaMessageId }, { body: '', type: 'revoked' })
.catch(err => {
this.logger.error(`Failed to update revoked message: ${revokedWaMessageId}`, String(err));
});
// Notify consumers regardless of whether the row existed: webhook (message.revoked
// is a declared event) + the real-time dashboard stream.
const revokedPayload = message as unknown as Record<string, unknown>;
void this.webhookService.dispatch(id, 'message.revoked', revokedPayload);
this.eventsGateway.emitMessageRevoked(id, revokedPayload);
},
onMessageReaction: (event): void => {
if (!this.isLiveEngine(id, engine)) return;
if (!event.messageId) {
this.logger.warn('Ignoring message reaction without a target message id', {
sessionId: id,
action: 'message_reaction_ignored',
});
return;
}
this.logger.debug(`Message reaction received: ${event.messageId} -> ${event.reaction}`, {
sessionId: id,
messageId: event.messageId,
action: 'message_reaction_received',
});
this.enqueueMessageMutation(id, event.messageId, () => this.applyReaction(id, event));
},
onMessageEdited: (message): void => {
if (!this.isLiveEngine(id, engine)) return;
if (!message.messageId) {
this.logger.warn('Ignoring message edit without a target message id', {
sessionId: id,
action: 'message_edit_ignored',
});
return;
}
this.logger.debug(`Message edited: ${message.messageId}`, {
sessionId: id,
messageId: message.messageId,
action: 'message_edited',
});
this.enqueueMessageMutation(id, message.messageId, () => this.applyMessageEdit(id, message));
},
onGroupEvent: (event): void => {
if (!this.isLiveEngine(id, engine)) return;
this.logger.debug(`Group event: ${event.kind} in ${event.groupId}`, {
sessionId: id,
groupId: event.groupId,
kind: event.kind,
action: 'group_event',
});
this.dispatchGroupEvent(id, event);
},
onCall: (event: IncomingCallEvent): void => {
if (!this.isLiveEngine(id, engine)) return;
this.logger.log(`Incoming call from ${event.from}`, {
sessionId: id,
callId: event.callId,
isVideo: event.isVideo,
isGroup: event.isGroup,
action: 'call_received',
});
const payload: Record<string, unknown> = { ...event };
this.eventsGateway.emitCallReceived(id, payload);
void this.webhookService.dispatch(id, 'call.received', payload);
// Opt-in auto-reject runs AFTER the dispatch so a reject failure can never eat the event.
void this.maybeAutoRejectCall(id, engine, event.callId);
},
onDisconnected: (reason: string): void => {
if (!this.isLiveEngine(id, engine)) return;
// Shared with the liveness watchdog (see handleEngineDisconnected). The handler re-reads the
// session row itself β this closure's `session` snapshot can be stale by the time a
// disconnect lands β so the reconnect always re-initializes from the current row.
void this.handleEngineDisconnected(id, reason);
},
onStateChanged: (engineState: EngineStatus): void => {
if (!this.isLiveEngine(id, engine)) return;
const statusMap: Record<EngineStatus, SessionStatus> = {
[EngineStatus.DISCONNECTED]: SessionStatus.DISCONNECTED,
[EngineStatus.INITIALIZING]: SessionStatus.INITIALIZING,
[EngineStatus.QR_READY]: SessionStatus.QR_READY,
[EngineStatus.AUTHENTICATING]: SessionStatus.AUTHENTICATING,
[EngineStatus.READY]: SessionStatus.READY,
[EngineStatus.FAILED]: SessionStatus.FAILED,
};
const newStatus = statusMap[engineState];
if (newStatus) {
void this.updateStatus(id, newStatus);
}
},
onError: (reason: string): void => {
if (!this.isLiveEngine(id, engine)) return;
this.logger.error(`Session engine failed: ${reason}`, undefined, {
sessionId: id,
reason,
action: 'engine_error',
});
// Remember the reason so findOne/findAll can surface it to the dashboard,
// then persist the FAILED status. This is terminal β no reconnect is
// scheduled (unlike onDisconnected), since re-scanning is required.
this.sessionErrors.set(id, reason);
// A prior onDisconnected may have scheduled a reconnect. This failure is terminal
// (re-scan required), so cancel it β otherwise the pending timer would resurrect a
// session the operator must manually restart.
this.cancelReconnect(id);
void this.hookManager.execute(
'session:error',
{ reason },
{
sessionId: id,
source: 'Engine',
},
);
void this.updateStatus(id, SessionStatus.FAILED);
// onError is terminal (no reconnect is scheduled β re-scan is required). Evict the dead engine
// and SIGKILL its process: leaving it in the map would hold a concurrency slot indefinitely and
// make the next start() reject the session as "already started" instead of re-initializing it.
this.evictAndForceDestroy(id, engine);
},
});
// engine.initialize() launches Chromium and navigates to WhatsApp Web with no internal timeout:
// whatsapp-web.js calls page.goto(..., { timeout: 0 }) and its web-version-cache fetch has none
// either. If the browser stalls under container memory pressure (observed in prod: a session
// wedged in INITIALIZING with no error logged and GET /sessions/:id/qr 400ing forever), this
// await never settles. Race it against a deadline so a wedged init fails fast instead.
//
// ONLY the timeout case mutates state here. A REAL rejection (e.g. Chromium can't launch) must
// propagate untouched so start()'s catch keeps owning FAILED+reason (the diagnosability #600/#631
// added) β pre-deleting the engine and writing DISCONNECTED here would make start()'s
// `engines.get(id)` return undefined, skip its FAILED write, and hide the failure reason.
// The deadline MUST exceed the auth wait whatsapp-web.js runs INSIDE engine.initialize()
// (authTimeoutMs β the inject() poll for WA Web's JS to bootstrap, raisable via
// WWEBJS_AUTH_TIMEOUT_MS for slow first boots, e.g. WSL2/low-resource containers). A shorter
// outer deadline would SIGKILL a legitimate slow init mid-auth. Floor 60s for the hang case;
// otherwise give the configured auth window + 30s for launch/navigation/post-inject overhead.
const engineInitTimeoutMs = Math.max(60_000, (resolveAuthTimeoutMs() ?? 30_000) + 30_000);
// Promise.race can't cancel the losing promise, so swallow a late rejection from initPromise.
initPromise.catch(() => undefined);
let initTimer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
initPromise,
new Promise<never>((_, reject) => {
initTimer = setTimeout(() => reject(new EngineInitTimeoutError(engineInitTimeoutMs)), engineInitTimeoutMs);
}),
]);
} catch (err) {
if (err instanceof EngineInitTimeoutError) {
this.logger.error(`Engine initialization timed out for session ${session.name}`, undefined, {
sessionId: id,
action: 'engine_init_timeout',
});
this.sessionErrors.set(id, err.message);
// Evict from the map BEFORE tearing down. forceDestroy() β beginClientTeardown β setStatus
// fires onStateChanged SYNCHRONOUSLY while the engine is still live, so isLiveEngine would
// pass and the callback would run a redundant DISCONNECTED write against this path; removing
// the engine first makes isLiveEngine return false. Unlike delete()/stop()/forceKill(), this
// path has no stoppingSessions + cancelReconnect wrap to fall back on. Matches the canonical
// delete-before-teardown at evictAndForceDestroy() and start()'s catch.
//
// Do NOT port this reorder to delete()/stop()/forceKill(): there, engines.has(id) staying
// TRUE for the duration of the teardown await is the sole deterministic block on a concurrent
// start() (start() clears stoppingSessions rather than rejecting on it), so delete-first would
// open a start()-during-teardown orphan-engine window. Verified in the teardown-ordering audit.
this.engines.delete(id);
// Force-kill whatever got launched so a retry doesn't collide with an orphaned browser.
// teardownEngineSafely is itself time-bound, so this can't wedge a second time.
await this.teardownEngineSafely(id, engine, e => e.forceDestroy(), 'force-destroy');
await this.updateStatus(id, SessionStatus.DISCONNECTED);
// Map to a diagnostic 504 like the auth-timeout branch below, so a wedged init doesn't escape as a
// bare 500 (#733 follow-up). The browser stalled mid-startup β usually a container memory/resource
// limit or a wedged Chromium, not a network/proxy issue (that's the auth-timeout's signature).
throw new HttpException(
`Engine initialization timed out after ${err.timeoutMs}ms β the browser process did not complete ` +
'startup in time (often a container memory/resource limit or a stalled Chromium, not a network ' +
'issue). Retry the session; for chronically slow first boots, raise WWEBJS_AUTH_TIMEOUT_MS.',
HttpStatus.GATEWAY_TIMEOUT,
);
} else if (isAuthTimeoutRejection(err)) {
// The engine's INTERNAL auth-timeout: whatsapp-web.js throws the primitive string 'auth timeout'
// (see ENGINE_AUTH_TIMEOUT) when its inject poll exhausts authTimeoutMs (default 30s) β the common
// pre-QR failure when the browser launched but couldn't reach WhatsApp, e.g. a dead/unreachable
// session proxy (#733). onError already evicted the engine + wrote FAILED before this catch ran, so
// only the HTTP mapping remains: surface a diagnostic 504 instead of letting the bare string escape
// to NestJS's default handler as a meaningless 500.
throw new HttpException(ENGINE_AUTH_TIMEOUT_MESSAGE, HttpStatus.GATEWAY_TIMEOUT);
}
throw err;
} finally {
if (initTimer) clearTimeout(initTimer);
}
}
/**
* Apply one reaction event to the stored message's reactions map (read-modify-write of the JSON
* column). Invoked through the per-message serialization chain in onMessageReaction, so concurrent
* reactions on the same message run sequentially and don't clobber each other.
*/
private async applyReaction(id: string, event: ReactionEvent): Promise<void> {
try {
// Guard the lookup key before it reaches TypeORM: `findOne` DROPS an undefined condition from
// the where-clause rather than matching nothing, so an engine that couldn't resolve the reacted
// message's id would silently match an arbitrary row and clobber/emit its reactions. `!msg` is
// no protection against that β the row it finds is real, just the wrong one.
if (!event.messageId) return;
const msg = await this.messageRepository.findOne({ where: { sessionId: id, waMessageId: event.messageId } });
if (!msg) return;
const metadata = msg.metadata || {};
const reactions = (metadata.reactions as Record<string, string>) || {};
if (!event.reaction) {
delete reactions[event.senderId];
} else {
reactions[event.senderId] = event.reaction;
}
metadata.reactions = reactions;
// Scoped update of ONLY the metadata column. A full-row save(msg) would re-persist the `status`
// read at findOne time, clobbering a concurrent ack UPDATE (SENTβDELIVERED/READ) that committed in
// the window between this findOne and the write β the mutation chain serializes reaction-vs-reaction
// but NOT reaction-vs-ack, so scoping the write to metadata is what keeps delivery state monotonic
// (#220). Other metadata fields are carried through untouched (they were read into `metadata`).
await this.messageRepository.update({ sessionId: id, waMessageId: event.messageId }, {
metadata,
} as QueryDeepPartialEntity<Message>);
this.eventsGateway.emitMessageReaction(id, { ...event, reactions });
// Webhook parity with the WebSocket broadcast: same payload (event + post-apply snapshot), so a
// webhook-only consumer observes reactions too. Idempotency for this event is salted per dispatch.
void this.webhookService.dispatch(id, 'message.reaction', { ...event, reactions });
} catch (err) {
this.logger.error(`Failed to update message reaction: ${event.messageId}`, String(err));
}
}
/** Queue a message-scoped mutation. A failed operation is isolated so later events still run. */
private enqueueMessageMutation(id: string, messageId: string, work: () => Promise<void>): void {
const key = `${id}:${messageId}`;
const prior = this.messageMutationChains.get(key) ?? Promise.resolve();
const next = prior
.catch(() => undefined)
.then(work)
.catch(err => {
// Both current mutation implementations contain their own contextual error handling. Keep a
// final guard here so a future implementation cannot leak a rejected fire-and-forget promise
// or permanently block the message's later mutations.
this.logger.error(`Unexpected failure applying message mutation: ${messageId}`, String(err));
});
this.messageMutationChains.set(key, next);
void next.finally(() => {
if (this.messageMutationChains.get(key) === next) {
this.messageMutationChains.delete(key);
}
});
}
/** Persist an edit before notifying consumers, while still surfacing the occurrence if storage fails. */
private async applyMessageEdit(id: string, message: EditedMessage): Promise<void> {
try {
await this.messageRepository.update({ sessionId: id, waMessageId: message.messageId }, { body: message.body });
} catch (err) {
this.logger.error(`Failed to update edited message: ${message.messageId}`, String(err));
}
const editedPayload = message as unknown as Record<string, unknown>;
this.eventsGateway.emitMessageEdited(id, editedPayload);
void this.webhookService.dispatch(id, 'message.edited', editedPayload);
}
/**
* Reflect an OUTBOUND edit (REST MessageService.editMessage) in the stored row, routed through the
* same per-message mutation queue as the inbound edit/reaction paths so the two writers cannot
* interleave (latest-write-wins holds across both directions). Same best-effort semantics as
* applyMessageEdit: a missing row or a failed write must not fail the request β the engine edit
* already succeeded. Resolves once the queued write has run.
*/
async recordOutboundMessageEdit(sessionId: string, messageId: string, body: string): Promise<void> {
await new Promise<void>(resolve => {
this.enqueueMessageMutation(sessionId, messageId, async () => {
try {
await this.messageRepository.update({ sessionId, waMessageId: messageId }, { body });
} catch (err) {
this.logger.warn(`Failed to update stored body of edited message ${messageId}`, { error: String(err) });
} finally {
resolve();
}
});
});
}
/**
* Fan a neutral engine GroupEvent out to consumers: the WebSocket room and the webhook stream.
* The `kind` selects the event name (`group.join` / `group.leave` / `group.update`); the payload
* is the same plain camelCase shape on both channels, with `kind` itself carried by the name.
* There is no persistence here β group membership/metadata lives in the engine, not the message
* store β so unlike message edits there is nothing to apply before notifying.
*/
private dispatchGroupEvent(id: string, event: GroupEvent): void {
const payload: Record<string, unknown> = {
groupId: event.groupId,
participantIds: event.participantIds,
timestamp: event.timestamp,
};
// Optional fields are added only when present so consumers never see explicit `undefined`s.
if (event.actorId !== undefined) {
payload.actorId = event.actorId;
}
if (event.changes !== undefined) {
payload.changes = event.changes;
}
switch (event.kind) {
case 'join':
this.eventsGateway.emitGroupJoin(id, payload);
void this.webhookService.dispatch(id, 'group.join', payload);
break;
case 'leave':
this.eventsGateway.emitGroupLeave(id, payload);
void this.webhookService.dispatch(id, 'group.leave', payload);
break;
case 'update':
this.eventsGateway.emitGroupUpdate(id, payload);
void this.webhookService.dispatch(id, 'group.update', payload);
break;
}
}
/**
* Reject a ringing call when the session opted in via `config.autoRejectCalls`. The session row
* is re-read here rather than trusting initializeEngine's closure snapshot β a call can arrive
* long after start, and the row is the only always-current source (mirrors
* handleEngineDisconnected). `config` is an untyped JSON column: only a strict boolean `true`
* opts in β truthy strings/numbers are ignored (the coercion discipline of
* resolveReconnectConfig). Never throws: a reject failure is logged, and the `call.received`
* dispatch already happened before this ran.
*/
private async maybeAutoRejectCall(id: string, engine: IWhatsAppEngine, callId: string): Promise<void> {
let session: Session | null;
try {
session = await this.sessionRepository.findOne({ where: { id } });
} catch (err) {
this.logger.error('Failed to reload the session for call auto-reject', String(err), {
sessionId: id,
action: 'call_auto_reject_error',
});
return;
}
if (session?.config?.autoRejectCalls !== true) {
return;
}
try {
await engine.rejectCall(callId);
this.logger.log('Auto-rejected incoming call', {
sessionId: id,
callId,
action: 'call_auto_rejected',
});
} catch (err) {
this.logger.warn('Failed to auto-reject incoming call', {
sessionId: id,
callId,
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* Shared disconnect handling for BOTH the engine's onDisconnected callback and the liveness
* watchdog: notify consumers (webhook + WS + hook), persist DISCONNECTED, then schedule a
* reconnect. The session row is re-read here rather than trusting a caller-held snapshot β the
* watchdog detects death long after the last state change, and even the callback's closure
* snapshot can be stale β so the reconnect always re-initializes from the current row. Never
* throws: a DB hiccup must not turn a disconnect into an unhandled rejection.
*/
private async handleEngineDisconnected(id: string, reason: string): Promise<void> {
this.logger.warn(`Session disconnected: ${reason}`, {
sessionId: id,
reason,
action: 'disconnected',
});
void this.webhookService.dispatch(id, 'session.disconnected', { sessionId: id, reason });
this.eventsGateway.emitSessionDisconnected(id, { reason });
// Execute hook for disconnected event
void this.hookManager.execute(
'session:disconnected',
{ reason },
{
sessionId: id,
source: 'Engine',
},
);
void this.updateStatus(id, SessionStatus.DISCONNECTED);
let session: Session | null;
try {
session = await this.sessionRepository.findOne({ where: { id } });
} catch (err) {
this.logger.error('Failed to reload the session for reconnect scheduling', String(err), {
sessionId: id,
action: 'reconnect_schedule_error',
});
return;
}
// A session deleted just before this ran has nothing left to reconnect; skip it.
if (!session) return;
// Attempt to reconnect
this.scheduleReconnect(id, session);
}
/** Start the liveness watchdog (idempotent). One unref'd interval probes every registered engine. */
private startWatchdog(): void {
if (this.watchdogTimer) return;
this.watchdogTimer = setInterval(() => {
// allSettled inside the tick keeps a failing session from ever throwing into the timer.
void this.runWatchdogTick();
}, SESSION_WATCHDOG_INTERVAL_MS);
// The watchdog must never keep the process alive on its own.
this.watchdogTimer.unref();
}
/** Probe all live engines in parallel; a slow/failed probe must not delay or abort the others. */
private async runWatchdogTick(): Promise<void> {
// Mid-shutdown the disconnect path would schedule a reconnect racing onModuleDestroy's teardown
// (same guard as scheduleReconnect) β leave the sessions to the drain instead.
if (this.shutdownService?.isShuttingDown()) {
return;
}
await Promise.allSettled([...this.engines].map(([id, engine]) => this.probeSessionLiveness(id, engine)));
}
/**
* Actively probe one engine. Only READY sessions are expected to answer (anything else is owned by
* the QR/reconnect flows); engines without `probeLiveness` keep relying on engine events alone.
* MAX_FAILURES consecutive failures treat the session exactly like an engine-reported disconnect.
*/
private async probeSessionLiveness(id: string, engine: IWhatsAppEngine): Promise<void> {
if (engine.getStatus() !== EngineStatus.READY) {
// Not expected to answer right now β and any accrued failures belong to a previous READY
// stretch, so the next one starts clean.
this.livenessFailures.delete(id);
return;
}
// Feature-detect: an engine whose transport already self-detects death may skip the probe.
if (typeof engine.probeLiveness !== 'function') {
return;
}
// A wedged connection can hang the probe itself, so race it against a timeout; a timeout or a
// probe error both count as "not proven alive".
let alive: boolean;
let probeTimer: ReturnType<typeof setTimeout> | undefined;
try {
alive = await Promise.race([
engine.probeLiveness(),
new Promise<never>((_, reject) => {
probeTimer = setTimeout(
() => reject(new Error('liveness probe timed out')),
SESSION_WATCHDOG_PROBE_TIMEOUT_MS,
);
}),
]);
} catch {
alive = false;
} finally {
if (probeTimer) clearTimeout(probeTimer);
}
// The session may have been stopped/restarted (engine superseded) while the probe was in flight;
// a stale result must not touch it (mirrors the isLiveEngine gate on engine callbacks).
if (!this.isLiveEngine(id, engine)) {
return;
}
if (alive) {
this.livenessFailures.delete(id);
return;
}
const failures = (this.livenessFailures.get(id) ?? 0) + 1;
if (failures < SESSION_WATCHDOG_MAX_FAILURES) {
this.livenessFailures.set(id, failures);
this.logger.warn('Liveness probe failed; will treat the session as dead after repeated failures', {
sessionId: id,
failures,
action: 'watchdog_probe_failed',
});
return;
}
this.livenessFailures.delete(id);
this.logger.warn('Liveness probe failed repeatedly; handling the session as disconnected', {
sessionId: id,
failures,
action: 'watchdog_disconnect',
});
await this.handleEngineDisconnected(id, 'liveness probe failed (watchdog)');
}
private scheduleReconnect(id: string, session: Session): void {
// Don't launch a fresh engine (Chromium) mid-shutdown: a disconnect during the drain window would
// otherwise schedule a reconnect that races onModuleDestroy's teardown and could orphan a browser.
// Leaving the session DISCONNECTED is the correct end state β a later start()/auto-restore
// re-initializes it cleanly.
if (this.shutdownService?.isShuttingDown()) {
this.logger.log(`Skipping reconnect during shutdown for session: ${session.name}`, { sessionId: id });
return;
}
const state = this.reconnectStates.get(id);
if (!state) return;
// Stability reset: the attempt budget covers one CONTINUOUS bad stretch. When the session stayed
// up β₯5 min since the last scheduled attempt it demonstrably recovered, so the next drop
// restarts the budget β unrelated transient drops must not accrue toward an explicit cap over
// the session's lifetime.
if (state.lastAttemptAt !== undefined && Date.now() - state.lastAttemptAt >= RECONNECT_STABILITY_RESET_MS) {
state.attempts = 0;
}
if (state.attempts >= state.maxAttempts) {
this.logger.error(`Max reconnect attempts reached for session: ${session.name}`, undefined, {
sessionId: id,
attempts: state.attempts,
action: 'reconnect_failed',
});
// Don't leave the session silently stuck DISCONNECTED β mark it terminally FAILED with a reason
// so findOne/findAll surface it via `lastError` and the dashboard shows it needs a restart.
// maxAttempts:0 means auto-reconnect is disabled, not that N attempts were tried and failed β say
// so instead of the misleading "failed after 0 attempts".
this.sessionErrors.set(
id,
state.maxAttempts === 0
? 'Auto-reconnect is disabled (max attempts set to 0); the session was left disconnected β restart it manually.'
: `Reconnection failed after ${state.attempts} attempts β restart the session.`,
);
void this.updateStatus(id, SessionStatus.FAILED);
return;
}
// Exponential backoff: baseDelay * 2^attempts (with jitter), clamped finite + within
// setTimeout's safe range so the timer can't overflow and fire immediately. With the default
// unlimited budget the delay parks at RECONNECT_DELAY_CAP_MS once the exponent outgrows it.
const delay = clampReconnectDelay(
state.baseDelay * Math.pow(2, state.attempts) + Math.random() * 1000,
state.baseDelay,
);
state.attempts++;
state.lastAttemptAt = Date.now();
const maxAttemptsLabel = Number.isFinite(state.maxAttempts) ? String(state.maxAttempts) : 'β';
this.logger.log(
`Scheduling reconnect attempt ${state.attempts}/${maxAttemptsLabel} in ${Math.round(delay / 1000)}s`,
{
sessionId: id,
attempt: state.attempts,
delayMs: delay,
action: 'reconnect_scheduled',
},
);
incrementSessionReconnectAttempts();
// Loop alert every RECONNECT_LOOP_ALERT_INTERVAL_ATTEMPTS consecutive attempts: with the default
// unlimited budget a permanently-broken setup retries forever, and this is the one operator-facing
// signal per ongoing episode (not per attempt). The streak resets via the stability window/onReady,
// so a fresh episode re-arms the alert instead of continuing an old cadence.
if (state.attempts > 0 && state.attempts % RECONNECT_LOOP_ALERT_INTERVAL_ATTEMPTS === 0) {
this.logger.warn(`Session is reconnect-looping: attempt ${state.attempts} scheduled`, {
sessionId: id,
attempts: state.attempts,
nextDelayMs: delay,
action: 'reconnect_loop',
});
incrementSessionReconnectLoopAlerts();
void this.webhookService.dispatch(id, 'session.reconnect_loop', {
sessionId: id,
attempts: state.attempts,
nextDelayMs: delay,
});
}
// Clear any timer a prior scheduleReconnect left pending so two back-to-back disconnects
// don't stack two timers (which would run executeReconnect twice and double-init the engine).
if (state.timer) clearTimeout(state.timer);
state.timer = setTimeout(() => {
void this.executeReconnect(id, session, state);
}, delay);
}
/**
* True once a session must stay down: it is explicitly marked tearing-down, or it was deleted
* outright while a slow engine.initialize() was in flight. delete() clears its `stoppingSessions`
* mark in its finally (ms) and removes the session row well before a Chromium launch resolves, so
* the mark alone can't catch a delete that raced a (re)connect β the session row is the source of
* truth a post-init guard must re-check before keeping the engine it just created.
*/
private async isSessionRetired(id: string): Promise<boolean> {
if (this.stoppingSessions.has(id)) {
return true;
}
return (await this.sessionRepository.findOne({ where: { id } })) == null;
}
private async executeReconnect(id: string, session: Session, state: ReconnectState): Promise<void> {
// The session may have been stopped/deleted before this fired β don't resurrect it.
if (this.stoppingSessions.has(id)) {
return;
}
try {
// Clean up old engine. Time-bound the teardown: a wedged Chromium (the common reconnect
// trigger) makes destroy() hang, and a raw await here would stall the reconnect forever β
// the session would never re-init nor reach FAILED. teardownEngineSafely always resolves
// (after 10s on a hang), so reconnection proceeds either way.
const oldEngine = this.engines.get(id);
if (oldEngine) {
await this.teardownEngineSafely(id, oldEngine, e => e.destroy(), 'destroy');
if (this.isLiveEngine(id, oldEngine)) this.engines.delete(id);
}
// Re-initialize
await this.initializeEngine(id, session);
// A stop()/delete() may have run while we awaited init β if so, tear down the engine we just
// registered so it isn't orphaned (the session is meant to be down). delete() clears its
// teardown mark before this slow init resolves, so re-check the session row exists, not just
// the mark β otherwise a delete that raced the reconnect leaks a live Chromium/socket.
// Guard the retirement DB read itself: a transient findOne failure must NOT fall through to the
// catch below, which would misread the freshly-built, HEALTHY engine as a half-built one and
// force-kill the session we just recovered. On a read error, assume not-retired and keep it.
let retired: boolean;
try {
retired = await this.isSessionRetired(id);
} catch {
retired = false;
}
if (retired) {
const resurrected = this.engines.get(id);
if (resurrected) {
await this.teardownEngineSafely(id, resurrected, e => e.destroy(), 'destroy');
if (this.isLiveEngine(id, resurrected)) this.engines.delete(id);
}
return;
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Reconnect attempt ${state.attempts} failed`, errorMessage, {
sessionId: id,
action: 'reconnect_error',
});
// initializeEngine registers the engine in the map BEFORE engine.initialize() runs, so a rejected
// re-init leaves a half-built engine behind. Evict + reap it: otherwise a reconnect that later
// exhausts its attempts strands an orphaned Chromium holding a concurrency slot, and the next
// start() sees the session as "already started".
const halfBuilt = this.engines.get(id);
if (halfBuilt) {
this.evictAndForceDestroy(id, halfBuilt);
}
// Schedule another attempt
this.scheduleReconnect(id, session);
}
}
private cancelReconnect(id: string): void {
const state = this.reconnectStates.get(id);
if (state?.timer) {
clearTimeout(state.timer);
state.timer = null;
}
this.reconnectStates.delete(id);
}
async stop(id: string): Promise<Session> {
const session = await this.findOne(id);
// Mark as tearing down BEFORE cleanup so an in-flight reconnect can't resurrect it.
this.stoppingSessions.add(id);
// Cancel any reconnection attempts
this.cancelReconnect(id);
// Disconnect the engine β time-bounded + isolated so a stuck socket can't wedge the stop; the
// Map is reconciled regardless. (The stop mark is intentionally left set, matching the prior
// behaviour: a later start() clears it; it guards against a late reconnect resurrecting the id.)
const engine = this.engines.get(id);
if (engine) {
await this.teardownEngineSafely(id, engine, e => e.disconnect(), 'disconnect');
if (this.isLiveEngine(id, engine)) this.engines.delete(id);
}
this.logger.log(`Session stopped: ${session.name}`, {
sessionId: id,
action: 'stop',
});
await this.updateStatus(id, SessionStatus.DISCONNECTED);
return this.findOne(id);
}
/**
* Force-recover a stuck session: SIGKILL its engine's own resources (a wedged Chromium for the
* whatsapp-web.js engine) and tear it down, even when a normal stop()/delete() can't because the
* engine is hung. Mirrors stop()'s lifecycle (stop-mark + cancel-reconnect + bounded, isolated
* teardown + Map reconciliation) but uses the engine's forceDestroy().
*/
async forceKill(id: string): Promise<Session> {
const session = await this.findOne(id);
// Mark as tearing down BEFORE cleanup so an in-flight reconnect can't resurrect it.
this.stoppingSessions.add(id);
this.cancelReconnect(id);
const engine = this.engines.get(id);
if (engine) {
await this.teardownEngineSafely(id, engine, e => e.forceDestroy(), 'force-destroy');
if (this.isLiveEngine(id, engine)) this.engines.delete(id);
}
this.logger.warn(`Session force-killed: ${session.name}`, {
sessionId: id,
action: 'force_kill',
});
await this.updateStatus(id, SessionStatus.DISCONNECTED);
return this.findOne(id);
}
async getQRCode(id: string): Promise<{ qrCode: string; status: SessionStatus }> {
const session = await this.findOne(id);
const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started. Call POST /sessions/:id/start first.');
}
const qrCode = engine.getQRCode();
if (!qrCode) {
if (session.status === SessionStatus.READY) {
throw new BadRequestException('Session is already authenticated, no QR code needed');
}
throw new BadRequestException('QR code is not ready yet. Please wait...');
}
return {
qrCode,
status: session.status,
};
}
/**
* Request an 8-char pairing code (link via phone number) as an alternative to scanning the QR.
* The session must be started but not yet authenticated.
*/
async requestPairingCode(id: string, phoneNumber: string): Promise<{ pairingCode: string; status: SessionStatus }> {
const session = await this.findOne(id);
const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started. Call POST /sessions/:id/start first.');
}
if (session.status === SessionStatus.READY) {
throw new BadRequestException('Session is already authenticated, no pairing needed');
}
const pairingCode = await engine.requestPairingCode(phoneNumber);
return { pairingCode, status: session.status };
}
getEngine(id: string): IWhatsAppEngine | undefined {
return this.engines.get(id);
}
/**
* Best-effort resolution of a privacy-id sender (`@lid`) to a phone number for inline attachment on
* incoming messages (#263). Cached per session (incl. misses). Never throws β returns null on any
* failure or when the engine isn't available. Gated by the caller on `RESOLVE_LID_TO_PHONE`.
*/
private async resolveSenderPhone(sessionId: string, contactId: string): Promise<string | null> {
const key = `${sessionId}:${contactId}`;
const cached = this.lidPhoneCache.get(key);
if (cached !== undefined) {
return cached;
}
let phone: string | null;
try {
phone = (await this.getEngine(sessionId)?.resolveContactPhone(contactId)) ?? null;
} catch {
phone = null;
}
// Bounded FIFO eviction: Map preserves insertion order, so the first key is the oldest.
if (this.lidPhoneCache.size >= SessionService.LID_PHONE_CACHE_MAX) {
for (const oldest of this.lidPhoneCache.keys()) {
this.lidPhoneCache.delete(oldest);
break;
}
}
this.lidPhoneCache.set(key, phone);
// Persist a real @lid -> phone resolution so the read-path can bridge this contact's `@lid` and
// `@c.us` rows even when the operator never sent to them (#583 R3 Phase 2). Reuses the resolution
// above β no extra network call β and is fire-and-forget so dispatch never blocks/fails on it.
if (phone) {
void this.lidMappingStore?.remember(userPart(contactId), phone, sessionId)?.catch(() => {});
}
return phone;
}
async getGroups(
id: string,
opts: ListOptions = {},
): Promise<{ id: string; name: string; linkedParentJID?: string | null }[]> {
await this.findOne(id); // Verify session exists
const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started');
}
const groups = await engine.getGroups();
const mapped = groups.map(g => ({
id: g.id,
name: g.name,
linkedParentJID: g.linkedParentJID,
}));
return paginate(mapped, opts.limit, opts.offset);
}
async getChats(id: string, opts: ListOptions = {}): Promise<ChatSummary[]> {
await this.findOne(id); // Verify session exists
const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started');
}
// Most-recent first, then bound the response window. Sorting before the cap means a capped
// response is the N newest chats (what clients show first) rather than an arbitrary slice.
const chats = [...(await engine.getChats())].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
return paginate(chats, opts.limit, opts.offset);
}
async sendSeen(id: string, chatId: string): Promise<boolean> {
await this.findOne(id); // Verify session exists
const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started');
}
return engine.sendSeen(chatId);
}
async markUnread(id: string, chatId: string): Promise<boolean> {
await this.findOne(id); // Verify session exists
const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started');
}
return engine.markUnread(chatId);
}
async deleteChat(id: string, chatId: string): Promise<boolean> {
await this.findOne(id); // Verify session exists
const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started');
}
return engine.deleteChat(chatId);
}
async sendChatState(id: string, chatId: string, state: ChatState): Promise<void> {
await this.findOne(id); // Verify session exists
const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started');
}
await engine.sendChatState(chatId, state);
}
private async updateStatus(id: string, status: SessionStatus): Promise<void> {
await this.sessionRepository.update(id, { status });
this.logger.debug(`Session status updated to ${status}`, {
sessionId: id,
status,
action: 'status_update',
});
// Mirror the status change to WS clients AND subscribed webhooks β both de-duped. Some engines signal
// one transition via both onStateChanged AND a dedicated callback (onQRCode/onDisconnected), which
// would otherwise emit/POST the same status twice; only act when it actually changed from the last one.
if (this.lastDispatchedStatus.get(id) !== status) {
this.lastDispatchedStatus.set(id, status);
this.eventsGateway.emitSessionStatus(id, status);
void this.webhookService.dispatch(id, 'session.status', { sessionId: id, status });
}
}
/**
* Get overall session statistics for multi-session monitoring
*/
async getStats(allowedSessions?: string[] | null): Promise<{
total: number;
active: number;
ready: number;
disconnected: number;
byStatus: Record<string, number>;
memoryUsage: { heapUsed: number; heapTotal: number; rss: number };
}> {
// Scope to the caller's allowedSessions so a session-restricted key cannot enumerate the count /
// status distribution of sessions it has no rights to (matches the scoped GET /sessions route).
const scope = allowedSessions && allowedSessions.length > 0 ? allowedSessions : null;
// Aggregate status counts in the database instead of loading every row. findAll() is bounded by
// DEFAULT_LIST_LIMIT for the HTTP routes, so reusing it here would silently undercount `total` and
// `byStatus` on deployments with more sessions than that cap. A grouped COUNT is correct at any
// scale and cheaper (no entity hydration).
const qb = this.sessionRepository
.createQueryBuilder('session')
.select('session.status', 'status')
.addSelect('COUNT(session.id)', 'count');
if (scope) {
qb.where('session.id IN (:...scope)', { scope });
}
const rows = await qb.groupBy('session.status').getRawMany<{ status: string; count: string }>();
const byStatus: Record<string, number> = {};
let total = 0;
for (const row of rows) {
const count = Number(row.count) || 0;
byStatus[row.status] = count;
total += count;
}
const memory = process.memoryUsage();
return {
total,
// engines is keyed by session id; a scoped key sees only its own running engines, not the global count.
active: scope ? [...this.engines.keys()].filter(id => scope.includes(id)).length : this.engines.size,
ready: byStatus[SessionStatus.READY] || 0,
disconnected: byStatus[SessionStatus.DISCONNECTED] || 0,
byStatus,
memoryUsage: {
heapUsed: Math.round(memory.heapUsed / 1024 / 1024),
heapTotal: Math.round(memory.heapTotal / 1024 / 1024),
rss: Math.round(memory.rss / 1024 / 1024),
},
};
}
/**
* Get count of currently active (running) sessions
*/
getActiveCount(): number {
return this.engines.size;
}
/**
* Check if session is currently active (engine running)
*/
isActive(id: string): boolean {
return this.engines.has(id);
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
|