@kaikybrofc/omnizap-system 2.3.1 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +20 -18
  2. package/app/controllers/messageController.js +473 -255
  3. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +83 -0
  4. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +1 -3
  5. package/app/observability/metrics.js +6 -3
  6. package/app/services/googleWebLinkService.js +77 -0
  7. package/database/index.js +2 -0
  8. package/database/migrations/20260301_0028_message_analysis_event.sql +32 -0
  9. package/database/migrations/20260301_0029_admin_action_audit.sql +16 -0
  10. package/package.json +1 -1
  11. package/public/index.html +12 -8
  12. package/public/js/apps/homeApp.js +75 -30
  13. package/public/js/apps/loginApp.js +184 -29
  14. package/public/js/apps/stickersAdminApp.js +3 -9
  15. package/public/js/apps/userApp.js +985 -55
  16. package/public/js/apps/userProfileApp.js +244 -0
  17. package/public/login/index.html +430 -100
  18. package/public/termos-de-uso/index.html +1 -1
  19. package/public/user/index.html +2 -180
  20. package/public/user/systemadm/index.html +774 -0
  21. package/server/controllers/stickerCatalog/nonCatalogHandlers.js +208 -0
  22. package/server/controllers/stickerCatalogController.js +1186 -363
  23. package/server/controllers/systemAdminController.js +141 -0
  24. package/server/controllers/userController.js +87 -0
  25. package/server/http/httpServer.js +72 -32
  26. package/server/middleware/cachePolicy.js +24 -0
  27. package/server/middleware/cachePolicyHelpers.js +2 -0
  28. package/server/middleware/rateLimit.js +82 -0
  29. package/server/middleware/requestLogger.js +16 -0
  30. package/server/middleware/requireAdminAuth.js +42 -0
  31. package/server/middleware/securityHeaders.js +6 -0
  32. package/server/routes/admin/systemAdminRouter.js +56 -0
  33. package/server/routes/health/healthRouter.js +41 -0
  34. package/server/routes/indexRouter.js +203 -0
  35. package/server/routes/metrics/metricsRouter.js +13 -0
  36. package/server/routes/stickerCatalog/catalogHandlers/catalogAdminHttp.js +44 -0
  37. package/server/routes/stickerCatalog/stickerApiRouter.js +84 -0
  38. package/server/routes/stickerCatalog/stickerDataRouter.js +140 -0
  39. package/server/routes/stickerCatalog/stickerSiteRouter.js +43 -0
  40. package/server/routes/user/userRouter.js +56 -0
  41. package/server/utils/safePath.js +26 -0
  42. package/server/routes/metricsRoute.js +0 -7
  43. package/server/routes/stickerCatalogRoute.js +0 -20
@@ -0,0 +1,83 @@
1
+ import { executeQuery, TABLES } from '../../../database/index.js';
2
+
3
+ const sanitizeText = (value, maxLength = 255) => {
4
+ const normalized = String(value || '')
5
+ .trim()
6
+ .replace(/\s+/g, ' ')
7
+ .slice(0, maxLength);
8
+ return normalized || null;
9
+ };
10
+
11
+ const sanitizeCommandName = (value) => {
12
+ const normalized = String(value || '')
13
+ .trim()
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9_-]/g, '')
16
+ .slice(0, 64);
17
+ return normalized || null;
18
+ };
19
+
20
+ const sanitizeBool = (value) => (value ? 1 : 0);
21
+
22
+ const clampInt = (value, fallback, min, max) => {
23
+ const numeric = Number(value);
24
+ if (!Number.isFinite(numeric)) return fallback;
25
+ return Math.max(min, Math.min(max, Math.floor(numeric)));
26
+ };
27
+
28
+ const sanitizeMetadata = (value) => {
29
+ if (!value || typeof value !== 'object') return null;
30
+ try {
31
+ const asJson = JSON.stringify(value);
32
+ if (!asJson || asJson === '{}') return null;
33
+ return asJson;
34
+ } catch {
35
+ return null;
36
+ }
37
+ };
38
+
39
+ export async function createMessageAnalysisEvent(payload = {}, connection = null) {
40
+ const messageId = sanitizeText(payload.messageId, 255);
41
+ const chatId = sanitizeText(payload.chatId, 255);
42
+ const senderId = sanitizeText(payload.senderId, 255);
43
+ const senderName = sanitizeText(payload.senderName, 120);
44
+ const upsertType = sanitizeText(payload.upsertType, 32);
45
+ const source = sanitizeText(payload.source, 32) || 'whatsapp';
46
+ const commandPrefix = sanitizeText(payload.commandPrefix, 8);
47
+ const commandName = sanitizeCommandName(payload.commandName);
48
+ const messageKind = sanitizeText(payload.messageKind, 48) || 'other';
49
+ const processingResult = sanitizeText(payload.processingResult, 64) || 'processed';
50
+ const errorCode = sanitizeText(payload.errorCode, 96);
51
+ const metadata = sanitizeMetadata(payload.metadata);
52
+
53
+ await executeQuery(
54
+ `INSERT INTO ${TABLES.MESSAGE_ANALYSIS_EVENT}
55
+ (
56
+ message_id,
57
+ chat_id,
58
+ sender_id,
59
+ sender_name,
60
+ upsert_type,
61
+ source,
62
+ is_group,
63
+ is_from_bot,
64
+ is_command,
65
+ command_name,
66
+ command_args_count,
67
+ command_known,
68
+ command_prefix,
69
+ message_kind,
70
+ has_media,
71
+ media_count,
72
+ text_length,
73
+ processing_result,
74
+ error_code,
75
+ metadata
76
+ )
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
78
+ [messageId, chatId, senderId, senderName, upsertType, source, sanitizeBool(payload.isGroup), sanitizeBool(payload.isFromBot), sanitizeBool(payload.isCommand), commandName, clampInt(payload.commandArgsCount, 0, 0, 64), payload.commandKnown === null || payload.commandKnown === undefined ? null : sanitizeBool(payload.commandKnown), commandPrefix, messageKind, sanitizeBool(payload.hasMedia), clampInt(payload.mediaCount, 0, 0, 25), clampInt(payload.textLength, 0, 0, 16_000), processingResult, errorCode, metadata],
79
+ connection,
80
+ );
81
+
82
+ return true;
83
+ }
@@ -108,9 +108,7 @@ const handleDomainEvent = async (event) => {
108
108
  if (packId) {
109
109
  enqueuePackScoreSnapshotRefresh([packId]);
110
110
  }
111
- const rebuildIdempotency = packId
112
- ? `evt:${eventType}:${packId}:${coalesceBucket}:rebuild_cycle`
113
- : `evt:${eventType}:${coalesceBucket}:rebuild_cycle`;
111
+ const rebuildIdempotency = packId ? `evt:${eventType}:${packId}:${coalesceBucket}:rebuild_cycle` : `evt:${eventType}:${coalesceBucket}:rebuild_cycle`;
114
112
  await enqueueTaskSafely({
115
113
  taskType: 'rebuild_cycle',
116
114
  payload: { reason: 'domain_event', event_type: eventType, aggregate_id: aggregateId, pack_id: packId || null, coalesced: true },
@@ -57,15 +57,17 @@ const toStatusClass = (statusCode) => {
57
57
  return `${head}xx`;
58
58
  };
59
59
 
60
- export const resolveRouteGroup = ({ pathname, metricsPath, catalogConfig = null } = {}) => {
60
+ export const resolveRouteGroup = ({ pathname, metricsPath, catalogConfig = null, userConfig = null, systemAdminConfig = null } = {}) => {
61
61
  if (pathname?.startsWith(metricsPath)) return 'metrics';
62
+ if (pathname === '/healthz' || pathname === '/readyz') return 'health';
62
63
  if (pathname === '/sitemap.xml') return 'sitemap';
63
64
  if (pathname === '/api/marketplace/stats') return 'marketplace_stats';
64
65
 
65
- const apiBasePath = catalogConfig?.apiBasePath || '';
66
+ const apiBasePath = catalogConfig?.apiBasePath || userConfig?.apiBasePath || '';
66
67
  const webPath = catalogConfig?.webPath || '';
67
68
  const dataPublicPath = catalogConfig?.dataPublicPath || '';
68
- const userProfilePath = catalogConfig?.userProfilePath || '';
69
+ const userProfilePath = userConfig?.webPath || '';
70
+ const systemAdminPath = systemAdminConfig?.webPath || '';
69
71
 
70
72
  if (apiBasePath && (pathname === apiBasePath || pathname?.startsWith(`${apiBasePath}/`))) {
71
73
  if (pathname === `${apiBasePath}/auth/google/session` || pathname === `${apiBasePath}/me` || pathname === `${apiBasePath}/admin/session`) {
@@ -78,6 +80,7 @@ export const resolveRouteGroup = ({ pathname, metricsPath, catalogConfig = null
78
80
  return 'catalog_api_public';
79
81
  }
80
82
  if (dataPublicPath && (pathname === dataPublicPath || pathname?.startsWith(`${dataPublicPath}/`))) return 'catalog_data_asset';
83
+ if (systemAdminPath && (pathname === systemAdminPath || pathname === `${systemAdminPath}/`)) return 'system_admin_web';
81
84
  if (userProfilePath && (pathname === userProfilePath || pathname === `${userProfilePath}/`)) return 'catalog_user_profile';
82
85
  if (webPath && (pathname === webPath || pathname?.startsWith(`${webPath}/`))) return 'catalog_web';
83
86
 
@@ -0,0 +1,77 @@
1
+ import { executeQuery, TABLES } from '../../database/index.js';
2
+ import { normalizeJid } from '../config/baileysConfig.js';
3
+ import { toWhatsAppPhoneDigits } from './whatsappLoginLinkService.js';
4
+
5
+ const parseEnvInt = (value, fallback, min, max) => {
6
+ const numeric = Number(value);
7
+ if (!Number.isFinite(numeric)) return fallback;
8
+ return Math.max(min, Math.min(max, Math.floor(numeric)));
9
+ };
10
+
11
+ const GOOGLE_LINK_CHECK_CACHE_TTL_MS = parseEnvInt(process.env.WHATSAPP_GOOGLE_LINK_CHECK_CACHE_TTL_MS, 60_000, 1_000, 10 * 60_000);
12
+ const googleLinkCheckCache = new Map();
13
+ let googleLinkTableMissingLogged = false;
14
+
15
+ const normalizeCacheKey = ({ ownerJid = '', ownerPhone = '' }) => {
16
+ const normalizedOwnerJid = normalizeJid(ownerJid) || '';
17
+ const normalizedOwnerPhone = toWhatsAppPhoneDigits(ownerPhone || ownerJid) || '';
18
+ return `${normalizedOwnerJid}|${normalizedOwnerPhone}`;
19
+ };
20
+
21
+ const getCachedGoogleLinkStatus = (cacheKey) => {
22
+ const cached = googleLinkCheckCache.get(cacheKey);
23
+ if (!cached) return null;
24
+ if (Number(cached.expiresAt || 0) <= Date.now()) {
25
+ googleLinkCheckCache.delete(cacheKey);
26
+ return null;
27
+ }
28
+ return Boolean(cached.linked);
29
+ };
30
+
31
+ const setCachedGoogleLinkStatus = (cacheKey, linked) => {
32
+ googleLinkCheckCache.set(cacheKey, {
33
+ linked: Boolean(linked),
34
+ expiresAt: Date.now() + GOOGLE_LINK_CHECK_CACHE_TTL_MS,
35
+ });
36
+ };
37
+
38
+ export const isWhatsAppUserLinkedToGoogleWebAccount = async ({ ownerJid = '', ownerPhone = '' } = {}) => {
39
+ const normalizedOwnerJid = normalizeJid(ownerJid) || '';
40
+ const normalizedOwnerPhone = toWhatsAppPhoneDigits(ownerPhone || ownerJid) || '';
41
+ if (!normalizedOwnerJid && !normalizedOwnerPhone) return false;
42
+
43
+ const cacheKey = normalizeCacheKey({ ownerJid: normalizedOwnerJid, ownerPhone: normalizedOwnerPhone });
44
+ const cached = getCachedGoogleLinkStatus(cacheKey);
45
+ if (cached !== null) return cached;
46
+
47
+ const whereClauses = [];
48
+ const params = [];
49
+ if (normalizedOwnerJid) {
50
+ whereClauses.push('owner_jid = ?');
51
+ params.push(normalizedOwnerJid);
52
+ }
53
+ if (normalizedOwnerPhone) {
54
+ whereClauses.push('owner_phone = ?');
55
+ params.push(normalizedOwnerPhone);
56
+ }
57
+
58
+ const rows = await executeQuery(
59
+ `SELECT google_sub
60
+ FROM ${TABLES.STICKER_WEB_GOOGLE_USER}
61
+ WHERE ${whereClauses.join(' OR ')}
62
+ LIMIT 1`,
63
+ params,
64
+ ).catch((error) => {
65
+ if (error?.code === 'ER_NO_SUCH_TABLE') {
66
+ if (!googleLinkTableMissingLogged) {
67
+ googleLinkTableMissingLogged = true;
68
+ }
69
+ return [];
70
+ }
71
+ throw error;
72
+ });
73
+
74
+ const linked = Array.isArray(rows) && rows.length > 0;
75
+ setCachedGoogleLinkStatus(cacheKey, linked);
76
+ return linked;
77
+ };
package/database/index.js CHANGED
@@ -91,6 +91,7 @@ logger.info(`Configuração de banco de dados carregada para o ambiente: ${envir
91
91
  */
92
92
  export const TABLES = {
93
93
  MESSAGES: 'messages',
94
+ MESSAGE_ANALYSIS_EVENT: 'message_analysis_event',
94
95
  CHATS: 'chats',
95
96
  GROUPS_METADATA: 'groups_metadata',
96
97
  GROUP_CONFIGS: 'group_configs',
@@ -116,6 +117,7 @@ export const TABLES = {
116
117
  STICKER_WEB_GOOGLE_SESSION: 'sticker_web_google_session',
117
118
  STICKER_WEB_ADMIN_BAN: 'sticker_web_admin_ban',
118
119
  STICKER_WEB_ADMIN_MODERATOR: 'sticker_web_admin_moderator',
120
+ ADMIN_ACTION_AUDIT: 'admin_action_audit',
119
121
  RPG_PLAYER: 'rpg_player',
120
122
  RPG_PLAYER_POKEMON: 'rpg_player_pokemon',
121
123
  RPG_BATTLE_STATE: 'rpg_battle_state',
@@ -0,0 +1,32 @@
1
+ CREATE TABLE IF NOT EXISTS message_analysis_event (
2
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
3
+ message_id VARCHAR(255) NULL,
4
+ chat_id VARCHAR(255) NULL,
5
+ sender_id VARCHAR(255) NULL,
6
+ sender_name VARCHAR(120) NULL,
7
+ upsert_type VARCHAR(32) NULL,
8
+ source VARCHAR(32) NOT NULL DEFAULT 'whatsapp',
9
+ is_group TINYINT(1) NOT NULL DEFAULT 0,
10
+ is_from_bot TINYINT(1) NOT NULL DEFAULT 0,
11
+ is_command TINYINT(1) NOT NULL DEFAULT 0,
12
+ command_name VARCHAR(64) NULL,
13
+ command_args_count SMALLINT UNSIGNED NOT NULL DEFAULT 0,
14
+ command_known TINYINT(1) NULL,
15
+ command_prefix VARCHAR(8) NULL,
16
+ message_kind VARCHAR(48) NOT NULL DEFAULT 'other',
17
+ has_media TINYINT(1) NOT NULL DEFAULT 0,
18
+ media_count SMALLINT UNSIGNED NOT NULL DEFAULT 0,
19
+ text_length INT UNSIGNED NOT NULL DEFAULT 0,
20
+ processing_result VARCHAR(64) NOT NULL DEFAULT 'processed',
21
+ error_code VARCHAR(96) NULL,
22
+ metadata JSON NULL,
23
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
24
+ INDEX idx_message_analysis_created (created_at),
25
+ INDEX idx_message_analysis_chat_created (chat_id, created_at),
26
+ INDEX idx_message_analysis_sender_created (sender_id, created_at),
27
+ INDEX idx_message_analysis_command_created (command_name, created_at),
28
+ INDEX idx_message_analysis_kind_created (message_kind, created_at),
29
+ INDEX idx_message_analysis_result_created (processing_result, created_at),
30
+ INDEX idx_message_analysis_is_command_created (is_command, created_at)
31
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
32
+
@@ -0,0 +1,16 @@
1
+ CREATE TABLE IF NOT EXISTS admin_action_audit (
2
+ id CHAR(36) PRIMARY KEY,
3
+ admin_role VARCHAR(32) NOT NULL DEFAULT 'owner',
4
+ admin_google_sub VARCHAR(255) NULL,
5
+ admin_email VARCHAR(255) NULL,
6
+ admin_owner_jid VARCHAR(255) NULL,
7
+ action VARCHAR(96) NOT NULL,
8
+ target_type VARCHAR(64) NULL,
9
+ target_id VARCHAR(255) NULL,
10
+ status VARCHAR(32) NOT NULL DEFAULT 'success',
11
+ details JSON NULL,
12
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
13
+ INDEX idx_admin_action_audit_created (created_at),
14
+ INDEX idx_admin_action_audit_action_created (action, created_at),
15
+ INDEX idx_admin_action_audit_admin_created (admin_google_sub, admin_email, created_at)
16
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaikybrofc/omnizap-system",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
4
  "description": "Sistema profissional de automação WhatsApp com tecnologia Baileys",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
package/public/index.html CHANGED
@@ -395,7 +395,7 @@
395
395
  .proof-grid {
396
396
  margin-top: 16px;
397
397
  display: grid;
398
- grid-template-columns: repeat(3, minmax(0, 1fr));
398
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
399
399
  gap: 10px;
400
400
  }
401
401
 
@@ -879,16 +879,20 @@
879
879
 
880
880
  <div class="proof-grid" aria-label="Prova social">
881
881
  <article class="proof">
882
- <strong id="proof-packs">--</strong>
883
- <span>rotinas e respostas prontas</span>
882
+ <strong id="proof-bots-online">--</strong>
883
+ <span>bots online agora</span>
884
884
  </article>
885
885
  <article class="proof">
886
- <strong id="proof-stickers">--</strong>
887
- <span>stickers disponíveis</span>
886
+ <strong id="proof-messages-today">--</strong>
887
+ <span>mensagens hoje</span>
888
888
  </article>
889
889
  <article class="proof">
890
- <strong id="proof-groups">--</strong>
891
- <span>grupos ativos com o bot</span>
890
+ <strong id="proof-spam-blocked">--</strong>
891
+ <span>spam bloqueado hoje</span>
892
+ </article>
893
+ <article class="proof">
894
+ <strong id="proof-uptime">--</strong>
895
+ <span>uptime do bot</span>
892
896
  </article>
893
897
  </div>
894
898
  </div>
@@ -1132,6 +1136,6 @@
1132
1136
  ><svg viewBox="0 0 24 24"><use href="#icon-whatsapp"></use></svg></span
1133
1137
  ></a>
1134
1138
 
1135
- <script type="module" src="/js/apps/homeApp.js?v=20260228-home-bootstrap-v10"></script>
1139
+ <script type="module" src="/js/apps/homeApp.js?v=20260301-home-realtime-v11"></script>
1136
1140
  </body>
1137
1141
  </html>
@@ -1,16 +1,25 @@
1
1
  const FALLBACK_THUMB_URL = '/assets/images/brand-logo-128.webp';
2
2
  const HOME_BOOTSTRAP_ENDPOINT = '/api/sticker-packs/home-bootstrap';
3
3
  const SVG_NS = 'http://www.w3.org/2000/svg';
4
+ const SOCIAL_PROOF_REFRESH_MS = 15_000;
4
5
  let homeBootstrapPayloadPromise = null;
5
6
 
6
- const fetchHomeBootstrapPayload = async () => {
7
+ const loadHomeBootstrapPayload = async () => {
8
+ const response = await fetch(HOME_BOOTSTRAP_ENDPOINT, { credentials: 'include' });
9
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
10
+ const payload = await response.json();
11
+ return payload?.data || {};
12
+ };
13
+
14
+ const fetchHomeBootstrapPayload = async ({ forceRefresh = false } = {}) => {
15
+ if (forceRefresh) {
16
+ const freshData = await loadHomeBootstrapPayload();
17
+ homeBootstrapPayloadPromise = Promise.resolve(freshData);
18
+ return freshData;
19
+ }
20
+
7
21
  if (!homeBootstrapPayloadPromise) {
8
- homeBootstrapPayloadPromise = fetch(HOME_BOOTSTRAP_ENDPOINT, { credentials: 'include' })
9
- .then((response) => {
10
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
11
- return response.json();
12
- })
13
- .then((payload) => payload?.data || {})
22
+ homeBootstrapPayloadPromise = loadHomeBootstrapPayload()
14
23
  .catch((error) => {
15
24
  homeBootstrapPayloadPromise = null;
16
25
  throw error;
@@ -454,17 +463,19 @@ const initAddBotCtas = () => {
454
463
  };
455
464
 
456
465
  const initSocialProof = () => {
457
- const packsEl = document.getElementById('proof-packs');
458
- const stickersEl = document.getElementById('proof-stickers');
459
- const groupsEl = document.getElementById('proof-groups');
466
+ const botsOnlineEl = document.getElementById('proof-bots-online');
467
+ const messagesTodayEl = document.getElementById('proof-messages-today');
468
+ const spamBlockedEl = document.getElementById('proof-spam-blocked');
469
+ const uptimeEl = document.getElementById('proof-uptime');
460
470
  const statusEl = document.getElementById('proof-status');
461
471
 
462
- if (!packsEl || !stickersEl || !groupsEl) return null;
472
+ if (!botsOnlineEl || !messagesTodayEl || !spamBlockedEl || !uptimeEl) return null;
463
473
 
464
474
  const setFallback = () => {
465
- packsEl.textContent = 'n/d';
466
- stickersEl.textContent = 'n/d';
467
- groupsEl.textContent = 'n/d';
475
+ botsOnlineEl.textContent = 'n/d';
476
+ messagesTodayEl.textContent = 'n/d';
477
+ spamBlockedEl.textContent = 'n/d';
478
+ uptimeEl.textContent = 'n/d';
468
479
  if (statusEl) statusEl.textContent = 'bot pronto';
469
480
  };
470
481
 
@@ -479,27 +490,61 @@ const initSocialProof = () => {
479
490
  return 'pronto';
480
491
  };
481
492
 
482
- return runAfterLoadIdle(
483
- () => {
484
- fetchHomeBootstrapPayload()
485
- .then((bootstrapData) => {
486
- const stats = bootstrapData?.stats || {};
487
- const summary = bootstrapData?.system_summary || {};
493
+ const setNumericMetric = (element, value, { animate = true } = {}) => {
494
+ const hasValue = value !== null && value !== undefined && value !== '';
495
+ const numeric = hasValue ? Number(value) : Number.NaN;
496
+ if (!hasValue || !Number.isFinite(numeric)) {
497
+ element.textContent = 'n/d';
498
+ return;
499
+ }
500
+ if (!animate) {
501
+ element.textContent = shortNum(numeric);
502
+ element.dataset.value = String(Math.max(0, numeric));
503
+ return;
504
+ }
505
+ animateCountUp(element, numeric);
506
+ };
488
507
 
489
- animateCountUp(packsEl, Number(stats.packs_total || 0));
490
- animateCountUp(stickersEl, Number(stats.stickers_total || 0));
491
- animateCountUp(groupsEl, Number(summary?.platform?.total_groups || 0));
508
+ const refreshMetrics = async ({ forceRefresh = false, animate = false } = {}) => {
509
+ const bootstrapData = await fetchHomeBootstrapPayload({ forceRefresh });
510
+ const summary = bootstrapData?.system_summary || {};
511
+ const realtime = bootstrapData?.home_realtime || {};
492
512
 
493
- if (statusEl) {
494
- statusEl.textContent = `bot ${normalizeStatus(summary?.system_status || summary?.bot?.connection_status)}`;
495
- }
496
- })
497
- .catch(() => {
498
- setFallback();
499
- });
513
+ const botsOnline = Number(realtime?.bots_online);
514
+ const messagesToday = Number(realtime?.messages_today);
515
+ const spamBlockedToday = Number(realtime?.spam_blocked_today);
516
+ const uptime = String(realtime?.uptime || summary?.process?.uptime || '').trim() || 'n/d';
517
+
518
+ setNumericMetric(botsOnlineEl, botsOnline, { animate });
519
+ setNumericMetric(messagesTodayEl, messagesToday, { animate });
520
+ setNumericMetric(spamBlockedEl, spamBlockedToday, { animate });
521
+ uptimeEl.textContent = uptime;
522
+
523
+ if (statusEl) {
524
+ statusEl.textContent = `bot ${normalizeStatus(summary?.system_status || summary?.bot?.connection_status)}`;
525
+ }
526
+ };
527
+
528
+ let intervalId = null;
529
+ const stopBootstrap = runAfterLoadIdle(
530
+ () => {
531
+ void refreshMetrics({ forceRefresh: false, animate: true }).catch(() => {
532
+ setFallback();
533
+ });
500
534
  },
501
535
  { delayMs: 620, timeoutMs: 1500 },
502
536
  );
537
+
538
+ intervalId = window.setInterval(() => {
539
+ void refreshMetrics({ forceRefresh: true, animate: false }).catch(() => {});
540
+ }, SOCIAL_PROOF_REFRESH_MS);
541
+
542
+ return () => {
543
+ stopBootstrap();
544
+ if (intervalId !== null) {
545
+ window.clearInterval(intervalId);
546
+ }
547
+ };
503
548
  };
504
549
 
505
550
  const registerCleanup = (cleanups, cleanup) => {