@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,1305 @@
1
+ import { createCanvas, loadImage } from 'canvas';
2
+ import { executeQuery } from '../../../database/index.js';
3
+ import { getJidUser, getProfilePicBuffer, normalizeJid } from '../../config/index.js';
4
+ import { primeLidCache, resolveUserIdCached, isLidUserId, isWhatsAppUserId } from '../../config/index.js';
5
+
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ const PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
8
+ const PROFILE_CACHE_LIMIT = 2000;
9
+ const PROFILE_PIC_CACHE = globalThis.__omnizapProfilePicCache || new Map();
10
+ globalThis.__omnizapProfilePicCache = PROFILE_PIC_CACHE;
11
+ const RANKING_IMAGE_WIDTH = 1600;
12
+ const RANKING_IMAGE_HEIGHT = 900;
13
+ const RANKING_IMAGE_SCALE = 2;
14
+ const PROFILE_FETCH_TIMEOUT_MS = 4000;
15
+ const ELLIPSIS = '…';
16
+ const CANVAS_FONT_STACK = "'Noto Color Emoji', 'Segoe UI Emoji', 'Apple Color Emoji', 'Segoe UI Symbol', 'Noto Sans', 'DejaVu Sans', 'Arial Unicode MS', Arial, sans-serif";
17
+ const GRAPHEME_SEGMENTER = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' ? new Intl.Segmenter('pt-BR', { granularity: 'grapheme' }) : null;
18
+ const ZERO_WIDTH_UNICODE_REGEX = /(?:\u200B|\u200C|\u200D|\u2060|\uFE00|\uFE01|\uFE02|\uFE03|\uFE04|\uFE05|\uFE06|\uFE07|\uFE08|\uFE09|\uFE0A|\uFE0B|\uFE0C|\uFE0D|\uFE0E|\uFE0F)/gu;
19
+ const PRIVATE_USE_UNICODE_REGEX = /[\uE000-\uF8FF]/gu;
20
+ const EMOJI_AND_PICTO_REGEX = /[\u{1F000}-\u{1FAFF}\u2600-\u27BF]/gu;
21
+ const ASCII_PRINTABLE_REGEX = /^[\x20-\x7E]$/u;
22
+ const LATIN_CHAR_REGEX = /^\p{Script=Latin}$/u;
23
+ const NUMBER_CHAR_REGEX = /^\p{Number}$/u;
24
+ const MARK_CHAR_REGEX = /^\p{Mark}$/u;
25
+ let messageActivityDailyAvailable = null;
26
+
27
+ export const MESSAGE_TYPE_SQL = `
28
+ CASE
29
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.conversation') IS NOT NULL THEN 'texto'
30
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.extendedTextMessage') IS NOT NULL THEN 'texto'
31
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.imageMessage') IS NOT NULL THEN 'imagem'
32
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.videoMessage') IS NOT NULL THEN 'video'
33
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.audioMessage') IS NOT NULL THEN 'audio'
34
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.stickerMessage') IS NOT NULL THEN 'figurinha'
35
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.documentMessage') IS NOT NULL THEN 'documento'
36
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.locationMessage') IS NOT NULL THEN 'localizacao'
37
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.reactionMessage') IS NOT NULL THEN 'reacao'
38
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.pollCreationMessage') IS NOT NULL THEN 'enquete'
39
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.listMessage') IS NOT NULL THEN 'lista'
40
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.buttonsMessage') IS NOT NULL THEN 'botoes'
41
+ ELSE 'outros'
42
+ END
43
+ `;
44
+
45
+ export const TIMESTAMP_TO_DATETIME_SQL = `
46
+ CASE
47
+ WHEN m.timestamp IS NULL THEN NULL
48
+ WHEN CAST(m.timestamp AS CHAR) REGEXP '^[0-9]{13,}$' THEN FROM_UNIXTIME(CAST(m.timestamp AS DECIMAL(20,0)) / 1000)
49
+ WHEN CAST(m.timestamp AS CHAR) REGEXP '^[0-9]{10}$' THEN FROM_UNIXTIME(CAST(m.timestamp AS DECIMAL(20,0)))
50
+ ELSE CAST(m.timestamp AS DATETIME)
51
+ END
52
+ `;
53
+
54
+ /**
55
+ * Formata data para pt-BR (America/Sao_Paulo).
56
+ * @param {Date|string|number|null|undefined} value
57
+ * @returns {string}
58
+ */
59
+ export const formatDate = (value) => {
60
+ if (!value) return 'N/D';
61
+ const date = value instanceof Date ? value : new Date(value);
62
+ if (Number.isNaN(date.getTime())) return 'N/D';
63
+ return new Intl.DateTimeFormat('pt-BR', {
64
+ dateStyle: 'short',
65
+ timeStyle: 'medium',
66
+ timeZone: 'America/Sao_Paulo',
67
+ }).format(date);
68
+ };
69
+
70
+ /**
71
+ * Converte timestamp em ms (aceita segundos/ms/string).
72
+ * @param {string|number|null|undefined} value
73
+ * @returns {number|null}
74
+ */
75
+ export const toMillis = (value) => {
76
+ if (value === null || value === undefined) return null;
77
+ if (typeof value === 'number') {
78
+ if (value > 1e12) return value;
79
+ if (value > 1e9) return value * 1000;
80
+ return value;
81
+ }
82
+ const parsed = Date.parse(value);
83
+ return Number.isNaN(parsed) ? null : parsed;
84
+ };
85
+
86
+ /**
87
+ * Retorna o nome exibido (pushName) ou fallback.
88
+ * @param {string|null|undefined} pushName
89
+ * @param {string|null|undefined} mentionId
90
+ * @returns {string}
91
+ */
92
+ export const getDisplayName = (pushName, mentionId) => {
93
+ const mentionUser = getJidUser(mentionId);
94
+ const base = mentionUser ? `@${mentionUser}` : null;
95
+ if (pushName && typeof pushName === 'string' && pushName.trim() !== '') {
96
+ const clean = pushName.trim();
97
+ return base ? `${base} (${clean})` : clean;
98
+ }
99
+ return base || 'Desconhecido';
100
+ };
101
+
102
+ const getShortName = (row) => {
103
+ const displayName = toSafeCanvasDisplayName(row?.display_name);
104
+ if (displayName) return displayName;
105
+ const mentionUser = getJidUser(row?.mention_id || row?.sender_id);
106
+ return mentionUser ? toSafeCanvasText(`@${mentionUser}`) : 'Desconhecido';
107
+ };
108
+
109
+ const CANONICAL_SENDER_SQL = 'COALESCE(m.canonical_sender_id, m.sender_id)';
110
+ const LID_MAP_JOIN_SQL = '';
111
+
112
+ const resolveSenderIdsCanonical = (rawJid) => {
113
+ if (!rawJid) return { displayId: null, mentionId: null, key: null };
114
+ const canonical = resolveUserIdCached({ lid: rawJid, jid: rawJid, participantAlt: null });
115
+ const displayId = canonical || rawJid;
116
+ const mentionId = isWhatsAppUserId(canonical) ? canonical : null;
117
+ const key = canonical || rawJid;
118
+ return { displayId, mentionId, key };
119
+ };
120
+
121
+ const buildWhere = ({ scope, remoteJid, botJid, useCanonicalSender = false }) => {
122
+ const senderExpr = useCanonicalSender ? CANONICAL_SENDER_SQL : 'm.sender_id';
123
+ const joinSql = useCanonicalSender ? LID_MAP_JOIN_SQL : '';
124
+ const where = [`${senderExpr} IS NOT NULL`];
125
+ const params = [];
126
+ if (scope === 'group') {
127
+ where.push('m.chat_id = ?');
128
+ params.push(remoteJid);
129
+ }
130
+ if (botJid) {
131
+ const normalizedBotJid = normalizeJid(botJid) || botJid;
132
+ const botUser = getJidUser(normalizedBotJid);
133
+
134
+ // Exclui por JID exato (normalizado e bruto) e pelo usuário base
135
+ // para cobrir formatos como numero:dispositivo@s.whatsapp.net.
136
+ where.push(`${senderExpr} <> ?`);
137
+ params.push(normalizedBotJid);
138
+ if (botJid !== normalizedBotJid) {
139
+ where.push(`${senderExpr} <> ?`);
140
+ params.push(botJid);
141
+ }
142
+ if (botUser) {
143
+ where.push(`${senderExpr} NOT LIKE ?`);
144
+ params.push(`${botUser}@%`);
145
+ where.push(`${senderExpr} NOT LIKE ?`);
146
+ params.push(`${botUser}:%`);
147
+ }
148
+ }
149
+ return { where, params, senderExpr, joinSql };
150
+ };
151
+
152
+ const isMissingMessageActivityDailyError = (error) => {
153
+ const code = String(error?.code || '')
154
+ .trim()
155
+ .toUpperCase();
156
+ if (code === 'ER_NO_SUCH_TABLE') return true;
157
+ const errno = Number(error?.errno || 0);
158
+ if (errno === 1146) return true;
159
+ const message = String(error?.message || '').toLowerCase();
160
+ return message.includes('message_activity_daily') && message.includes("doesn't exist");
161
+ };
162
+
163
+ const canUseMessageActivityDaily = async () => {
164
+ if (messageActivityDailyAvailable !== null) return messageActivityDailyAvailable;
165
+ try {
166
+ await executeQuery('SELECT 1 FROM message_activity_daily LIMIT 1');
167
+ messageActivityDailyAvailable = true;
168
+ } catch (error) {
169
+ if (isMissingMessageActivityDailyError(error)) {
170
+ messageActivityDailyAvailable = false;
171
+ } else {
172
+ throw error;
173
+ }
174
+ }
175
+ return messageActivityDailyAvailable;
176
+ };
177
+
178
+ const getCachedProfilePic = (jid) => {
179
+ const entry = PROFILE_PIC_CACHE.get(jid);
180
+ if (!entry) return null;
181
+ const lastAccess = entry.lastAccess || entry.createdAt || 0;
182
+ if (Date.now() - lastAccess > PROFILE_CACHE_TTL_MS) {
183
+ PROFILE_PIC_CACHE.delete(jid);
184
+ return null;
185
+ }
186
+ entry.lastAccess = Date.now();
187
+ return entry.buffer || null;
188
+ };
189
+
190
+ const setCachedProfilePic = (jid, buffer) => {
191
+ if (!jid || !buffer) return;
192
+ PROFILE_PIC_CACHE.set(jid, { buffer, createdAt: Date.now(), lastAccess: Date.now() });
193
+ if (PROFILE_PIC_CACHE.size > PROFILE_CACHE_LIMIT) {
194
+ const oldestKey = Array.from(PROFILE_PIC_CACHE.entries()).sort((a, b) => (a[1].lastAccess || a[1].createdAt || 0) - (b[1].lastAccess || b[1].createdAt || 0))[0]?.[0];
195
+ if (oldestKey) PROFILE_PIC_CACHE.delete(oldestKey);
196
+ }
197
+ };
198
+
199
+ const fetchProfileBuffer = async (sock, jid, remoteJid) => {
200
+ const cached = getCachedProfilePic(jid);
201
+ if (cached) return cached;
202
+ const buffer = await Promise.race([
203
+ getProfilePicBuffer(sock, { key: { participant: jid, remoteJid } }),
204
+ new Promise((resolve) => {
205
+ setTimeout(() => resolve(null), PROFILE_FETCH_TIMEOUT_MS);
206
+ }),
207
+ ]);
208
+ if (buffer) setCachedProfilePic(jid, buffer);
209
+ return buffer;
210
+ };
211
+
212
+ const loadProfileImages = async ({ sock, jids, remoteJid, concurrency = 6 }) => {
213
+ const results = new Map();
214
+ if (!sock) return results;
215
+ const queue = Array.from(new Set((jids || []).filter(Boolean)));
216
+ let index = 0;
217
+
218
+ const worker = async () => {
219
+ while (index < queue.length) {
220
+ const jid = queue[index];
221
+ index += 1;
222
+ if (results.has(jid)) continue;
223
+ try {
224
+ const buffer = await fetchProfileBuffer(sock, jid, remoteJid);
225
+ if (!buffer) continue;
226
+ const image = await loadImage(buffer);
227
+ results.set(jid, image);
228
+ } catch {
229
+ // Ignora falhas de imagem
230
+ }
231
+ }
232
+ };
233
+
234
+ const workers = Array.from({ length: concurrency }, () => worker());
235
+ await Promise.all(workers);
236
+ return results;
237
+ };
238
+
239
+ const drawRoundedRect = (ctx, x, y, w, h, r) => {
240
+ const radius = Math.min(r, w / 2, h / 2);
241
+ ctx.beginPath();
242
+ ctx.moveTo(x + radius, y);
243
+ ctx.arcTo(x + w, y, x + w, y + h, radius);
244
+ ctx.arcTo(x + w, y + h, x, y + h, radius);
245
+ ctx.arcTo(x, y + h, x, y, radius);
246
+ ctx.arcTo(x, y, x + w, y, radius);
247
+ ctx.closePath();
248
+ };
249
+
250
+ const drawTrackedText = (ctx, text, x, y, tracking = 0) => {
251
+ const chars = splitGraphemes(text);
252
+ if (!chars.length) return 0;
253
+ let cursor = x;
254
+ chars.forEach((char) => {
255
+ ctx.fillText(char, cursor, y);
256
+ cursor += ctx.measureText(char).width + tracking;
257
+ });
258
+ return cursor - x;
259
+ };
260
+
261
+ const replaceControlCharsBySpace = (text) => {
262
+ let normalized = '';
263
+ for (const char of String(text || '')) {
264
+ const code = char.codePointAt(0) || 0;
265
+ if ((code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f)) {
266
+ normalized += ' ';
267
+ continue;
268
+ }
269
+ normalized += char;
270
+ }
271
+ return normalized;
272
+ };
273
+
274
+ const toSafeCanvasText = (value) => {
275
+ if (value === null || value === undefined) return '';
276
+ const normalized = String(value).normalize('NFKC').replace(/\r?\n/g, ' ');
277
+ return replaceControlCharsBySpace(normalized).replace(ZERO_WIDTH_UNICODE_REGEX, '').replace(PRIVATE_USE_UNICODE_REGEX, '').replace(EMOJI_AND_PICTO_REGEX, '').replace(/\s+/g, ' ').trim();
278
+ };
279
+
280
+ const toSafeCanvasDisplayName = (value) => {
281
+ const base = toSafeCanvasText(value);
282
+ if (!base) return '';
283
+ let safe = '';
284
+ for (const char of base) {
285
+ if (ASCII_PRINTABLE_REGEX.test(char) || LATIN_CHAR_REGEX.test(char) || NUMBER_CHAR_REGEX.test(char)) {
286
+ safe += char;
287
+ continue;
288
+ }
289
+ if (MARK_CHAR_REGEX.test(char) && safe) {
290
+ safe += char;
291
+ }
292
+ }
293
+ return safe.replace(/\s+/g, ' ').trim();
294
+ };
295
+
296
+ const splitGraphemes = (value) => {
297
+ const text = toSafeCanvasText(value);
298
+ if (!text) return [];
299
+ if (GRAPHEME_SEGMENTER) {
300
+ return Array.from(GRAPHEME_SEGMENTER.segment(text), (entry) => entry.segment);
301
+ }
302
+ return Array.from(text);
303
+ };
304
+
305
+ const getCanvasFont = (size, weight = 'normal') => `${weight} ${Math.max(10, Number(size) || 10)}px ${CANVAS_FONT_STACK}`;
306
+
307
+ const fitText = (ctx, text, maxWidth) => {
308
+ const base = toSafeCanvasText(text);
309
+ if (!base) return '';
310
+ if (ctx.measureText(base).width <= maxWidth) return base;
311
+ const graphemes = splitGraphemes(base);
312
+ while (graphemes.length > 0 && ctx.measureText(`${graphemes.join('')}${ELLIPSIS}`).width > maxWidth) {
313
+ graphemes.pop();
314
+ }
315
+ return graphemes.length ? `${graphemes.join('')}${ELLIPSIS}` : '';
316
+ };
317
+
318
+ const getInitials = (label) => {
319
+ if (!label) return '?';
320
+ const clean = toSafeCanvasDisplayName(label).replace(/^@/, '');
321
+ if (!clean) return '?';
322
+ const parts = clean.split(/\s+/).filter(Boolean);
323
+ if (!parts.length) return '?';
324
+ if (parts.length === 1) return splitGraphemes(parts[0]).slice(0, 2).join('').toUpperCase();
325
+ const first = splitGraphemes(parts[0])[0] || '';
326
+ const second = splitGraphemes(parts[1])[0] || '';
327
+ const value = `${first}${second}`.toUpperCase();
328
+ return value || '?';
329
+ };
330
+
331
+ const formatCompactNumber = (value) => {
332
+ const num = Number(value || 0);
333
+ if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
334
+ if (num >= 1_000) return `${(num / 1_000).toFixed(1)}k`;
335
+ return `${num}`;
336
+ };
337
+
338
+ const pickAvatarJid = (row) => {
339
+ if (!row) return null;
340
+ if (isWhatsAppUserId(row.mention_id)) return row.mention_id;
341
+ if (isWhatsAppUserId(row.sender_id)) return row.sender_id;
342
+ return null;
343
+ };
344
+
345
+ const drawAvatar = (ctx, { x, y, radius, image, fallbackLabel, borderColor = '#38bdf8', glowColor = null, glowBlur = 14 }) => {
346
+ ctx.save();
347
+ ctx.shadowColor = glowColor || borderColor;
348
+ ctx.shadowBlur = Math.max(0, Number(glowBlur) || 0);
349
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.32)';
350
+ ctx.beginPath();
351
+ ctx.arc(x, y, radius + 4, 0, Math.PI * 2);
352
+ ctx.fill();
353
+ ctx.restore();
354
+
355
+ const glow = ctx.createRadialGradient(x - radius * 0.2, y - radius * 0.2, radius * 0.4, x, y, radius * 1.2);
356
+ glow.addColorStop(0, 'rgba(226, 232, 240, 0.25)');
357
+ glow.addColorStop(1, 'rgba(15, 23, 42, 0)');
358
+ ctx.save();
359
+ ctx.fillStyle = glow;
360
+ ctx.beginPath();
361
+ ctx.arc(x, y, radius + 6, 0, Math.PI * 2);
362
+ ctx.fill();
363
+ ctx.restore();
364
+
365
+ ctx.save();
366
+ ctx.beginPath();
367
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
368
+ ctx.closePath();
369
+ ctx.clip();
370
+ if (image) {
371
+ ctx.drawImage(image, x - radius, y - radius, radius * 2, radius * 2);
372
+ } else {
373
+ const gradient = ctx.createLinearGradient(x - radius, y - radius, x + radius, y + radius);
374
+ gradient.addColorStop(0, '#1f2937');
375
+ gradient.addColorStop(1, '#0f172a');
376
+ ctx.fillStyle = gradient;
377
+ ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
378
+ ctx.fillStyle = '#f8fafc';
379
+ ctx.font = getCanvasFont(Math.max(16, radius * 0.7), 'bold');
380
+ ctx.textAlign = 'center';
381
+ ctx.textBaseline = 'middle';
382
+ ctx.fillText(getInitials(fallbackLabel), x, y);
383
+ }
384
+ ctx.restore();
385
+
386
+ ctx.save();
387
+ ctx.strokeStyle = borderColor;
388
+ ctx.lineWidth = 2;
389
+ ctx.beginPath();
390
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
391
+ ctx.stroke();
392
+ ctx.restore();
393
+ };
394
+
395
+ const buildCanonicalWhere = ({ scope, remoteJid, botJid, canonicalId }) => {
396
+ const canonical = normalizeJid(canonicalId) || canonicalId;
397
+ const senderExpr = CANONICAL_SENDER_SQL;
398
+ const where = [];
399
+ const params = [];
400
+
401
+ if (isWhatsAppUserId(canonical)) {
402
+ const user = getJidUser(canonical);
403
+ if (user) {
404
+ // Inclui variações com dispositivo: user:device@server.
405
+ where.push(`(${senderExpr} = ? OR ${senderExpr} LIKE ? OR ${senderExpr} LIKE ?)`);
406
+ params.push(canonical, `${user}@%`, `${user}:%`);
407
+ } else {
408
+ where.push(`${senderExpr} = ?`);
409
+ params.push(canonical);
410
+ }
411
+ } else {
412
+ where.push(`${senderExpr} = ?`);
413
+ params.push(canonical);
414
+ }
415
+
416
+ if (scope === 'group') {
417
+ where.push('m.chat_id = ?');
418
+ params.push(remoteJid);
419
+ }
420
+ if (botJid) {
421
+ const normalizedBotJid = normalizeJid(botJid) || botJid;
422
+ where.push(`${senderExpr} <> ?`);
423
+ params.push(normalizedBotJid);
424
+ if (botJid !== normalizedBotJid) {
425
+ where.push(`${senderExpr} <> ?`);
426
+ params.push(botJid);
427
+ }
428
+ const botUser = getJidUser(normalizedBotJid);
429
+ if (botUser) {
430
+ where.push(`${senderExpr} NOT LIKE ?`);
431
+ params.push(`${botUser}@%`);
432
+ where.push(`${senderExpr} NOT LIKE ?`);
433
+ params.push(`${botUser}:%`);
434
+ }
435
+ }
436
+
437
+ return { where, params };
438
+ };
439
+
440
+ const buildDailyWhere = ({ scope, remoteJid, botJid, canonicalId = null }) => {
441
+ const senderExpr = 'd.canonical_sender_id';
442
+ const where = [`${senderExpr} IS NOT NULL`];
443
+ const params = [];
444
+
445
+ if (canonicalId) {
446
+ const canonical = normalizeJid(canonicalId) || canonicalId;
447
+ if (isWhatsAppUserId(canonical)) {
448
+ const user = getJidUser(canonical);
449
+ if (user) {
450
+ where.push(`(${senderExpr} = ? OR ${senderExpr} LIKE ? OR ${senderExpr} LIKE ?)`);
451
+ params.push(canonical, `${user}@%`, `${user}:%`);
452
+ } else {
453
+ where.push(`${senderExpr} = ?`);
454
+ params.push(canonical);
455
+ }
456
+ } else {
457
+ where.push(`${senderExpr} = ?`);
458
+ params.push(canonical);
459
+ }
460
+ }
461
+
462
+ if (scope === 'group') {
463
+ where.push('d.chat_id = ?');
464
+ params.push(remoteJid);
465
+ }
466
+ if (botJid) {
467
+ const normalizedBotJid = normalizeJid(botJid) || botJid;
468
+ where.push(`${senderExpr} <> ?`);
469
+ params.push(normalizedBotJid);
470
+ if (botJid !== normalizedBotJid) {
471
+ where.push(`${senderExpr} <> ?`);
472
+ params.push(botJid);
473
+ }
474
+ const botUser = getJidUser(normalizedBotJid);
475
+ if (botUser) {
476
+ where.push(`${senderExpr} NOT LIKE ?`);
477
+ params.push(`${botUser}@%`);
478
+ where.push(`${senderExpr} NOT LIKE ?`);
479
+ params.push(`${botUser}:%`);
480
+ }
481
+ }
482
+
483
+ return { where, params };
484
+ };
485
+
486
+ /**
487
+ * Busca total de mensagens conforme escopo.
488
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null}} params
489
+ * @returns {Promise<number>}
490
+ */
491
+ export const getTotalMessages = async ({ scope, remoteJid, botJid }) => {
492
+ if (await canUseMessageActivityDaily()) {
493
+ try {
494
+ const { where, params } = buildDailyWhere({ scope, remoteJid, botJid });
495
+ const [row] = await executeQuery(
496
+ `SELECT COALESCE(SUM(d.total_messages), 0) AS total
497
+ FROM message_activity_daily d
498
+ WHERE ${where.join(' AND ')}`,
499
+ params,
500
+ );
501
+ return Number(row?.total || 0);
502
+ } catch (error) {
503
+ if (isMissingMessageActivityDailyError(error)) {
504
+ messageActivityDailyAvailable = false;
505
+ } else {
506
+ throw error;
507
+ }
508
+ }
509
+ }
510
+
511
+ const { where, params, joinSql } = buildWhere({
512
+ scope,
513
+ remoteJid,
514
+ botJid,
515
+ useCanonicalSender: true,
516
+ });
517
+ const sql = `SELECT COUNT(*) AS total FROM messages m ${joinSql} WHERE ${where.join(' AND ')}`;
518
+ const [row] = await executeQuery(sql, params);
519
+ return Number(row?.total || 0);
520
+ };
521
+
522
+ /**
523
+ * Busca o tipo de mensagem mais usado conforme escopo.
524
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null}} params
525
+ * @returns {Promise<{label: string, count: number}|null>}
526
+ */
527
+ export const getTopMessageType = async ({ scope, remoteJid, botJid }) => {
528
+ const { where, params, joinSql } = buildWhere({
529
+ scope,
530
+ remoteJid,
531
+ botJid,
532
+ useCanonicalSender: true,
533
+ });
534
+ const [row] = await executeQuery(
535
+ `SELECT
536
+ ${MESSAGE_TYPE_SQL} AS message_type,
537
+ COUNT(*) AS total
538
+ FROM messages m
539
+ ${joinSql}
540
+ WHERE ${where.join(' AND ')}
541
+ AND m.raw_message IS NOT NULL
542
+ GROUP BY message_type
543
+ ORDER BY total DESC
544
+ LIMIT 1`,
545
+ params,
546
+ );
547
+ if (!row?.message_type) return null;
548
+ return { label: row.message_type, count: Number(row.total || 0) };
549
+ };
550
+
551
+ /**
552
+ * Busca inicio do banco conforme escopo.
553
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null}} params
554
+ * @returns {Promise<any>}
555
+ */
556
+ export const getDbStart = async ({ scope, remoteJid, botJid }) => {
557
+ const { where, params, joinSql } = buildWhere({
558
+ scope,
559
+ remoteJid,
560
+ botJid,
561
+ useCanonicalSender: true,
562
+ });
563
+ const sql = `SELECT MIN(m.timestamp) AS db_start FROM messages m ${joinSql} WHERE ${where.join(' AND ')}`;
564
+ const rows = await executeQuery(sql, params);
565
+ return rows?.[0]?.db_start || null;
566
+ };
567
+
568
+ /**
569
+ * Busca os ultimos pushNames por sender_id.
570
+ * @param {Array<string>} senderIds
571
+ * @returns {Promise<Map<string, string>>}
572
+ */
573
+ export const fetchLatestPushNames = async (senderIds) => {
574
+ if (!senderIds || !senderIds.length) return new Map();
575
+ const placeholders = senderIds.map(() => '?').join(',');
576
+ const rows = await executeQuery(
577
+ `SELECT t.sender_id,
578
+ JSON_UNQUOTE(JSON_EXTRACT(m.raw_message, '$.pushName')) AS pushName
579
+ FROM (
580
+ SELECT ${CANONICAL_SENDER_SQL} AS sender_id, MAX(id) AS max_id
581
+ FROM messages m
582
+ WHERE ${CANONICAL_SENDER_SQL} IN (${placeholders})
583
+ AND raw_message IS NOT NULL
584
+ AND JSON_EXTRACT(raw_message, '$.pushName') IS NOT NULL
585
+ GROUP BY ${CANONICAL_SENDER_SQL}
586
+ ) t
587
+ JOIN messages m ON m.id = t.max_id`,
588
+ senderIds,
589
+ );
590
+ const map = new Map();
591
+ (rows || []).forEach((row) => {
592
+ if (row?.sender_id && row?.pushName) {
593
+ map.set(row.sender_id, row.pushName);
594
+ }
595
+ });
596
+ return map;
597
+ };
598
+
599
+ /**
600
+ * Monta ranking base por remetente canônico.
601
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null, limit?: number|null}} params
602
+ * @returns {Promise<{rows: Array<any>}>}
603
+ */
604
+ export const getRankingBase = async ({ scope, remoteJid, botJid, limit = null }) => {
605
+ const limitClause = limit ? `LIMIT ${Number(limit)}` : '';
606
+ let rankingRows = [];
607
+
608
+ if (await canUseMessageActivityDaily()) {
609
+ try {
610
+ const { where, params } = buildDailyWhere({ scope, remoteJid, botJid });
611
+ rankingRows = await executeQuery(
612
+ `SELECT
613
+ d.canonical_sender_id AS sender_id,
614
+ SUM(d.total_messages) AS total_messages,
615
+ MIN(d.first_message_at) AS first_message,
616
+ MAX(d.last_message_at) AS last_message
617
+ FROM message_activity_daily d
618
+ WHERE ${where.join(' AND ')}
619
+ GROUP BY d.canonical_sender_id
620
+ ORDER BY total_messages DESC
621
+ ${limitClause}`,
622
+ params,
623
+ );
624
+ } catch (error) {
625
+ if (isMissingMessageActivityDailyError(error)) {
626
+ messageActivityDailyAvailable = false;
627
+ } else {
628
+ throw error;
629
+ }
630
+ }
631
+ }
632
+
633
+ if (!rankingRows.length) {
634
+ const { where, params, joinSql, senderExpr } = buildWhere({
635
+ scope,
636
+ remoteJid,
637
+ botJid,
638
+ useCanonicalSender: true,
639
+ });
640
+ rankingRows = await executeQuery(
641
+ `SELECT
642
+ ${senderExpr} AS sender_id,
643
+ COUNT(*) AS total_messages,
644
+ MIN(m.timestamp) AS first_message,
645
+ MAX(m.timestamp) AS last_message
646
+ FROM messages m
647
+ ${joinSql}
648
+ WHERE ${where.join(' AND ')}
649
+ GROUP BY ${senderExpr}
650
+ ORDER BY total_messages DESC
651
+ ${limitClause}`,
652
+ params,
653
+ );
654
+ }
655
+
656
+ const senderIds = rankingRows.map((row) => row.sender_id).filter(Boolean);
657
+ const lidsToPrime = senderIds.filter((id) => isLidUserId(id));
658
+ if (lidsToPrime.length > 0) {
659
+ await primeLidCache(lidsToPrime);
660
+ }
661
+
662
+ const pushNameBySender = await fetchLatestPushNames(senderIds);
663
+ const normalizedTotals = new Map();
664
+
665
+ rankingRows.forEach((row) => {
666
+ const rawJid = row.sender_id || '';
667
+ if (!rawJid) return;
668
+ const { displayId, mentionId, key } = resolveSenderIdsCanonical(rawJid);
669
+ if (!displayId || !key) return;
670
+ const total = Number(row.total_messages || 0);
671
+ const firstMs = toMillis(row.first_message);
672
+ const lastMs = toMillis(row.last_message);
673
+ const current = normalizedTotals.get(key) || {
674
+ sender_id: displayId,
675
+ mention_id: mentionId,
676
+ display_name: null,
677
+ total_messages: 0,
678
+ first_message: null,
679
+ last_message: null,
680
+ };
681
+ current.total_messages += total;
682
+ if (firstMs !== null) {
683
+ current.first_message = current.first_message === null ? firstMs : Math.min(current.first_message, firstMs);
684
+ }
685
+ if (lastMs !== null) {
686
+ current.last_message = current.last_message === null ? lastMs : Math.max(current.last_message, lastMs);
687
+ }
688
+ if (!current.mention_id && mentionId) {
689
+ current.mention_id = mentionId;
690
+ }
691
+ if (isWhatsAppUserId(rawJid)) {
692
+ current.mention_id = rawJid;
693
+ }
694
+ if (!current.display_name) {
695
+ const pushName = pushNameBySender.get(rawJid);
696
+ if (pushName) current.display_name = pushName;
697
+ }
698
+ normalizedTotals.set(key, current);
699
+ });
700
+
701
+ const rows = Array.from(normalizedTotals.values()).sort((a, b) => b.total_messages - a.total_messages);
702
+ return { rows: limit ? rows.slice(0, limit) : rows };
703
+ };
704
+
705
+ const normalizeDayKey = (value) => {
706
+ if (!value) return null;
707
+ if (value instanceof Date) {
708
+ if (Number.isNaN(value.getTime())) return null;
709
+ return value.toISOString().slice(0, 10);
710
+ }
711
+ const raw = String(value).trim();
712
+ if (!raw) return null;
713
+ const dateOnlyMatch = raw.match(/^(\d{4}-\d{2}-\d{2})/);
714
+ if (dateOnlyMatch?.[1]) return dateOnlyMatch[1];
715
+ const parsed = new Date(raw);
716
+ if (Number.isNaN(parsed.getTime())) return null;
717
+ return parsed.toISOString().slice(0, 10);
718
+ };
719
+
720
+ const dayKeyToUtcMs = (dayKey) => {
721
+ const match = String(dayKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
722
+ if (!match) return null;
723
+ const year = Number(match[1]);
724
+ const month = Number(match[2]);
725
+ const day = Number(match[3]);
726
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
727
+ return Date.UTC(year, month - 1, day);
728
+ };
729
+
730
+ const computeStreak = (days) => {
731
+ if (!days.length) return 0;
732
+ let best = 1;
733
+ let current = 1;
734
+ let prev = dayKeyToUtcMs(days[0]);
735
+ if (prev === null) return days.length ? 1 : 0;
736
+ for (let i = 1; i < days.length; i += 1) {
737
+ const currentDay = dayKeyToUtcMs(days[i]);
738
+ if (currentDay === null) continue;
739
+ const diff = currentDay - prev;
740
+ if (diff === DAY_MS) {
741
+ current += 1;
742
+ } else {
743
+ current = 1;
744
+ }
745
+ if (current > best) best = current;
746
+ prev = currentDay;
747
+ }
748
+ return best;
749
+ };
750
+
751
+ /**
752
+ * Enriquecer ranking com dias ativos, streak, media/dia e favorito.
753
+ * @param {{rows: Array<any>, scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null}} params
754
+ * @returns {Promise<void>}
755
+ */
756
+ export const enrichRankingRows = async ({ rows, scope, remoteJid, botJid }) => {
757
+ for (const row of rows) {
758
+ const rawJid = row.sender_id;
759
+ if (!rawJid) continue;
760
+
761
+ const { where, params } = buildCanonicalWhere({
762
+ scope,
763
+ remoteJid,
764
+ botJid,
765
+ canonicalId: rawJid,
766
+ });
767
+
768
+ let daysRows = [];
769
+ if (await canUseMessageActivityDaily()) {
770
+ try {
771
+ const { where: dailyWhere, params: dailyParams } = buildDailyWhere({
772
+ scope,
773
+ remoteJid,
774
+ botJid,
775
+ canonicalId: rawJid,
776
+ });
777
+ daysRows = await executeQuery(
778
+ `SELECT d.day_ref_date AS day
779
+ FROM message_activity_daily d
780
+ WHERE ${dailyWhere.join(' AND ')}
781
+ ORDER BY d.day_ref_date ASC`,
782
+ dailyParams,
783
+ );
784
+ } catch (error) {
785
+ if (isMissingMessageActivityDailyError(error)) {
786
+ messageActivityDailyAvailable = false;
787
+ } else {
788
+ throw error;
789
+ }
790
+ }
791
+ }
792
+
793
+ if (!daysRows.length) {
794
+ daysRows = await executeQuery(
795
+ `SELECT DISTINCT DATE(ts) AS day
796
+ FROM (
797
+ SELECT ${TIMESTAMP_TO_DATETIME_SQL} AS ts
798
+ FROM messages m
799
+ WHERE ${where.join(' AND ')}
800
+ AND m.timestamp IS NOT NULL
801
+ ) d
802
+ WHERE d.ts IS NOT NULL
803
+ ORDER BY day ASC`,
804
+ params,
805
+ );
806
+ }
807
+
808
+ const days = Array.from(new Set((daysRows || []).map((item) => normalizeDayKey(item?.day)).filter(Boolean))).sort();
809
+ row.active_days = days.length;
810
+ row.streak = computeStreak(days);
811
+
812
+ const total = Number(row.total_messages || 0);
813
+ const firstMs = toMillis(row.first_message);
814
+ const lastMs = toMillis(row.last_message);
815
+ if (firstMs !== null && lastMs !== null && total > 0) {
816
+ const rangeDays = Math.max(1, Math.ceil((lastMs - firstMs) / DAY_MS) + 1);
817
+ row.avg_per_day = (total / rangeDays).toFixed(2);
818
+ } else {
819
+ row.avg_per_day = '0.00';
820
+ }
821
+
822
+ const [favRow] = await executeQuery(
823
+ `SELECT
824
+ ${MESSAGE_TYPE_SQL} AS message_type,
825
+ COUNT(*) AS total
826
+ FROM messages m
827
+ WHERE ${where.join(' AND ')}
828
+ AND m.raw_message IS NOT NULL
829
+ GROUP BY message_type
830
+ ORDER BY total DESC
831
+ LIMIT 1`,
832
+ params,
833
+ );
834
+
835
+ row.favorite_type = favRow?.message_type || null;
836
+ row.favorite_count = Number(favRow?.total || 0);
837
+ }
838
+ };
839
+
840
+ /**
841
+ * Monta um relatorio completo do ranking conforme escopo.
842
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null, limit?: number|null, includeTopType?: boolean, includeDbStart?: boolean, enrichRows?: boolean}} params
843
+ * @returns {Promise<{rows: Array<any>, totalMessages: number, topType: {label: string, count: number}|null, topTotal: number, dbStart: any}>}
844
+ */
845
+ export const getRankingReport = async ({ scope, remoteJid, botJid, limit = null, includeTopType = true, includeDbStart = true, enrichRows = true }) => {
846
+ const totalMessages = await getTotalMessages({ scope, remoteJid, botJid });
847
+ const topType = includeTopType ? await getTopMessageType({ scope, remoteJid, botJid }) : null;
848
+ const { rows } = await getRankingBase({ scope, remoteJid, botJid, limit });
849
+ if (enrichRows) {
850
+ await enrichRankingRows({ rows, scope, remoteJid, botJid });
851
+ }
852
+ const topTotal = rows.reduce((acc, row) => acc + Number(row.total_messages || 0), 0);
853
+ const dbStart = includeDbStart ? await getDbStart({ scope, remoteJid, botJid }) : null;
854
+ return { rows, totalMessages, topType, topTotal, dbStart };
855
+ };
856
+
857
+ /**
858
+ * Monta mensagem detalhada do ranking.
859
+ * @param {{scope: 'group'|'global', limit: number, rows: Array<any>, totalMessages: number, topTotal: number, topType: {label: string, count: number}|null, dbStart: any}} params
860
+ * @returns {string}
861
+ */
862
+ export const buildRankingMessage = ({ scope, limit, rows, totalMessages, topTotal, topType, dbStart }) => {
863
+ const scopeTitle = scope === 'global' ? 'Global' : 'Grupo';
864
+ const scopeLabel = scope === 'global' ? 'global' : 'grupo';
865
+
866
+ if (!rows.length) {
867
+ return `Nao ha mensagens suficientes para gerar o ranking ${scopeLabel}.\n\nInicio do banco (primeira mensagem): ${formatDate(dbStart)}`;
868
+ }
869
+
870
+ const totalLabel = Number(totalMessages || 0);
871
+ const topShare = totalLabel > 0 ? ((Number(topTotal || 0) / totalLabel) * 100).toFixed(2) : '0.00';
872
+ const topTypeLabel = topType?.label ? `${topType.label} (${topType.count})` : 'N/D';
873
+
874
+ const lines = [`🏆 *Ranking ${scopeTitle} Top ${limit} (mensagens)*`, `📦 Total de mensagens (${scopeLabel}): ${totalLabel}`, `📊 Top ${limit} = ${topShare}% do total`, `🔥 Tipo mais usado: ${topTypeLabel}`, ''];
875
+
876
+ rows.forEach((row, index) => {
877
+ const handle = getDisplayName(row.display_name, row.mention_id || row.sender_id);
878
+ const total = row.total_messages || 0;
879
+ const percent = totalLabel > 0 ? ((Number(total || 0) / totalLabel) * 100).toFixed(2) : '0.00';
880
+ const first = formatDate(row.first_message);
881
+ const last = formatDate(row.last_message);
882
+ const avgPerDay = row.avg_per_day || '0.00';
883
+ const activeDays = row.active_days ?? 0;
884
+ const streak = row.streak ?? 0;
885
+ const favoriteType = row.favorite_type ? `${row.favorite_type} (${row.favorite_count || 0})` : 'N/D';
886
+ const position = `${index + 1}`.padStart(2, '0');
887
+ lines.push(`${position}. ${handle}`, ` 💬 ${total} msg(s)`, ` 📊 ${percent}% do total`, ` 📆 dias ativos: ${activeDays}`, ` 📈 media/dia: ${avgPerDay}`, ` 🔥 favorito: ${favoriteType}`, ` 🔗 streak: ${streak} dia(s)`, ` 📅 primeira: ${first}`, ` 🕘 ultima: ${last}`, '');
888
+ });
889
+
890
+ lines.push(`Inicio do banco (primeira mensagem): ${formatDate(dbStart)}`);
891
+ return lines.join('\n');
892
+ };
893
+
894
+ /**
895
+ * Renderiza uma imagem de ranking horizontal.
896
+ * @param {object} params
897
+ * @param {object} params.sock
898
+ * @param {string} params.remoteJid
899
+ * @param {Array<object>} params.rows
900
+ * @param {number} params.totalMessages
901
+ * @param {{label: string, count: number}|null} params.topType
902
+ * @param {'group'|'global'} params.scope
903
+ * @param {number} params.limit
904
+ * @returns {Promise<Buffer>}
905
+ */
906
+ export const renderRankingImage = async ({ sock, remoteJid, rows, totalMessages, topType, scope, limit }) => {
907
+ const width = RANKING_IMAGE_WIDTH;
908
+ const height = RANKING_IMAGE_HEIGHT;
909
+ const scale = RANKING_IMAGE_SCALE;
910
+ const canvas = createCanvas(width * scale, height * scale);
911
+ const ctx = canvas.getContext('2d');
912
+ ctx.scale(scale, scale);
913
+ ctx.imageSmoothingEnabled = true;
914
+ ctx.imageSmoothingQuality = 'high';
915
+ const rankColors = {
916
+ 1: '#facc15',
917
+ 2: '#38bdf8',
918
+ 3: '#34d399',
919
+ 4: '#64748b',
920
+ 5: '#64748b',
921
+ };
922
+ const progressGradientByRank = {
923
+ 1: ['#facc15', '#eab308'],
924
+ 2: ['#38bdf8', '#0284c7'],
925
+ 3: ['#34d399', '#059669'],
926
+ 4: ['#64748b', '#475569'],
927
+ 5: ['#64748b', '#475569'],
928
+ };
929
+ const medalLabelByRank = {
930
+ 1: 'GOLD',
931
+ 2: 'SILVER',
932
+ 3: 'BRONZE',
933
+ };
934
+
935
+ const uiFontStack = "'Inter', 'Poppins', 'Segoe UI', 'Noto Sans', 'DejaVu Sans', Arial, sans-serif";
936
+ const uiFont = (size, weight = 500) => `${weight} ${Math.max(10, Number(size) || 10)}px ${uiFontStack}`;
937
+ const clamp01 = (value) => Math.max(0, Math.min(1, Number(value) || 0));
938
+ const hexToRgba = (hex, alpha = 1) => {
939
+ const clean = String(hex || '').replace('#', '');
940
+ const normalized =
941
+ clean.length === 3
942
+ ? clean
943
+ .split('')
944
+ .map((value) => `${value}${value}`)
945
+ .join('')
946
+ : clean;
947
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return `rgba(148, 163, 184, ${alpha})`;
948
+ const int = Number.parseInt(normalized, 16);
949
+ const r = (int >> 16) & 255;
950
+ const g = (int >> 8) & 255;
951
+ const b = int & 255;
952
+ return `rgba(${r}, ${g}, ${b}, ${clamp01(alpha)})`;
953
+ };
954
+
955
+ const baseGradient = ctx.createLinearGradient(0, 0, width, height);
956
+ baseGradient.addColorStop(0, '#0f172a');
957
+ baseGradient.addColorStop(1, '#020617');
958
+ ctx.fillStyle = baseGradient;
959
+ ctx.fillRect(0, 0, width, height);
960
+
961
+ const drawRadialShape = (x, y, radius, color, alpha = 1) => {
962
+ const radial = ctx.createRadialGradient(x, y, 0, x, y, radius);
963
+ radial.addColorStop(0, hexToRgba(color, alpha));
964
+ radial.addColorStop(1, hexToRgba(color, 0));
965
+ ctx.save();
966
+ ctx.fillStyle = radial;
967
+ ctx.beginPath();
968
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
969
+ ctx.fill();
970
+ ctx.restore();
971
+ };
972
+
973
+ drawRadialShape(width * 0.84, height * 0.19, 230, '#38bdf8', 0.11);
974
+ drawRadialShape(width * 0.2, height * 0.78, 270, '#34d399', 0.07);
975
+ drawRadialShape(width * 0.56, height * 0.42, 300, '#1e293b', 0.2);
976
+
977
+ ctx.save();
978
+ ctx.strokeStyle = 'rgba(100, 116, 139, 0.08)';
979
+ ctx.lineWidth = 1;
980
+ for (let x = 0; x <= width; x += 96) {
981
+ ctx.beginPath();
982
+ ctx.moveTo(x, 0);
983
+ ctx.lineTo(x, height);
984
+ ctx.stroke();
985
+ }
986
+ for (let y = 0; y <= height; y += 96) {
987
+ ctx.beginPath();
988
+ ctx.moveTo(0, y);
989
+ ctx.lineTo(width, y);
990
+ ctx.stroke();
991
+ }
992
+ ctx.restore();
993
+
994
+ const noiseSize = 140;
995
+ const noiseCanvas = createCanvas(noiseSize, noiseSize);
996
+ const noiseCtx = noiseCanvas.getContext('2d');
997
+ const noiseData = noiseCtx.createImageData(noiseSize, noiseSize);
998
+ for (let i = 0; i < noiseData.data.length; i += 4) {
999
+ const value = 215 + Math.floor(Math.random() * 40);
1000
+ noiseData.data[i] = value;
1001
+ noiseData.data[i + 1] = value;
1002
+ noiseData.data[i + 2] = value;
1003
+ noiseData.data[i + 3] = 10;
1004
+ }
1005
+ noiseCtx.putImageData(noiseData, 0, 0);
1006
+ const noisePattern = ctx.createPattern(noiseCanvas, 'repeat');
1007
+ if (noisePattern) {
1008
+ ctx.save();
1009
+ ctx.globalAlpha = 0.05;
1010
+ ctx.fillStyle = noisePattern;
1011
+ ctx.fillRect(0, 0, width, height);
1012
+ ctx.restore();
1013
+ }
1014
+
1015
+ const margin = 42;
1016
+ const gap = 24;
1017
+ const headerTop = 44;
1018
+ const gridTop = 160;
1019
+ const title = scope === 'global' ? `Ranking Global - Top ${limit}` : `Ranking do Grupo - Top ${limit}`;
1020
+ const topTypeLabel = toSafeCanvasText(topType?.label || 'N/D').toLowerCase() || 'n/d';
1021
+ const subtitle = `${formatCompactNumber(totalMessages)} mensagens • tipo mais usado: ${topTypeLabel}`;
1022
+
1023
+ ctx.fillStyle = '#e2e8f0';
1024
+ ctx.font = uiFont(48, 700);
1025
+ ctx.textAlign = 'left';
1026
+ ctx.textBaseline = 'top';
1027
+ drawTrackedText(ctx, title, margin, headerTop + 10, 1.15);
1028
+
1029
+ ctx.fillStyle = '#94a3b8';
1030
+ ctx.font = uiFont(22, 500);
1031
+ ctx.fillText(subtitle, margin, headerTop + 72);
1032
+
1033
+ ctx.save();
1034
+ const accentBarY = headerTop + 62;
1035
+ drawRoundedRect(ctx, margin, accentBarY, 16, 4, 2);
1036
+ ctx.fillStyle = 'rgba(250, 204, 21, 0.9)';
1037
+ ctx.fill();
1038
+ drawRoundedRect(ctx, margin + 22, accentBarY, 32, 4, 2);
1039
+ ctx.fillStyle = 'rgba(56, 189, 248, 0.85)';
1040
+ ctx.fill();
1041
+ drawRoundedRect(ctx, margin + 60, accentBarY, 22, 4, 2);
1042
+ ctx.fillStyle = 'rgba(52, 211, 153, 0.85)';
1043
+ ctx.fill();
1044
+ ctx.restore();
1045
+
1046
+ const avatarJids = rows.map((row) => pickAvatarJid(row)).filter(Boolean);
1047
+ const avatars = await loadProfileImages({ sock, jids: avatarJids, remoteJid });
1048
+
1049
+ const availableWidth = width - margin * 2;
1050
+ const rank1Scale = 1.21;
1051
+ const rank2Scale = 0.94;
1052
+ const topBaseWidth = (availableWidth - gap) / (rank1Scale + rank2Scale);
1053
+ const topBaseHeight = 280;
1054
+ const rank1Width = topBaseWidth * rank1Scale;
1055
+ const rank2Width = topBaseWidth * rank2Scale;
1056
+ const rank1Height = topBaseHeight * rank1Scale;
1057
+ const rank2Height = topBaseHeight * rank2Scale;
1058
+ const topCombinedWidth = rank1Width + rank2Width + gap;
1059
+ const topStartX = margin + Math.max(0, (availableWidth - topCombinedWidth) / 2);
1060
+ const topRowBottom = gridTop + rank1Height;
1061
+
1062
+ const drawProgressBar = ({ x, y, w, h, ratio, accentColor, rank }) => {
1063
+ const safeRatio = clamp01(ratio);
1064
+ const [startColor, endColor] = progressGradientByRank[rank] || [accentColor, accentColor];
1065
+ ctx.save();
1066
+ drawRoundedRect(ctx, x, y, w, h, h / 2);
1067
+ ctx.fillStyle = 'rgba(100, 116, 139, 0.23)';
1068
+ ctx.fill();
1069
+
1070
+ const fillWidth = safeRatio > 0 ? Math.max(2, Math.min(w, w * safeRatio)) : 0;
1071
+ if (fillWidth > 0) {
1072
+ const fillGradient = ctx.createLinearGradient(x, y, x + fillWidth, y + h);
1073
+ fillGradient.addColorStop(0, hexToRgba(startColor, 0.98));
1074
+ fillGradient.addColorStop(1, hexToRgba(endColor, 0.84));
1075
+ drawRoundedRect(ctx, x, y, fillWidth, h, h / 2);
1076
+ ctx.fillStyle = fillGradient;
1077
+ ctx.fill();
1078
+
1079
+ const gloss = ctx.createLinearGradient(x, y, x, y + h);
1080
+ gloss.addColorStop(0, 'rgba(255, 255, 255, 0.24)');
1081
+ gloss.addColorStop(1, 'rgba(255, 255, 255, 0)');
1082
+ drawRoundedRect(ctx, x, y, fillWidth, h / 2, h / 2);
1083
+ ctx.fillStyle = gloss;
1084
+ ctx.fill();
1085
+ }
1086
+ ctx.restore();
1087
+ };
1088
+
1089
+ const drawCard = ({ row, x, y, w, h, rank }) => {
1090
+ if (!row) return;
1091
+ const accentColor = rankColors[rank] || rankColors[5];
1092
+ const isTop = rank === 1;
1093
+ const share = totalMessages > 0 ? Number(row.total_messages || 0) / totalMessages : 0;
1094
+ const percent = (clamp01(share) * 100).toFixed(1);
1095
+ const label = getShortName(row);
1096
+
1097
+ ctx.save();
1098
+ ctx.shadowColor = hexToRgba(accentColor, isTop ? 0.58 : 0.34);
1099
+ ctx.shadowBlur = isTop ? 38 : 30;
1100
+ ctx.shadowOffsetY = 6;
1101
+ drawRoundedRect(ctx, x, y, w, h, 20);
1102
+ ctx.fillStyle = isTop ? 'rgba(15, 23, 42, 0.74)' : 'rgba(15, 23, 42, 0.68)';
1103
+ ctx.fill();
1104
+ ctx.restore();
1105
+
1106
+ ctx.save();
1107
+ drawRoundedRect(ctx, x, y, w, h, 20);
1108
+ const cardGradient = ctx.createLinearGradient(x, y, x + w, y + h);
1109
+ cardGradient.addColorStop(0, isTop ? 'rgba(30, 41, 59, 0.76)' : 'rgba(30, 41, 59, 0.64)');
1110
+ cardGradient.addColorStop(1, 'rgba(15, 23, 42, 0.62)');
1111
+ ctx.fillStyle = cardGradient;
1112
+ ctx.fill();
1113
+ ctx.lineWidth = isTop ? 2.2 : 1.7;
1114
+ ctx.strokeStyle = hexToRgba(accentColor, isTop ? 0.9 : 0.72);
1115
+ ctx.stroke();
1116
+
1117
+ ctx.clip();
1118
+ const shine = ctx.createLinearGradient(x - 120, y - 80, x + w * 0.68, y + h * 0.42);
1119
+ shine.addColorStop(0, 'rgba(255, 255, 255, 0)');
1120
+ shine.addColorStop(0.45, isTop ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.06)');
1121
+ shine.addColorStop(0.9, 'rgba(255, 255, 255, 0)');
1122
+ ctx.fillStyle = shine;
1123
+ ctx.fillRect(x - 120, y - 120, w + 300, h + 200);
1124
+ ctx.restore();
1125
+
1126
+ ctx.save();
1127
+ drawRoundedRect(ctx, x + 1, y + 1, w - 2, h - 2, 18);
1128
+ ctx.clip();
1129
+ const insetShade = ctx.createLinearGradient(x, y, x, y + h);
1130
+ insetShade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
1131
+ insetShade.addColorStop(0.35, 'rgba(255, 255, 255, 0.01)');
1132
+ insetShade.addColorStop(1, 'rgba(0, 0, 0, 0.42)');
1133
+ ctx.fillStyle = insetShade;
1134
+ ctx.fillRect(x, y, w, h);
1135
+ ctx.restore();
1136
+
1137
+ if (isTop) {
1138
+ ctx.save();
1139
+ drawRoundedRect(ctx, x - 3, y - 3, w + 6, h + 6, 24);
1140
+ ctx.lineWidth = 1;
1141
+ ctx.strokeStyle = 'rgba(250, 204, 21, 0.25)';
1142
+ ctx.stroke();
1143
+ ctx.restore();
1144
+ }
1145
+
1146
+ const pad = Math.round(Math.max(20, Math.min(30, w * 0.035)));
1147
+ const avatarRadius = Math.min(isTop ? 76 : 64, h * 0.29);
1148
+ const avatarX = x + pad + avatarRadius;
1149
+ const avatarY = y + h * 0.5;
1150
+ const avatarImage = avatars.get(pickAvatarJid(row)) || null;
1151
+ drawAvatar(ctx, {
1152
+ x: avatarX,
1153
+ y: avatarY,
1154
+ radius: avatarRadius,
1155
+ image: avatarImage,
1156
+ fallbackLabel: label,
1157
+ borderColor: accentColor,
1158
+ glowColor: accentColor,
1159
+ glowBlur: isTop ? 18 : 14,
1160
+ });
1161
+
1162
+ const rankBadgeSize = isTop ? 50 : 44;
1163
+ ctx.save();
1164
+ ctx.fillStyle = hexToRgba(accentColor, 0.96);
1165
+ ctx.beginPath();
1166
+ ctx.arc(x + pad + rankBadgeSize / 2, y + pad + rankBadgeSize / 2, rankBadgeSize / 2, 0, Math.PI * 2);
1167
+ ctx.fill();
1168
+ ctx.fillStyle = '#020617';
1169
+ ctx.font = uiFont(rankBadgeSize * 0.48, 700);
1170
+ ctx.textAlign = 'center';
1171
+ ctx.textBaseline = 'middle';
1172
+ ctx.fillText(String(rank), x + pad + rankBadgeSize / 2, y + pad + rankBadgeSize / 2 + 1);
1173
+ ctx.restore();
1174
+
1175
+ if (isTop) {
1176
+ const badgeW = 90;
1177
+ const badgeH = 32;
1178
+ const badgeX = x + w - pad - badgeW;
1179
+ const badgeY = y + pad + 2;
1180
+ ctx.save();
1181
+ drawRoundedRect(ctx, badgeX, badgeY, badgeW, badgeH, 14);
1182
+ const topBadgeGradient = ctx.createLinearGradient(badgeX, badgeY, badgeX + badgeW, badgeY + badgeH);
1183
+ topBadgeGradient.addColorStop(0, 'rgba(250, 204, 21, 0.24)');
1184
+ topBadgeGradient.addColorStop(1, 'rgba(234, 179, 8, 0.14)');
1185
+ ctx.fillStyle = topBadgeGradient;
1186
+ ctx.fill();
1187
+ ctx.strokeStyle = 'rgba(250, 204, 21, 0.78)';
1188
+ ctx.lineWidth = 1.2;
1189
+ ctx.stroke();
1190
+ ctx.fillStyle = '#facc15';
1191
+ ctx.font = uiFont(14, 700);
1192
+ ctx.textAlign = 'center';
1193
+ ctx.textBaseline = 'middle';
1194
+ ctx.fillText('TOP 1', badgeX + badgeW / 2, badgeY + badgeH / 2 + 1);
1195
+ ctx.restore();
1196
+ }
1197
+
1198
+ const medalLabel = medalLabelByRank[rank];
1199
+ if (medalLabel) {
1200
+ const medalW = 86;
1201
+ const medalH = 24;
1202
+ const medalX = x + w - pad - medalW;
1203
+ const medalY = y + h - pad - 52;
1204
+ ctx.save();
1205
+ drawRoundedRect(ctx, medalX, medalY, medalW, medalH, 12);
1206
+ ctx.fillStyle = hexToRgba(accentColor, 0.17);
1207
+ ctx.fill();
1208
+ ctx.strokeStyle = hexToRgba(accentColor, 0.65);
1209
+ ctx.lineWidth = 1;
1210
+ ctx.stroke();
1211
+ ctx.fillStyle = hexToRgba(accentColor, 0.95);
1212
+ ctx.font = uiFont(12, 700);
1213
+ ctx.textAlign = 'center';
1214
+ ctx.textBaseline = 'middle';
1215
+ ctx.fillText(medalLabel, medalX + medalW / 2, medalY + medalH / 2 + 1);
1216
+ ctx.restore();
1217
+ }
1218
+
1219
+ const textX = avatarX + avatarRadius + (isTop ? 24 : 20);
1220
+ const textWidth = x + w - pad - textX;
1221
+ const nameY = y + h * 0.24;
1222
+ const nameSize = Math.max(26, Math.min(40, h * 0.12));
1223
+ const messageSize = Math.max(20, Math.min(34, h * 0.1));
1224
+ const secondarySize = Math.max(16, Math.min(22, h * 0.07));
1225
+
1226
+ ctx.save();
1227
+ ctx.fillStyle = '#e2e8f0';
1228
+ ctx.font = uiFont(nameSize, 700);
1229
+ ctx.textAlign = 'left';
1230
+ ctx.textBaseline = 'top';
1231
+ ctx.fillText(fitText(ctx, label, textWidth), textX, nameY);
1232
+
1233
+ const totalLabel = formatCompactNumber(row.total_messages || 0);
1234
+ ctx.fillStyle = '#e2e8f0';
1235
+ ctx.font = uiFont(messageSize, 600);
1236
+ ctx.fillText(`${totalLabel} mensagens`, textX, nameY + messageSize + 10);
1237
+
1238
+ ctx.fillStyle = '#94a3b8';
1239
+ ctx.font = uiFont(secondarySize, 500);
1240
+ ctx.fillText(`${percent}% do grupo`, textX, nameY + messageSize + secondarySize + 24);
1241
+ ctx.restore();
1242
+
1243
+ const progressY = y + h - pad - 16;
1244
+ drawProgressBar({
1245
+ x: textX,
1246
+ y: progressY,
1247
+ w: textWidth,
1248
+ h: 11,
1249
+ ratio: share,
1250
+ accentColor,
1251
+ rank,
1252
+ });
1253
+ };
1254
+
1255
+ drawCard({
1256
+ row: rows[0],
1257
+ x: topStartX,
1258
+ y: gridTop,
1259
+ w: rank1Width,
1260
+ h: rank1Height,
1261
+ rank: 1,
1262
+ });
1263
+
1264
+ drawCard({
1265
+ row: rows[1],
1266
+ x: topStartX + rank1Width + gap,
1267
+ y: topRowBottom - rank2Height,
1268
+ w: rank2Width,
1269
+ h: rank2Height,
1270
+ rank: 2,
1271
+ });
1272
+
1273
+ const restRows = rows.slice(2, 5);
1274
+ const restTop = topRowBottom + 28;
1275
+ if (restRows.length) {
1276
+ const restCount = restRows.length;
1277
+ const restGap = 20;
1278
+ const restWidth = (availableWidth - restGap * Math.max(0, restCount - 1)) / Math.max(1, restCount);
1279
+ const restHeight = Math.min(228, height - restTop - 116);
1280
+ const usedWidth = restWidth * restCount + restGap * Math.max(0, restCount - 1);
1281
+ const restStartX = margin + Math.max(0, (availableWidth - usedWidth) / 2);
1282
+ restRows.forEach((row, index) => {
1283
+ drawCard({
1284
+ row,
1285
+ x: restStartX + index * (restWidth + restGap),
1286
+ y: restTop,
1287
+ w: restWidth,
1288
+ h: restHeight,
1289
+ rank: index + 3,
1290
+ });
1291
+ });
1292
+ }
1293
+
1294
+ const footerY = height - 36;
1295
+ const updatedAt = formatDate(new Date());
1296
+ ctx.fillStyle = 'rgba(148, 163, 184, 0.7)';
1297
+ ctx.font = uiFont(15, 500);
1298
+ ctx.textAlign = 'left';
1299
+ ctx.fillText(`Atualizado em: ${updatedAt}`, margin, footerY);
1300
+ ctx.textAlign = 'right';
1301
+ ctx.fillText('Powered by OmniZap', width - margin, footerY);
1302
+ ctx.textAlign = 'left';
1303
+
1304
+ return canvas.toBuffer('image/png');
1305
+ };