@kaikybrofc/omnizap-system 2.1.8

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 (166) hide show
  1. package/.env.example +534 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/RELEASE-v2.1.2.md +83 -0
  5. package/app/config/adminIdentity.js +87 -0
  6. package/app/config/baileysConfig.js +693 -0
  7. package/app/config/groupUtils.js +388 -0
  8. package/app/connection/socketController.js +992 -0
  9. package/app/controllers/messageController.js +354 -0
  10. package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
  11. package/app/modules/adminModule/groupEventHandlers.js +355 -0
  12. package/app/modules/aiModule/catCommand.js +1006 -0
  13. package/app/modules/broadcastModule/noticeCommand.js +416 -0
  14. package/app/modules/gameModule/diceCommand.js +67 -0
  15. package/app/modules/menuModule/common.js +311 -0
  16. package/app/modules/menuModule/menus.js +59 -0
  17. package/app/modules/playModule/playCommand.js +1615 -0
  18. package/app/modules/quoteModule/quoteCommand.js +851 -0
  19. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
  20. package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
  21. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
  22. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  23. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
  24. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  25. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  26. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  27. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  28. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
  29. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
  30. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  31. package/app/modules/statsModule/globalRankingCommand.js +65 -0
  32. package/app/modules/statsModule/noMessageCommand.js +288 -0
  33. package/app/modules/statsModule/rankingCommand.js +60 -0
  34. package/app/modules/statsModule/rankingCommon.js +889 -0
  35. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  36. package/app/modules/stickerModule/convertToWebp.js +390 -0
  37. package/app/modules/stickerModule/stickerCommand.js +454 -0
  38. package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
  39. package/app/modules/stickerModule/stickerTextCommand.js +657 -0
  40. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  41. package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
  42. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
  43. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
  44. package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
  45. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
  46. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  47. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
  48. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
  49. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
  50. package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
  51. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
  52. package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
  53. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
  54. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
  55. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  56. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
  57. package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
  58. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
  59. package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
  60. package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
  61. package/app/modules/stickerPackModule/stickerPackService.js +788 -0
  62. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
  63. package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
  64. package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
  65. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
  66. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
  67. package/app/modules/systemMetricsModule/pingCommand.js +421 -0
  68. package/app/modules/tiktokModule/tiktokCommand.js +798 -0
  69. package/app/modules/userModule/userCommand.js +1217 -0
  70. package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
  71. package/app/observability/metrics.js +734 -0
  72. package/app/services/captchaService.js +492 -0
  73. package/app/services/dbWriteQueue.js +572 -0
  74. package/app/services/groupMetadataService.js +279 -0
  75. package/app/services/lidMapService.js +663 -0
  76. package/app/services/messagePersistenceService.js +56 -0
  77. package/app/services/newsBroadcastService.js +351 -0
  78. package/app/services/pokeApiService.js +398 -0
  79. package/app/services/queueUtils.js +57 -0
  80. package/app/services/socketState.js +7 -0
  81. package/app/store/aiPromptStore.js +38 -0
  82. package/app/store/groupConfigStore.js +58 -0
  83. package/app/store/premiumUserStore.js +36 -0
  84. package/app/utils/antiLink/antiLinkModule.js +804 -0
  85. package/app/utils/http/getImageBufferModule.js +18 -0
  86. package/app/utils/json/jsonSanitizer.js +113 -0
  87. package/app/utils/json/jsonSanitizer.test.js +40 -0
  88. package/app/utils/logger/loggerModule.js +262 -0
  89. package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
  90. package/database/index.js +2052 -0
  91. package/database/init.js +516 -0
  92. package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
  93. package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
  94. package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
  95. package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
  96. package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
  97. package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
  98. package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
  99. package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
  100. package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
  101. package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
  102. package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
  103. package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
  104. package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
  105. package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
  106. package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
  107. package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
  108. package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
  109. package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
  110. package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
  111. package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
  112. package/docker-compose.yml +103 -0
  113. package/ecosystem.prod.config.cjs +35 -0
  114. package/eslint.config.js +61 -0
  115. package/index.js +437 -0
  116. package/ml/clip_classifier/Dockerfile +16 -0
  117. package/ml/clip_classifier/README.md +120 -0
  118. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  119. package/ml/clip_classifier/classifier.py +654 -0
  120. package/ml/clip_classifier/embedding_store.py +481 -0
  121. package/ml/clip_classifier/env_loader.py +15 -0
  122. package/ml/clip_classifier/llm_label_expander.py +144 -0
  123. package/ml/clip_classifier/main.py +213 -0
  124. package/ml/clip_classifier/requirements.txt +10 -0
  125. package/ml/clip_classifier/similarity_engine.py +74 -0
  126. package/observability/alert-rules.yml +60 -0
  127. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  128. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  129. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  130. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  131. package/observability/loki-config.yml +38 -0
  132. package/observability/mysql-exporter.cnf +5 -0
  133. package/observability/mysql-setup.sql +46 -0
  134. package/observability/prometheus.yml +32 -0
  135. package/observability/promtail-config.yml +84 -0
  136. package/package.json +109 -0
  137. package/public/api-docs/index.html +144 -0
  138. package/public/css/github-project-panel.css +297 -0
  139. package/public/css/stickers-admin.css +1272 -0
  140. package/public/css/styles.css +671 -0
  141. package/public/index.html +1311 -0
  142. package/public/js/apps/apiDocsApp.js +310 -0
  143. package/public/js/apps/createPackApp.js +2069 -0
  144. package/public/js/apps/homeApp.js +396 -0
  145. package/public/js/apps/stickersAdminApp.js +1744 -0
  146. package/public/js/apps/stickersApp.js +4830 -0
  147. package/public/js/catalog.js +1019 -0
  148. package/public/js/github-panel/components/CommitList.js +34 -0
  149. package/public/js/github-panel/components/ErrorState.js +16 -0
  150. package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
  151. package/public/js/github-panel/components/ReleaseList.js +38 -0
  152. package/public/js/github-panel/components/SkeletonPanel.js +22 -0
  153. package/public/js/github-panel/components/StatCard.js +15 -0
  154. package/public/js/github-panel/index.js +15 -0
  155. package/public/js/github-panel/useGithubRepoData.js +154 -0
  156. package/public/js/github-panel/vendor/react.js +11 -0
  157. package/public/js/runtime/react-runtime.js +19 -0
  158. package/public/licenca/index.html +106 -0
  159. package/public/stickers/admin/index.html +23 -0
  160. package/public/stickers/create/index.html +47 -0
  161. package/public/stickers/index.html +48 -0
  162. package/public/termos-de-uso/index.html +125 -0
  163. package/scripts/cache-bust.mjs +107 -0
  164. package/scripts/deploy.sh +458 -0
  165. package/scripts/github-deploy-notify.mjs +174 -0
  166. package/scripts/release.sh +129 -0
@@ -0,0 +1,1217 @@
1
+ import { executeQuery, TABLES } from '../../../database/index.js';
2
+ import { getJidUser, getProfilePicBuffer, normalizeJid } from '../../config/baileysConfig.js';
3
+ import { isUserAdmin } from '../../config/groupUtils.js';
4
+ import { extractUserIdInfo, isWhatsAppUserId, resolveUserId, resolveUserIdCached } from '../../services/lidMapService.js';
5
+ import { sendAndStore } from '../../services/messagePersistenceService.js';
6
+ import premiumUserStore from '../../store/premiumUserStore.js';
7
+ import logger from '../../utils/logger/loggerModule.js';
8
+ import { MESSAGE_TYPE_SQL, TIMESTAMP_TO_DATETIME_SQL } from '../statsModule/rankingCommon.js';
9
+ import { getAdminJid } from '../../config/adminIdentity.js';
10
+
11
+ const DEFAULT_COMMAND_PREFIX = process.env.COMMAND_PREFIX || '/';
12
+ const ACTIVE_DAYS_WINDOW = Number.parseInt(process.env.USER_PROFILE_ACTIVE_DAYS || '30', 10);
13
+ const OWNER_JID = getAdminJid();
14
+ const MIN_PHONE_DIGITS = 5;
15
+ const MAX_PHONE_DIGITS = 20;
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ const SOCIAL_RECENT_DAYS = Number.parseInt(process.env.USER_PROFILE_SOCIAL_DAYS || '45', 10);
18
+ const SOCIAL_DST_EXPR = `JSON_UNQUOTE(
19
+ COALESCE(
20
+ JSON_EXTRACT(m.raw_message, '$.message.extendedTextMessage.contextInfo.participant'),
21
+ JSON_EXTRACT(m.raw_message, '$.message.extendedTextMessage.contextInfo.mentionedJid[0]'),
22
+ JSON_EXTRACT(m.raw_message, '$.message.imageMessage.contextInfo.participant'),
23
+ JSON_EXTRACT(m.raw_message, '$.message.imageMessage.contextInfo.mentionedJid[0]'),
24
+ JSON_EXTRACT(m.raw_message, '$.message.videoMessage.contextInfo.participant'),
25
+ JSON_EXTRACT(m.raw_message, '$.message.videoMessage.contextInfo.mentionedJid[0]'),
26
+ JSON_EXTRACT(m.raw_message, '$.message.documentMessage.contextInfo.participant'),
27
+ JSON_EXTRACT(m.raw_message, '$.message.documentMessage.contextInfo.mentionedJid[0]')
28
+ )
29
+ )`;
30
+
31
+ /**
32
+ * Monta o texto de ajuda com a forma correta de uso do comando.
33
+ * @param {string} [commandPrefix=DEFAULT_COMMAND_PREFIX] Prefixo configurado para comandos.
34
+ * @returns {string} Texto de instruções para o usuário.
35
+ */
36
+ const buildUsageText = (commandPrefix = DEFAULT_COMMAND_PREFIX) => ['Formato de uso:', `${commandPrefix}user perfil <id|telefone>`, '', 'Dica:', '• Você pode mencionar alguém.', '• Ou responder a mensagem do usuário desejado.'].join('\n');
37
+
38
+ /**
39
+ * Extrai o `contextInfo` da mensagem, incluindo estruturas aninhadas.
40
+ * @param {object} messageInfo Estrutura da mensagem recebida pelo bot.
41
+ * @returns {object|null} `contextInfo` encontrado ou `null` quando indisponível.
42
+ */
43
+ const getContextInfo = (messageInfo) => {
44
+ const message = messageInfo?.message;
45
+ if (!message || typeof message !== 'object') return null;
46
+
47
+ for (const value of Object.values(message)) {
48
+ if (value?.contextInfo && typeof value.contextInfo === 'object') {
49
+ return value.contextInfo;
50
+ }
51
+ if (value?.message && typeof value.message === 'object') {
52
+ for (const nested of Object.values(value.message)) {
53
+ if (nested?.contextInfo && typeof nested.contextInfo === 'object') {
54
+ return nested.contextInfo;
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ return null;
61
+ };
62
+
63
+ /**
64
+ * Normaliza e valida o alvo informado manualmente no comando.
65
+ * @param {string} rawValue Valor bruto digitado após o subcomando.
66
+ * @returns {{ jid: string | null, invalid: boolean }} JID normalizado ou sinalização de entrada inválida.
67
+ */
68
+ const parseTargetArgument = (rawValue) => {
69
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
70
+ if (!value) return { jid: null, invalid: false };
71
+
72
+ const withoutAt = value.startsWith('@') ? value.slice(1).trim() : value;
73
+ if (!withoutAt) return { jid: null, invalid: true };
74
+
75
+ if (withoutAt.includes('@')) {
76
+ const normalized = normalizeJid(withoutAt);
77
+ return normalized ? { jid: normalized, invalid: false } : { jid: null, invalid: true };
78
+ }
79
+
80
+ const digits = withoutAt.replace(/\D/g, '');
81
+ const hasValidLength = digits.length >= MIN_PHONE_DIGITS && digits.length <= MAX_PHONE_DIGITS;
82
+ if (!digits || !hasValidLength) return { jid: null, invalid: true };
83
+
84
+ return { jid: `${digits}@s.whatsapp.net`, invalid: false };
85
+ };
86
+
87
+ /**
88
+ * Define qual usuário será usado como alvo (menção, argumento, reply ou remetente).
89
+ * @param {object} messageInfo Mensagem usada para inferir contexto.
90
+ * @param {string|null} senderJid JID do remetente do comando.
91
+ * @param {string} targetArg Argumento explícito passado no comando.
92
+ * @returns {{ source: string | object | null, invalidExplicitTarget: boolean }} Fonte escolhida e sinalizador de argumento inválido.
93
+ */
94
+ const resolveCandidateTarget = (messageInfo, senderJid, targetArg) => {
95
+ const contextInfo = getContextInfo(messageInfo);
96
+ const mentioned = Array.isArray(contextInfo?.mentionedJid) ? contextInfo.mentionedJid.find(Boolean) || null : null;
97
+ const parsedTarget = parseTargetArgument(targetArg);
98
+ const repliedSource =
99
+ contextInfo?.participant || contextInfo?.participantAlt
100
+ ? {
101
+ participant: contextInfo.participant || null,
102
+ participantAlt: contextInfo.participantAlt || null,
103
+ }
104
+ : null;
105
+ const hasContextTarget = Boolean(mentioned || repliedSource);
106
+
107
+ return {
108
+ source: mentioned || parsedTarget.jid || repliedSource || senderJid || null,
109
+ invalidExplicitTarget: parsedTarget.invalid && !hasContextTarget,
110
+ };
111
+ };
112
+
113
+ /**
114
+ * Resolve o identificador canônico do usuário, considerando mapeamento JID/LID.
115
+ * @param {string|object|null} source Fonte de identificação do usuário.
116
+ * @returns {Promise<string|null>} ID canônico resolvido ou fallback quando possível.
117
+ */
118
+ const resolveCanonicalTarget = async (source) => {
119
+ if (!source) return null;
120
+ const info = extractUserIdInfo(source);
121
+ const fallbackId = resolveUserIdCached(info) || info.raw || null;
122
+ try {
123
+ const resolved = await resolveUserId(info);
124
+ return normalizeJid(resolved) || resolved || fallbackId;
125
+ } catch (error) {
126
+ logger.warn('Falha ao resolver alvo no comando user perfil.', {
127
+ error: error.message,
128
+ source: info.raw,
129
+ });
130
+ return fallbackId;
131
+ }
132
+ };
133
+
134
+ /**
135
+ * Carrega todos os IDs equivalentes ao alvo (JID e/ou LID) para consultas no banco.
136
+ * @param {string|null} canonicalTarget ID canônico do usuário.
137
+ * @returns {Promise<string[]>} Lista de IDs possíveis para o mesmo usuário.
138
+ */
139
+ const resolveSenderIdsForTarget = async (canonicalTarget) => {
140
+ if (!canonicalTarget) return [];
141
+ const ids = new Set([canonicalTarget]);
142
+
143
+ if (isWhatsAppUserId(canonicalTarget)) {
144
+ const rows = await executeQuery(`SELECT lid FROM ${TABLES.LID_MAP} WHERE jid = ?`, [canonicalTarget]);
145
+ (rows || []).forEach((row) => {
146
+ if (row?.lid) ids.add(row.lid);
147
+ });
148
+ } else {
149
+ const rows = await executeQuery(`SELECT jid FROM ${TABLES.LID_MAP} WHERE lid = ?`, [canonicalTarget]);
150
+ (rows || []).forEach((row) => {
151
+ if (row?.jid) ids.add(normalizeJid(row.jid) || row.jid);
152
+ });
153
+ }
154
+
155
+ return Array.from(ids);
156
+ };
157
+
158
+ /**
159
+ * Constrói placeholders SQL para cláusulas `IN`.
160
+ * @param {unknown[]} items Itens que serão bindados na query.
161
+ * @returns {string} String no formato `?, ?, ?`.
162
+ */
163
+ const buildInClause = (items) => items.map(() => '?').join(', ');
164
+
165
+ /**
166
+ * Busca contagem e período de atividade do usuário no histórico de mensagens.
167
+ * @param {{ canonicalId: string | null, senderIds?: string[] }} params Parâmetros de busca.
168
+ * @returns {Promise<{ totalMessages: number, firstMessage: string | Date | null, lastMessage: string | Date | null }>} Estatísticas básicas.
169
+ */
170
+ const fetchUserStats = async ({ canonicalId, senderIds = [] }) => {
171
+ if (canonicalId) {
172
+ const [row] = await executeQuery(
173
+ `SELECT COUNT(*) AS total_messages,
174
+ MIN(m.timestamp) AS first_message,
175
+ MAX(m.timestamp) AS last_message
176
+ FROM ${TABLES.MESSAGES} m
177
+ LEFT JOIN ${TABLES.LID_MAP} lm
178
+ ON lm.lid = m.sender_id
179
+ AND lm.jid IS NOT NULL
180
+ WHERE m.sender_id IS NOT NULL
181
+ AND COALESCE(lm.jid, m.sender_id) = ?`,
182
+ [canonicalId],
183
+ );
184
+
185
+ return {
186
+ totalMessages: Number(row?.total_messages || 0),
187
+ firstMessage: row?.first_message || null,
188
+ lastMessage: row?.last_message || null,
189
+ };
190
+ }
191
+
192
+ if (!senderIds.length) return { totalMessages: 0, firstMessage: null, lastMessage: null };
193
+
194
+ const inClause = buildInClause(senderIds);
195
+ const [row] = await executeQuery(
196
+ `SELECT COUNT(*) AS total_messages,
197
+ MIN(timestamp) AS first_message,
198
+ MAX(timestamp) AS last_message
199
+ FROM ${TABLES.MESSAGES}
200
+ WHERE sender_id IN (${inClause})`,
201
+ senderIds,
202
+ );
203
+
204
+ return {
205
+ totalMessages: Number(row?.total_messages || 0),
206
+ firstMessage: row?.first_message || null,
207
+ lastMessage: row?.last_message || null,
208
+ };
209
+ };
210
+
211
+ /**
212
+ * Converte timestamps numéricos ou datas textuais para milissegundos.
213
+ * @param {number|string|Date|null|undefined} value Valor de data/hora em formatos suportados.
214
+ * @returns {number|null} Timestamp em milissegundos ou `null` quando inválido.
215
+ */
216
+ const toMillis = (value) => {
217
+ if (value === null || value === undefined) return null;
218
+ if (typeof value === 'number') {
219
+ if (value > 1e12) return value;
220
+ if (value > 1e9) return value * 1000;
221
+ return value;
222
+ }
223
+ const parsed = Date.parse(value);
224
+ return Number.isNaN(parsed) ? null : parsed;
225
+ };
226
+
227
+ /**
228
+ * Formata uma proporção em percentual com duas casas decimais.
229
+ * @param {number} value Numerador.
230
+ * @param {number} total Denominador.
231
+ * @returns {string} Percentual no padrão `00.00%`.
232
+ */
233
+ const formatPercent = (value, total) => {
234
+ const numericValue = Number(value || 0);
235
+ const numericTotal = Number(total || 0);
236
+ if (numericTotal <= 0) return '0.00%';
237
+ return `${((numericValue / numericTotal) * 100).toFixed(2)}%`;
238
+ };
239
+
240
+ /**
241
+ * Calcula a diferença inteira em dias entre dois timestamps.
242
+ * @param {number} fromMs Timestamp inicial em milissegundos.
243
+ * @param {number} [toMs=Date.now()] Timestamp final em milissegundos.
244
+ * @returns {number} Quantidade de dias inteiros.
245
+ */
246
+ const toIntegerDays = (fromMs, toMs = Date.now()) => {
247
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs < fromMs) return 0;
248
+ return Math.floor((toMs - fromMs) / DAY_MS);
249
+ };
250
+
251
+ /**
252
+ * Calcula a maior sequência de dias consecutivos com atividade.
253
+ * @param {string[]} days Dias ativos ordenados no formato `YYYY-MM-DD`.
254
+ * @returns {number} Melhor sequência contínua em dias.
255
+ */
256
+ const computeStreak = (days) => {
257
+ if (!days.length) return 0;
258
+ let best = 1;
259
+ let current = 1;
260
+ let prev = new Date(`${days[0]}T00:00:00Z`).getTime();
261
+ for (let i = 1; i < days.length; i += 1) {
262
+ const currentDay = new Date(`${days[i]}T00:00:00Z`).getTime();
263
+ const diff = currentDay - prev;
264
+ if (diff === DAY_MS) {
265
+ current += 1;
266
+ } else {
267
+ current = 1;
268
+ }
269
+ if (current > best) best = current;
270
+ prev = currentDay;
271
+ }
272
+ return best;
273
+ };
274
+
275
+ /**
276
+ * Consolida métricas globais de atividade do usuário para o perfil.
277
+ * @param {{ canonicalId: string | null, totalMessages?: number, firstMessage?: string | Date | null, lastMessage?: string | Date | null }} params Dados base do usuário.
278
+ * @returns {Promise<{ activeDays: number, avgPerDay: string, streakDays: number, favoriteType: string | null, favoriteCount: number }>} Indicadores de frequência e tipo favorito.
279
+ */
280
+ const fetchUserGlobalRankingInsights = async ({ canonicalId, totalMessages = 0, firstMessage = null, lastMessage = null }) => {
281
+ if (!canonicalId) {
282
+ return {
283
+ activeDays: 0,
284
+ avgPerDay: '0.00',
285
+ streakDays: 0,
286
+ favoriteType: null,
287
+ favoriteCount: 0,
288
+ };
289
+ }
290
+
291
+ const daysRows = await executeQuery(
292
+ `SELECT DISTINCT DATE(ts) AS day
293
+ FROM (
294
+ SELECT ${TIMESTAMP_TO_DATETIME_SQL} AS ts
295
+ FROM ${TABLES.MESSAGES} m
296
+ LEFT JOIN ${TABLES.LID_MAP} lm
297
+ ON lm.lid = m.sender_id
298
+ AND lm.jid IS NOT NULL
299
+ WHERE m.sender_id IS NOT NULL
300
+ AND COALESCE(lm.jid, m.sender_id) = ?
301
+ AND m.timestamp IS NOT NULL
302
+ ) d
303
+ WHERE d.ts IS NOT NULL
304
+ ORDER BY day ASC`,
305
+ [canonicalId],
306
+ );
307
+ const days = (daysRows || []).map((item) => item.day).filter(Boolean);
308
+ const activeDays = days.length;
309
+ const streakDays = computeStreak(days);
310
+
311
+ const firstMs = toMillis(firstMessage);
312
+ const lastMs = toMillis(lastMessage);
313
+ let avgPerDay = '0.00';
314
+ if (Number(totalMessages) > 0 && firstMs !== null && lastMs !== null) {
315
+ const rangeDays = Math.max(1, Math.ceil((lastMs - firstMs) / DAY_MS) + 1);
316
+ avgPerDay = (Number(totalMessages) / rangeDays).toFixed(2);
317
+ }
318
+
319
+ const [favRow] = await executeQuery(
320
+ `SELECT
321
+ ${MESSAGE_TYPE_SQL} AS message_type,
322
+ COUNT(*) AS total
323
+ FROM ${TABLES.MESSAGES} m
324
+ LEFT JOIN ${TABLES.LID_MAP} lm
325
+ ON lm.lid = m.sender_id
326
+ AND lm.jid IS NOT NULL
327
+ WHERE m.sender_id IS NOT NULL
328
+ AND COALESCE(lm.jid, m.sender_id) = ?
329
+ AND m.raw_message IS NOT NULL
330
+ GROUP BY message_type
331
+ ORDER BY total DESC
332
+ LIMIT 1`,
333
+ [canonicalId],
334
+ );
335
+
336
+ return {
337
+ activeDays,
338
+ avgPerDay,
339
+ streakDays,
340
+ favoriteType: favRow?.message_type || null,
341
+ favoriteCount: Number(favRow?.total || 0),
342
+ };
343
+ };
344
+
345
+ /**
346
+ * Compara volume de mensagens dos últimos 30 dias com os 30 dias anteriores.
347
+ * @param {string|null} canonicalId ID canônico do usuário.
348
+ * @returns {Promise<{ last30: number, prev30: number, delta: number, trendLabel: 'subiu'|'caiu'|'estável' }>} Resultado da tendência.
349
+ */
350
+ const fetchUserTrendInsights = async (canonicalId) => {
351
+ if (!canonicalId) return { last30: 0, prev30: 0, delta: 0, trendLabel: 'estável' };
352
+
353
+ const [row] = await executeQuery(
354
+ `SELECT
355
+ SUM(CASE WHEN m.timestamp >= NOW() - INTERVAL 30 DAY THEN 1 ELSE 0 END) AS last30,
356
+ SUM(
357
+ CASE
358
+ WHEN m.timestamp < NOW() - INTERVAL 30 DAY
359
+ AND m.timestamp >= NOW() - INTERVAL 60 DAY
360
+ THEN 1
361
+ ELSE 0
362
+ END
363
+ ) AS prev30
364
+ FROM ${TABLES.MESSAGES} m
365
+ LEFT JOIN ${TABLES.LID_MAP} lm
366
+ ON lm.lid = m.sender_id
367
+ AND lm.jid IS NOT NULL
368
+ WHERE m.sender_id IS NOT NULL
369
+ AND m.timestamp IS NOT NULL
370
+ AND COALESCE(lm.jid, m.sender_id) = ?`,
371
+ [canonicalId],
372
+ );
373
+
374
+ const last30 = Number(row?.last30 || 0);
375
+ const prev30 = Number(row?.prev30 || 0);
376
+ const delta = last30 - prev30;
377
+ const trendLabel = delta > 0 ? 'subiu' : delta < 0 ? 'caiu' : 'estável';
378
+ return { last30, prev30, delta, trendLabel };
379
+ };
380
+
381
+ /**
382
+ * Traduz a hora do dia para uma faixa textual.
383
+ * @param {number|string|null} hour Hora em formato 0-23.
384
+ * @returns {string} Faixa horária (`madrugada`, `manhã`, `tarde`, `noite` ou `N/D`).
385
+ */
386
+ const getHourBand = (hour) => {
387
+ const h = Number(hour);
388
+ if (!Number.isFinite(h) || h < 0 || h > 23) return 'N/D';
389
+ if (h < 6) return 'madrugada';
390
+ if (h < 12) return 'manhã';
391
+ if (h < 18) return 'tarde';
392
+ return 'noite';
393
+ };
394
+
395
+ /**
396
+ * Obtém o horário de maior atividade do usuário.
397
+ * @param {string|null} canonicalId ID canônico do usuário.
398
+ * @returns {Promise<{ activeHour: number|null, hourBand: string, count: number }>} Hora mais ativa e total de mensagens na faixa.
399
+ */
400
+ const fetchUserActiveHourInsights = async (canonicalId) => {
401
+ if (!canonicalId) return { activeHour: null, hourBand: 'N/D', count: 0 };
402
+ const [row] = await executeQuery(
403
+ `SELECT HOUR(m.timestamp) AS active_hour,
404
+ COUNT(*) AS total
405
+ FROM ${TABLES.MESSAGES} m
406
+ LEFT JOIN ${TABLES.LID_MAP} lm
407
+ ON lm.lid = m.sender_id
408
+ AND lm.jid IS NOT NULL
409
+ WHERE m.sender_id IS NOT NULL
410
+ AND m.timestamp IS NOT NULL
411
+ AND COALESCE(lm.jid, m.sender_id) = ?
412
+ GROUP BY HOUR(m.timestamp)
413
+ ORDER BY total DESC
414
+ LIMIT 1`,
415
+ [canonicalId],
416
+ );
417
+
418
+ const activeHour = row?.active_hour ?? null;
419
+ return {
420
+ activeHour,
421
+ hourBand: getHourBand(activeHour),
422
+ count: Number(row?.total || 0),
423
+ };
424
+ };
425
+
426
+ /**
427
+ * Identifica o tipo de mensagem dominante no período atual e no período anterior.
428
+ * @param {string|null} canonicalId ID canônico do usuário.
429
+ * @returns {Promise<{ last30: { type: string|null, count: number }, prev30: { type: string|null, count: number } }>} Tipos dominantes por janela.
430
+ */
431
+ const fetchDominantTypeByPeriod = async (canonicalId) => {
432
+ if (!canonicalId) {
433
+ return {
434
+ last30: { type: null, count: 0 },
435
+ prev30: { type: null, count: 0 },
436
+ };
437
+ }
438
+
439
+ const rows = await executeQuery(
440
+ `SELECT period, message_type, total
441
+ FROM (
442
+ SELECT
443
+ CASE
444
+ WHEN m.timestamp >= NOW() - INTERVAL 30 DAY THEN 'last30'
445
+ ELSE 'prev30'
446
+ END AS period,
447
+ ${MESSAGE_TYPE_SQL} AS message_type,
448
+ COUNT(*) AS total
449
+ FROM ${TABLES.MESSAGES} m
450
+ LEFT JOIN ${TABLES.LID_MAP} lm
451
+ ON lm.lid = m.sender_id
452
+ AND lm.jid IS NOT NULL
453
+ WHERE m.sender_id IS NOT NULL
454
+ AND m.raw_message IS NOT NULL
455
+ AND m.timestamp IS NOT NULL
456
+ AND m.timestamp >= NOW() - INTERVAL 60 DAY
457
+ AND COALESCE(lm.jid, m.sender_id) = ?
458
+ GROUP BY period, message_type
459
+ ) t
460
+ ORDER BY period, total DESC`,
461
+ [canonicalId],
462
+ );
463
+
464
+ const result = {
465
+ last30: { type: null, count: 0 },
466
+ prev30: { type: null, count: 0 },
467
+ };
468
+ (rows || []).forEach((row) => {
469
+ const period = row?.period;
470
+ if (!period || !result[period]) return;
471
+ if (result[period].type) return;
472
+ result[period] = {
473
+ type: row?.message_type || null,
474
+ count: Number(row?.total || 0),
475
+ };
476
+ });
477
+
478
+ return result;
479
+ };
480
+
481
+ /**
482
+ * Calcula posição do usuário no ranking global por volume de mensagens.
483
+ * @param {string|null} canonicalId ID canônico do usuário.
484
+ * @returns {Promise<{ position: number|null, totalRankedUsers: number, totalMessages: number }>} Posição no ranking e totais associados.
485
+ */
486
+ const fetchUserRanking = async (canonicalId) => {
487
+ if (!canonicalId) {
488
+ return { position: null, totalRankedUsers: 0, totalMessages: 0 };
489
+ }
490
+
491
+ const [totalRow] = await executeQuery(
492
+ `SELECT COUNT(*) AS total_messages
493
+ FROM ${TABLES.MESSAGES} m
494
+ LEFT JOIN ${TABLES.LID_MAP} lm ON lm.lid = m.sender_id
495
+ WHERE m.sender_id IS NOT NULL
496
+ AND COALESCE(lm.jid, m.sender_id) = ?`,
497
+ [canonicalId],
498
+ );
499
+ const totalMessages = Number(totalRow?.total_messages || 0);
500
+
501
+ const [rankedUsersRow] = await executeQuery(
502
+ `SELECT COUNT(*) AS total_ranked_users
503
+ FROM (
504
+ SELECT COALESCE(lm.jid, m.sender_id) AS canonical_id
505
+ FROM ${TABLES.MESSAGES} m
506
+ LEFT JOIN ${TABLES.LID_MAP} lm ON lm.lid = m.sender_id
507
+ WHERE m.sender_id IS NOT NULL
508
+ GROUP BY COALESCE(lm.jid, m.sender_id)
509
+ ) ranked_users`,
510
+ );
511
+ const totalRankedUsers = Number(rankedUsersRow?.total_ranked_users || 0);
512
+
513
+ if (totalMessages <= 0) {
514
+ return { position: null, totalRankedUsers, totalMessages };
515
+ }
516
+
517
+ const [rankRow] = await executeQuery(
518
+ `SELECT COUNT(*) + 1 AS rank_position
519
+ FROM (
520
+ SELECT COALESCE(lm.jid, m.sender_id) AS canonical_id,
521
+ COUNT(*) AS total_messages
522
+ FROM ${TABLES.MESSAGES} m
523
+ LEFT JOIN ${TABLES.LID_MAP} lm ON lm.lid = m.sender_id
524
+ WHERE m.sender_id IS NOT NULL
525
+ GROUP BY COALESCE(lm.jid, m.sender_id)
526
+ ) ranked
527
+ WHERE ranked.total_messages > ?`,
528
+ [totalMessages],
529
+ );
530
+
531
+ return {
532
+ position: Number.isFinite(Number(rankRow?.rank_position)) ? Number(rankRow.rank_position) : null,
533
+ totalRankedUsers,
534
+ totalMessages,
535
+ };
536
+ };
537
+
538
+ /**
539
+ * Busca o `pushName` mais recente entre um conjunto de IDs equivalentes.
540
+ * @param {string[]} senderIds IDs usados nas mensagens salvas.
541
+ * @returns {Promise<string|null>} Nome exibido mais recente, quando disponível.
542
+ */
543
+ const fetchLatestPushName = async (senderIds) => {
544
+ if (!senderIds.length) return null;
545
+ const inClause = buildInClause(senderIds);
546
+ const [row] = await executeQuery(
547
+ `SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_message, '$.pushName')) AS push_name
548
+ FROM ${TABLES.MESSAGES}
549
+ WHERE sender_id IN (${inClause})
550
+ AND raw_message IS NOT NULL
551
+ AND JSON_EXTRACT(raw_message, '$.pushName') IS NOT NULL
552
+ ORDER BY id DESC
553
+ LIMIT 1`,
554
+ senderIds,
555
+ );
556
+ return row?.push_name || null;
557
+ };
558
+
559
+ /**
560
+ * Tenta resolver o nome de exibição do contato a partir do cache de contatos do socket.
561
+ * @param {object} sock Instância do socket Baileys.
562
+ * @param {string[]} ids Lista de IDs candidatos.
563
+ * @returns {string|null} Nome encontrado ou `null`.
564
+ */
565
+ const resolveNameFromContacts = (sock, ids) => {
566
+ for (const id of ids) {
567
+ const contact = sock?.contacts?.[id];
568
+ const name = contact?.notify || contact?.name || contact?.short || null;
569
+ if (name) return name;
570
+ }
571
+ return null;
572
+ };
573
+
574
+ /**
575
+ * Busca o `pushName` mais recente para um ID canônico específico.
576
+ * @param {string|null} canonicalId ID canônico alvo.
577
+ * @returns {Promise<string|null>} Nome mais recente registrado nas mensagens.
578
+ */
579
+ const fetchCanonicalPushName = async (canonicalId) => {
580
+ if (!canonicalId) return null;
581
+ const [row] = await executeQuery(
582
+ `SELECT JSON_UNQUOTE(JSON_EXTRACT(m.raw_message, '$.pushName')) AS push_name
583
+ FROM ${TABLES.MESSAGES} m
584
+ LEFT JOIN ${TABLES.LID_MAP} lm
585
+ ON lm.lid = m.sender_id
586
+ AND lm.jid IS NOT NULL
587
+ WHERE m.sender_id IS NOT NULL
588
+ AND COALESCE(lm.jid, m.sender_id) = ?
589
+ AND m.raw_message IS NOT NULL
590
+ AND JSON_EXTRACT(m.raw_message, '$.pushName') IS NOT NULL
591
+ ORDER BY m.id DESC
592
+ LIMIT 1`,
593
+ [canonicalId],
594
+ );
595
+ return row?.push_name || null;
596
+ };
597
+
598
+ /**
599
+ * Monta a base SQL reutilizável para análises de interação social.
600
+ * @param {string} selectSql Trecho `SELECT ...` que será aplicado sobre a CTE `base`.
601
+ * @returns {string} Query SQL final.
602
+ */
603
+ const buildSocialBaseQuery = (selectSql) => `
604
+ WITH base AS (
605
+ SELECT
606
+ COALESCE(src_map.jid, m.sender_id) AS src,
607
+ COALESCE(dst_map.jid, ${SOCIAL_DST_EXPR}) AS dst
608
+ FROM ${TABLES.MESSAGES} m
609
+ LEFT JOIN ${TABLES.LID_MAP} src_map
610
+ ON src_map.lid = m.sender_id
611
+ AND src_map.jid IS NOT NULL
612
+ LEFT JOIN ${TABLES.LID_MAP} dst_map
613
+ ON dst_map.lid = ${SOCIAL_DST_EXPR}
614
+ AND dst_map.jid IS NOT NULL
615
+ WHERE m.raw_message IS NOT NULL
616
+ AND m.sender_id IS NOT NULL
617
+ AND m.timestamp IS NOT NULL
618
+ AND m.timestamp >= NOW() - INTERVAL ${SOCIAL_RECENT_DAYS} DAY
619
+ AND ${SOCIAL_DST_EXPR} IS NOT NULL
620
+ AND ${SOCIAL_DST_EXPR} <> ''
621
+ AND COALESCE(src_map.jid, m.sender_id) <> COALESCE(dst_map.jid, ${SOCIAL_DST_EXPR})
622
+ )
623
+ ${selectSql}
624
+ `;
625
+
626
+ /**
627
+ * Calcula métricas sociais do usuário (envio/recebimento de respostas e parceiros).
628
+ * @param {{ canonicalId: string | null, sock: object }} params Parâmetros de consulta.
629
+ * @returns {Promise<{
630
+ * repliesSent: number,
631
+ * repliesReceived: number,
632
+ * socialScore: number,
633
+ * uniquePartners: number,
634
+ * topPartnerId: string|null,
635
+ * topPartnerCount: number,
636
+ * topPartnerLabel: string,
637
+ * responseRatePercent: string,
638
+ * responseRatio: string,
639
+ * topPartners: Array<{ id: string|null, count: number, label: string }>
640
+ * }>} Métricas sociais agregadas.
641
+ */
642
+ const fetchUserSocialInsights = async ({ canonicalId, sock }) => {
643
+ if (!canonicalId) {
644
+ return {
645
+ repliesSent: 0,
646
+ repliesReceived: 0,
647
+ socialScore: 0,
648
+ uniquePartners: 0,
649
+ topPartnerId: null,
650
+ topPartnerCount: 0,
651
+ topPartnerLabel: 'N/D',
652
+ responseRatePercent: '0.00%',
653
+ responseRatio: '0/0',
654
+ topPartners: [],
655
+ };
656
+ }
657
+
658
+ const [summaryRow] = await executeQuery(
659
+ buildSocialBaseQuery(
660
+ `SELECT
661
+ SUM(CASE WHEN src = ? THEN 1 ELSE 0 END) AS replies_sent,
662
+ SUM(CASE WHEN dst = ? THEN 1 ELSE 0 END) AS replies_received,
663
+ COUNT(DISTINCT CASE
664
+ WHEN src = ? THEN dst
665
+ WHEN dst = ? THEN src
666
+ ELSE NULL
667
+ END) AS unique_partners
668
+ FROM base
669
+ WHERE src = ? OR dst = ?`,
670
+ ),
671
+ [canonicalId, canonicalId, canonicalId, canonicalId, canonicalId, canonicalId],
672
+ );
673
+
674
+ const topPartnerRows = await executeQuery(
675
+ buildSocialBaseQuery(
676
+ `SELECT
677
+ CASE WHEN src = ? THEN dst ELSE src END AS partner_id,
678
+ COUNT(*) AS total
679
+ FROM base
680
+ WHERE src = ? OR dst = ?
681
+ GROUP BY partner_id
682
+ ORDER BY total DESC
683
+ LIMIT 3`,
684
+ ),
685
+ [canonicalId, canonicalId, canonicalId],
686
+ );
687
+
688
+ const repliesSent = Number(summaryRow?.replies_sent || 0);
689
+ const repliesReceived = Number(summaryRow?.replies_received || 0);
690
+ const uniquePartners = Number(summaryRow?.unique_partners || 0);
691
+ const topPartners = await Promise.all(
692
+ (topPartnerRows || []).map(async (row) => {
693
+ const id = row?.partner_id || null;
694
+ const count = Number(row?.total || 0);
695
+ const mention = id && getJidUser(id) ? `@${getJidUser(id)}` : null;
696
+ const fromContacts = resolveNameFromContacts(sock, id ? [id] : []);
697
+ const pushName = id ? await fetchCanonicalPushName(id) : null;
698
+ const label = fromContacts || pushName || mention || id || 'N/D';
699
+ return { id, count, label };
700
+ }),
701
+ );
702
+ const topPartner = topPartners[0] || null;
703
+ const topPartnerId = topPartner?.id || null;
704
+ const topPartnerCount = Number(topPartner?.count || 0);
705
+ const topPartnerLabel = topPartner?.label || 'N/D';
706
+ const totalSocial = repliesSent + repliesReceived;
707
+ const responseRatePercent = totalSocial > 0 ? `${((repliesSent / totalSocial) * 100).toFixed(2)}%` : '0.00%';
708
+ const responseRatio = `${repliesSent}/${repliesReceived}`;
709
+
710
+ return {
711
+ repliesSent,
712
+ repliesReceived,
713
+ socialScore: repliesSent + repliesReceived,
714
+ uniquePartners,
715
+ topPartnerId,
716
+ topPartnerCount,
717
+ topPartnerLabel,
718
+ responseRatePercent,
719
+ responseRatio,
720
+ topPartners,
721
+ };
722
+ };
723
+
724
+ /**
725
+ * Retorna os grupos onde o usuário mais fala.
726
+ * @param {string|null} canonicalId ID canônico do usuário.
727
+ * @returns {Promise<Array<{ chatId: string|null, subject: string|null, total: number }>>} Top grupos por volume.
728
+ */
729
+ const fetchTopGroupsInsights = async (canonicalId) => {
730
+ if (!canonicalId) return [];
731
+ const rows = await executeQuery(
732
+ `SELECT
733
+ m.chat_id,
734
+ COALESCE(gm.subject, '') AS group_subject,
735
+ COUNT(*) AS total
736
+ FROM ${TABLES.MESSAGES} m
737
+ LEFT JOIN ${TABLES.LID_MAP} lm
738
+ ON lm.lid = m.sender_id
739
+ AND lm.jid IS NOT NULL
740
+ LEFT JOIN ${TABLES.GROUPS_METADATA} gm
741
+ ON gm.id = m.chat_id
742
+ WHERE m.sender_id IS NOT NULL
743
+ AND m.chat_id LIKE '%@g.us'
744
+ AND COALESCE(lm.jid, m.sender_id) = ?
745
+ GROUP BY m.chat_id, gm.subject
746
+ ORDER BY total DESC
747
+ LIMIT 3`,
748
+ [canonicalId],
749
+ );
750
+ return (rows || []).map((row) => ({
751
+ chatId: row?.chat_id || null,
752
+ subject: row?.group_subject ? String(row.group_subject).trim() : null,
753
+ total: Number(row?.total || 0),
754
+ }));
755
+ };
756
+
757
+ /**
758
+ * Calcula participação proporcional do usuário no global e no grupo atual.
759
+ * @param {{ canonicalId: string | null, totalMessages: number, remoteJid: string, isGroupMessage: boolean }} params Contexto da conversa e totais.
760
+ * @returns {Promise<{ globalTotal: number, globalShare: string, groupTotal: number, groupUserTotal: number, groupShare: string }>} Métricas de participação.
761
+ */
762
+ const fetchParticipationInsights = async ({ canonicalId, totalMessages, remoteJid, isGroupMessage }) => {
763
+ const [globalRow] = await executeQuery(
764
+ `SELECT COUNT(*) AS total
765
+ FROM ${TABLES.MESSAGES}
766
+ WHERE sender_id IS NOT NULL`,
767
+ );
768
+ const globalTotal = Number(globalRow?.total || 0);
769
+
770
+ const globalShare = formatPercent(totalMessages, globalTotal);
771
+
772
+ if (!isGroupMessage || !remoteJid || !canonicalId) {
773
+ return {
774
+ globalTotal,
775
+ globalShare,
776
+ groupTotal: 0,
777
+ groupUserTotal: 0,
778
+ groupShare: 'N/D',
779
+ };
780
+ }
781
+
782
+ const [groupTotalsRow, groupUserRow] = await Promise.all([
783
+ executeQuery(
784
+ `SELECT COUNT(*) AS total
785
+ FROM ${TABLES.MESSAGES}
786
+ WHERE sender_id IS NOT NULL
787
+ AND chat_id = ?`,
788
+ [remoteJid],
789
+ ),
790
+ executeQuery(
791
+ `SELECT COUNT(*) AS total
792
+ FROM ${TABLES.MESSAGES} m
793
+ LEFT JOIN ${TABLES.LID_MAP} lm
794
+ ON lm.lid = m.sender_id
795
+ AND lm.jid IS NOT NULL
796
+ WHERE m.sender_id IS NOT NULL
797
+ AND m.chat_id = ?
798
+ AND COALESCE(lm.jid, m.sender_id) = ?`,
799
+ [remoteJid, canonicalId],
800
+ ),
801
+ ]);
802
+
803
+ const groupTotal = Number(groupTotalsRow?.[0]?.total || 0);
804
+ const groupUserTotal = Number(groupUserRow?.[0]?.total || 0);
805
+ const groupShare = groupTotal > 0 ? formatPercent(groupUserTotal, groupTotal) : '0.00%';
806
+
807
+ return {
808
+ globalTotal,
809
+ globalShare,
810
+ groupTotal,
811
+ groupUserTotal,
812
+ groupShare,
813
+ };
814
+ };
815
+
816
+ /**
817
+ * Formata JID para telefone em padrão internacional simples.
818
+ * @param {string|null} jid JID do usuário.
819
+ * @returns {string} Telefone formatado ou `N/D`.
820
+ */
821
+ const formatPhone = (jid) => {
822
+ const user = getJidUser(jid);
823
+ if (!user) return 'N/D';
824
+ const digits = user.replace(/\D/g, '');
825
+ return digits ? `+${digits}` : user;
826
+ };
827
+
828
+ /**
829
+ * Formata data/hora no padrão pt-BR com timezone de São Paulo.
830
+ * @param {string|Date|null} value Valor de data para formatação.
831
+ * @returns {string} Data formatada ou texto padrão quando indisponível.
832
+ */
833
+ const formatDateTime = (value) => {
834
+ if (!value) return 'Sem registros';
835
+ const date = value instanceof Date ? value : new Date(value);
836
+ if (Number.isNaN(date.getTime())) return 'Sem registros';
837
+ return new Intl.DateTimeFormat('pt-BR', {
838
+ dateStyle: 'short',
839
+ timeStyle: 'medium',
840
+ timeZone: 'America/Sao_Paulo',
841
+ }).format(date);
842
+ };
843
+
844
+ /**
845
+ * Verifica se houve interação dentro da janela de atividade configurada.
846
+ * @param {string|Date|null} lastMessage Última mensagem registrada.
847
+ * @returns {boolean} `true` quando a última interação está dentro da janela ativa.
848
+ */
849
+ const hasRecentInteraction = (lastMessage) => {
850
+ if (!lastMessage) return false;
851
+ const parsed = lastMessage instanceof Date ? lastMessage.getTime() : new Date(lastMessage).getTime();
852
+ if (!Number.isFinite(parsed)) return false;
853
+ const maxAgeMs = ACTIVE_DAYS_WINDOW * 24 * 60 * 60 * 1000;
854
+ return Date.now() - parsed <= maxAgeMs;
855
+ };
856
+
857
+ /**
858
+ * Consulta se algum dos IDs do usuário está bloqueado no WhatsApp.
859
+ * @param {object} sock Instância do socket Baileys.
860
+ * @param {string[]} targetIds IDs que representam o usuário alvo.
861
+ * @returns {Promise<boolean>} `true` quando o alvo consta na blocklist.
862
+ */
863
+ const isTargetBlocked = async (sock, targetIds) => {
864
+ if (!sock || typeof sock.fetchBlocklist !== 'function') return false;
865
+ try {
866
+ const blocklist = await sock.fetchBlocklist();
867
+ if (!Array.isArray(blocklist) || blocklist.length === 0) return false;
868
+ const normalizedBlocked = new Set(blocklist.map((jid) => normalizeJid(jid) || jid).filter(Boolean));
869
+ return targetIds.some((id) => normalizedBlocked.has(normalizeJid(id) || id));
870
+ } catch (error) {
871
+ logger.warn('Falha ao consultar blocklist no comando user perfil.', { error: error.message });
872
+ return false;
873
+ }
874
+ };
875
+
876
+ /**
877
+ * Converte a primeira mensagem em tempo de casa no bot (em dias).
878
+ * @param {string|Date|null} firstMessage Primeira mensagem registrada.
879
+ * @returns {string} Tempo de casa formatado.
880
+ */
881
+ const formatTempoDeCasa = (firstMessage) => {
882
+ const firstMs = toMillis(firstMessage);
883
+ if (!Number.isFinite(firstMs)) return 'N/D';
884
+ const days = toIntegerDays(firstMs, Date.now());
885
+ return `${days} dia(s)`;
886
+ };
887
+
888
+ /**
889
+ * Calcula quantos dias o usuário está sem enviar mensagens.
890
+ * @param {string|Date|null} lastMessage Última mensagem registrada.
891
+ * @returns {string} Quantidade de dias sem falar.
892
+ */
893
+ const formatDaysSinceLastMessage = (lastMessage) => {
894
+ const lastMs = toMillis(lastMessage);
895
+ if (!Number.isFinite(lastMs)) return 'N/D';
896
+ return `${toIntegerDays(lastMs, Date.now())} dia(s)`;
897
+ };
898
+
899
+ /**
900
+ * Formata o resumo da tendência de mensagens dos últimos períodos.
901
+ * @param {{ trendLabel: string, delta: number, last30: number, prev30: number }} trend Dados de tendência.
902
+ * @returns {string} Texto de tendência pronto para exibição.
903
+ */
904
+ const formatTrendLabel = ({ trendLabel, delta, last30, prev30 }) => {
905
+ const sign = delta > 0 ? '+' : '';
906
+ return `${trendLabel} (${sign}${delta} | 30d: ${last30} vs ant.: ${prev30})`;
907
+ };
908
+
909
+ /**
910
+ * Trunca labels longos preservando tamanho máximo com reticências.
911
+ * @param {string} value Texto original.
912
+ * @param {number} [max=30] Tamanho máximo permitido.
913
+ * @returns {string} Texto truncado quando necessário.
914
+ */
915
+ const truncateLabel = (value, max = 30) => {
916
+ const input = String(value || '');
917
+ if (input.length <= max) return input;
918
+ return `${input.slice(0, Math.max(0, max - 1))}…`;
919
+ };
920
+
921
+ /**
922
+ * Formata a saída do horário mais ativo do usuário.
923
+ * @param {{ hourBand: string, activeHour: number|null, count: number }} insights Dados de atividade por hora.
924
+ * @returns {string} Texto de horário mais ativo.
925
+ */
926
+ const formatActiveHourLabel = ({ hourBand, activeHour, count }) => {
927
+ if (!Number.isFinite(Number(activeHour))) return 'N/D';
928
+ return `${hourBand} (${String(activeHour).padStart(2, '0')}h, ${count} msg)`;
929
+ };
930
+
931
+ /**
932
+ * Formata os tipos de mensagem dominantes por janela temporal.
933
+ * @param {{ last30?: { type?: string|null, count?: number }, prev30?: { type?: string|null, count?: number } }} dominantByPeriod Resultado bruto da consulta.
934
+ * @returns {string} Texto com comparativo entre período atual e anterior.
935
+ */
936
+ const formatDominantTypeByPeriod = (dominantByPeriod) => {
937
+ const last30Type = dominantByPeriod?.last30?.type || 'N/D';
938
+ const last30Count = Number(dominantByPeriod?.last30?.count || 0);
939
+ const prev30Type = dominantByPeriod?.prev30?.type || 'N/D';
940
+ const prev30Count = Number(dominantByPeriod?.prev30?.count || 0);
941
+ return `30d: ${last30Type} (${last30Count}) | ant.: ${prev30Type} (${prev30Count})`;
942
+ };
943
+
944
+ /**
945
+ * Formata lista dos principais parceiros de interação em linhas.
946
+ * @param {Array<{ label: string, count: number }>} [topPartners=[]] Lista dos parceiros.
947
+ * @returns {string} Bloco multiline com ranking de parceiros.
948
+ */
949
+ const formatTopPartnersLine = (topPartners = []) => {
950
+ if (!Array.isArray(topPartners) || topPartners.length === 0) return ' N/D';
951
+ return topPartners
952
+ .slice(0, 3)
953
+ .map((entry, index) => ` ${index + 1}) ${truncateLabel(entry.label, 26)} (${entry.count})`)
954
+ .join('\n');
955
+ };
956
+
957
+ /**
958
+ * Formata lista dos grupos com maior volume de mensagens do usuário.
959
+ * @param {Array<{ subject?: string|null, chatId?: string|null, total: number }>} [topGroups=[]] Lista de grupos.
960
+ * @returns {string} Bloco multiline com ranking de grupos.
961
+ */
962
+ const formatTopGroupsLine = (topGroups = []) => {
963
+ if (!Array.isArray(topGroups) || topGroups.length === 0) return ' N/D';
964
+ return topGroups
965
+ .slice(0, 3)
966
+ .map((entry, index) => ` ${index + 1}) ${truncateLabel((entry.subject && entry.subject.trim()) || entry.chatId || 'grupo', 24)} (${entry.total})`)
967
+ .join('\n');
968
+ };
969
+
970
+ /**
971
+ * Insere linhas em branco entre itens para melhorar legibilidade.
972
+ * @param {string[]} [lines=[]] Linhas que serão espaçadas.
973
+ * @returns {string[]} Linhas com separação vertical.
974
+ */
975
+ const withVerticalSpacing = (lines = []) => lines.flatMap((line, index) => (index === lines.length - 1 ? [line] : [line, '']));
976
+
977
+ /**
978
+ * Constrói a mensagem final do perfil com seções e métricas organizadas.
979
+ * @param {object} data Dados agregados do usuário para renderização.
980
+ * @returns {string} Texto completo enviado no comando de perfil.
981
+ */
982
+ const buildProfileMessage = ({
983
+ mentionLabel,
984
+ displayName,
985
+ phone,
986
+ canonicalTarget,
987
+ status,
988
+ firstMessage,
989
+ tempoDeCasa,
990
+ lastInteraction,
991
+ diasSemFalar,
992
+ totalMessages,
993
+ rankingLabel,
994
+ trendLabel,
995
+ avgPerDay,
996
+ activeDays,
997
+ streakDays,
998
+ activeHourLabel,
999
+ favoriteTypeLabel,
1000
+ dominantTypeByPeriodLabel,
1001
+ socialScore,
1002
+ socialSent,
1003
+ socialReceived,
1004
+ responseRateLabel,
1005
+ socialPartners,
1006
+ topPartnerLabel,
1007
+ topPartnersLabel,
1008
+ topGroupsLabel,
1009
+ globalShareLabel,
1010
+ groupShareLabel,
1011
+ tags,
1012
+ }) =>
1013
+ [
1014
+ '👤 *PERFIL DO USUÁRIO*',
1015
+ '━━━━━━━━━━━━━━━━━━━━',
1016
+ '',
1017
+ '🧾 *Identificação*',
1018
+ ...withVerticalSpacing([
1019
+ `• Usuário: ${mentionLabel}`,
1020
+ `• Nome: ${displayName}`,
1021
+ `• Número: ${phone}`,
1022
+ `• ID: ${canonicalTarget || 'N/D'}`,
1023
+ `• Status: *${status}*`,
1024
+ ]),
1025
+ '',
1026
+ '📈 *Mensagens e Ranking*',
1027
+ ...withVerticalSpacing([
1028
+ `• Primeira mensagem: ${firstMessage}`,
1029
+ `• Tempo de casa no bot: ${tempoDeCasa}`,
1030
+ `• Última interação: ${lastInteraction}`,
1031
+ `• Dias sem falar: ${diasSemFalar}`,
1032
+ `• Mensagens gerais registradas: ${totalMessages}`,
1033
+ `• Participação global: ${globalShareLabel}`,
1034
+ `• Participação no grupo atual: ${groupShareLabel}`,
1035
+ `• Posição no ranking (mensagens): ${rankingLabel}`,
1036
+ `• Tendência de mensagens: ${trendLabel}`,
1037
+ `• Média/dia (global): ${avgPerDay}`,
1038
+ `• Dias ativos (global): ${activeDays}`,
1039
+ `• Streak (global): ${streakDays} dia(s)`,
1040
+ `• Horário mais ativo: ${activeHourLabel}`,
1041
+ `• Tipo favorito (global): ${favoriteTypeLabel}`,
1042
+ `• Tipo dominante por período: ${dominantTypeByPeriodLabel}`,
1043
+ ]),
1044
+ '',
1045
+ '🌐 *Interações Sociais*',
1046
+ ...withVerticalSpacing([
1047
+ `• Interações sociais (${SOCIAL_RECENT_DAYS}d): ${socialScore}`,
1048
+ `• Respostas enviadas (${SOCIAL_RECENT_DAYS}d): ${socialSent}`,
1049
+ `• Respostas recebidas (${SOCIAL_RECENT_DAYS}d): ${socialReceived}`,
1050
+ `• Taxa de resposta (${SOCIAL_RECENT_DAYS}d): ${responseRateLabel}`,
1051
+ `• Parceiros sociais (${SOCIAL_RECENT_DAYS}d): ${socialPartners}`,
1052
+ `• Parceiro principal (${SOCIAL_RECENT_DAYS}d): ${topPartnerLabel}`,
1053
+ `• Top 3 parceiros (${SOCIAL_RECENT_DAYS}d):\n${topPartnersLabel}`,
1054
+ ]),
1055
+ '',
1056
+ '🏘️ *Presença em Grupos*',
1057
+ ...withVerticalSpacing([`• Top grupos onde fala:\n${topGroupsLabel}`]),
1058
+ '',
1059
+ '🏷️ *Contexto*',
1060
+ ...withVerticalSpacing([`• Tags: ${tags.length ? tags.join(', ') : 'sem tags'}`]),
1061
+ ].join('\n');
1062
+
1063
+ /**
1064
+ * Seleciona o primeiro ID de usuário válido dentro de uma lista.
1065
+ * @param {string[]} [ids=[]] IDs candidatos.
1066
+ * @returns {string|null} Primeiro JID de usuário válido ou `null`.
1067
+ */
1068
+ const resolveMentionJid = (ids = []) => ids.find((id) => isWhatsAppUserId(id)) || null;
1069
+
1070
+ /**
1071
+ * Processa o comando `user perfil`, resolve o alvo e envia o resumo com métricas.
1072
+ * @param {object} params Parâmetros operacionais do comando.
1073
+ * @param {object} params.sock Instância do socket Baileys.
1074
+ * @param {string} params.remoteJid JID da conversa atual.
1075
+ * @param {object} params.messageInfo Mensagem original usada como contexto.
1076
+ * @param {number|undefined} params.expirationMessage Configuração de expiração de mensagem.
1077
+ * @param {string} params.senderJid JID de quem executou o comando.
1078
+ * @param {string[]} [params.args=[]] Argumentos recebidos após o comando.
1079
+ * @param {boolean} params.isGroupMessage Indica se o contexto é grupo.
1080
+ * @param {string} [params.commandPrefix=DEFAULT_COMMAND_PREFIX] Prefixo de comandos.
1081
+ * @returns {Promise<void>} Finaliza após responder ao usuário.
1082
+ */
1083
+ export async function handleUserCommand({ sock, remoteJid, messageInfo, expirationMessage, senderJid, args = [], isGroupMessage, commandPrefix = DEFAULT_COMMAND_PREFIX }) {
1084
+ const subcommand = args?.[0]?.toLowerCase() || '';
1085
+ if (subcommand !== 'perfil' && subcommand !== 'profile') {
1086
+ await sendAndStore(sock, remoteJid, { text: buildUsageText(commandPrefix) }, { quoted: messageInfo, ephemeralExpiration: expirationMessage });
1087
+ return;
1088
+ }
1089
+
1090
+ const explicitTargetArg = args.slice(1).join(' ').trim();
1091
+ const { source, invalidExplicitTarget } = resolveCandidateTarget(messageInfo, senderJid, explicitTargetArg);
1092
+ if (invalidExplicitTarget) {
1093
+ await sendAndStore(sock, remoteJid, { text: `❌ ID ou telefone inválido.\n\n${buildUsageText(commandPrefix)}` }, { quoted: messageInfo, ephemeralExpiration: expirationMessage });
1094
+ return;
1095
+ }
1096
+ if (!source) {
1097
+ await sendAndStore(sock, remoteJid, { text: buildUsageText(commandPrefix) }, { quoted: messageInfo, ephemeralExpiration: expirationMessage });
1098
+ return;
1099
+ }
1100
+
1101
+ try {
1102
+ const canonicalTarget = await resolveCanonicalTarget(source);
1103
+ const senderIds = await resolveSenderIdsForTarget(canonicalTarget);
1104
+ const normalizedTargetIds = Array.from(new Set([canonicalTarget, ...senderIds].map((value) => normalizeJid(value) || value).filter(Boolean)));
1105
+ const mentionJid = resolveMentionJid(normalizedTargetIds);
1106
+ const senderCanonical = resolveUserIdCached({ jid: senderJid, lid: senderJid, participantAlt: null });
1107
+ const rankingTargetId = mentionJid || canonicalTarget;
1108
+
1109
+ const [stats, ranking, latestPushName, premiumUsers, blocked, groupAdmin] = await Promise.all([
1110
+ fetchUserStats({ canonicalId: rankingTargetId, senderIds: normalizedTargetIds }),
1111
+ fetchUserRanking(rankingTargetId),
1112
+ fetchLatestPushName(normalizedTargetIds),
1113
+ premiumUserStore.getPremiumUsers(),
1114
+ isTargetBlocked(sock, normalizedTargetIds),
1115
+ isGroupMessage ? isUserAdmin(remoteJid, mentionJid || canonicalTarget) : Promise.resolve(false),
1116
+ ]);
1117
+ const [globalInsights, socialInsights, trendInsights, activeHourInsights, dominantTypeByPeriod, topGroups, participationInsights] = await Promise.all([
1118
+ fetchUserGlobalRankingInsights({
1119
+ canonicalId: rankingTargetId,
1120
+ totalMessages: stats.totalMessages,
1121
+ firstMessage: stats.firstMessage,
1122
+ lastMessage: stats.lastMessage,
1123
+ }),
1124
+ fetchUserSocialInsights({
1125
+ canonicalId: rankingTargetId,
1126
+ sock,
1127
+ }),
1128
+ fetchUserTrendInsights(rankingTargetId),
1129
+ fetchUserActiveHourInsights(rankingTargetId),
1130
+ fetchDominantTypeByPeriod(rankingTargetId),
1131
+ fetchTopGroupsInsights(rankingTargetId),
1132
+ fetchParticipationInsights({
1133
+ canonicalId: rankingTargetId,
1134
+ totalMessages: stats.totalMessages,
1135
+ remoteJid,
1136
+ isGroupMessage,
1137
+ }),
1138
+ ]);
1139
+
1140
+ const premiumSet = new Set((premiumUsers || []).map((jid) => normalizeJid(jid) || jid));
1141
+ const isPremium = normalizedTargetIds.some((id) => premiumSet.has(id));
1142
+ const isOwner = OWNER_JID ? normalizedTargetIds.some((id) => id === OWNER_JID) : false;
1143
+ const recentInteraction = hasRecentInteraction(stats.lastMessage);
1144
+ const status = blocked ? 'bloqueado' : 'ativo';
1145
+ const mentionUser = getJidUser(mentionJid || canonicalTarget);
1146
+ const mentionLabel = mentionUser ? `@${mentionUser}` : canonicalTarget || 'Desconhecido';
1147
+ const nameFromContacts = resolveNameFromContacts(sock, normalizedTargetIds);
1148
+ const displayName = nameFromContacts || latestPushName || mentionLabel;
1149
+
1150
+ const tags = [];
1151
+ if (senderCanonical && canonicalTarget && senderCanonical === canonicalTarget) tags.push('você');
1152
+ if (isPremium) tags.push('premium');
1153
+ if (groupAdmin) tags.push('admin do grupo');
1154
+ if (isOwner) tags.push('owner');
1155
+ if (!recentInteraction && stats.totalMessages > 0) tags.push('inativo');
1156
+ if (stats.totalMessages === 0) tags.push('sem histórico');
1157
+ const rankingLabel = ranking.position && ranking.totalRankedUsers > 0 ? `#${ranking.position} de ${ranking.totalRankedUsers}` : 'fora do ranking (sem mensagens)';
1158
+ const favoriteTypeLabel = globalInsights.favoriteType ? `${globalInsights.favoriteType} (${globalInsights.favoriteCount})` : 'N/D';
1159
+ const topPartnerLabel = socialInsights.topPartnerCount > 0 ? `${socialInsights.topPartnerLabel} (${socialInsights.topPartnerCount})` : 'N/D';
1160
+ const trendLabel = formatTrendLabel(trendInsights);
1161
+ const activeHourLabel = formatActiveHourLabel(activeHourInsights);
1162
+ const dominantTypeByPeriodLabel = formatDominantTypeByPeriod(dominantTypeByPeriod);
1163
+ const responseRateLabel = `${socialInsights.responseRatePercent} (${socialInsights.responseRatio})`;
1164
+ const topPartnersLabel = formatTopPartnersLine(socialInsights.topPartners);
1165
+ const topGroupsLabel = formatTopGroupsLine(topGroups);
1166
+ const groupShareLabel = isGroupMessage ? `${participationInsights.groupShare} (${participationInsights.groupUserTotal}/${participationInsights.groupTotal})` : 'N/D';
1167
+ const globalShareLabel = `${participationInsights.globalShare} (${stats.totalMessages}/${participationInsights.globalTotal})`;
1168
+
1169
+ const text = buildProfileMessage({
1170
+ mentionLabel,
1171
+ displayName,
1172
+ phone: formatPhone(canonicalTarget),
1173
+ canonicalTarget,
1174
+ status,
1175
+ firstMessage: formatDateTime(stats.firstMessage),
1176
+ tempoDeCasa: formatTempoDeCasa(stats.firstMessage),
1177
+ lastInteraction: formatDateTime(stats.lastMessage),
1178
+ diasSemFalar: formatDaysSinceLastMessage(stats.lastMessage),
1179
+ totalMessages: stats.totalMessages,
1180
+ globalShareLabel,
1181
+ groupShareLabel,
1182
+ rankingLabel,
1183
+ trendLabel,
1184
+ avgPerDay: globalInsights.avgPerDay,
1185
+ activeDays: globalInsights.activeDays,
1186
+ streakDays: globalInsights.streakDays,
1187
+ activeHourLabel,
1188
+ favoriteTypeLabel,
1189
+ dominantTypeByPeriodLabel,
1190
+ socialScore: socialInsights.socialScore,
1191
+ socialSent: socialInsights.repliesSent,
1192
+ socialReceived: socialInsights.repliesReceived,
1193
+ responseRateLabel,
1194
+ socialPartners: socialInsights.uniquePartners,
1195
+ topPartnerLabel,
1196
+ topPartnersLabel,
1197
+ topGroupsLabel,
1198
+ tags,
1199
+ });
1200
+
1201
+ const mentions = mentionJid ? [mentionJid] : [];
1202
+ const avatarJid = mentionJid;
1203
+ const profilePicBuffer = avatarJid
1204
+ ? await getProfilePicBuffer(sock, {
1205
+ key: {
1206
+ participant: avatarJid,
1207
+ remoteJid,
1208
+ },
1209
+ })
1210
+ : null;
1211
+
1212
+ await sendAndStore(sock, remoteJid, profilePicBuffer ? (mentions.length ? { image: profilePicBuffer, caption: text, mentions } : { image: profilePicBuffer, caption: text }) : mentions.length ? { text, mentions } : { text }, { quoted: messageInfo, ephemeralExpiration: expirationMessage });
1213
+ } catch (error) {
1214
+ logger.error('Erro ao processar comando user perfil.', { error: error.message });
1215
+ await sendAndStore(sock, remoteJid, { text: '❌ Não foi possível carregar o perfil do usuário agora.' }, { quoted: messageInfo, ephemeralExpiration: expirationMessage });
1216
+ }
1217
+ }