@omnizap-system/omnizap 2.5.12

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