@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.
- package/.clusterfuzzlite/Dockerfile +10 -0
- package/.env.example +907 -0
- package/.github/codeql/codeql-config.yml +10 -0
- package/.github/dependabot.yml +35 -0
- package/.github/workflows/ci.yml +73 -0
- package/.github/workflows/codeql.yml +106 -0
- package/.github/workflows/db-migration-check.yml +98 -0
- package/.github/workflows/dependency-review.yml +22 -0
- package/.github/workflows/deploy.yml +95 -0
- package/.github/workflows/release.yml +106 -0
- package/.github/workflows/security-attest-provenance.yml +51 -0
- package/.github/workflows/security-gitleaks.yml +34 -0
- package/.github/workflows/security-runner-hardening.yml +31 -0
- package/.github/workflows/security-scorecard.yml +44 -0
- package/.github/workflows/security-zap-baseline.yml +44 -0
- package/.github/workflows/security-zap-full-scan.yml +43 -0
- package/.github/workflows/security-zizmor.yml +36 -0
- package/.github/workflows/wiki-sync.yml +44 -0
- package/.gitleaks.toml +15 -0
- package/.prettierrc +34 -0
- package/CODE_OF_CONDUCT.md +114 -0
- package/LICENSE +56 -0
- package/README.md +110 -0
- package/SECURITY.md +110 -0
- package/app/config/index.js +4 -0
- package/app/configParts/adminIdentity.js +92 -0
- package/app/configParts/baileysConfig.js +1818 -0
- package/app/configParts/groupUtils.js +692 -0
- package/app/configParts/loggerConfig.js +394 -0
- package/app/configParts/messagePersistenceService.js +305 -0
- package/app/connection/baileysCompatibility.test.js +40 -0
- package/app/connection/baileysDbAuthState.js +344 -0
- package/app/connection/socketController.js +2243 -0
- package/app/controllers/messageController.js +7 -0
- package/app/controllers/messagePipeline/commandMiddleware.js +146 -0
- package/app/controllers/messagePipeline/conversationMiddleware.js +183 -0
- package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +522 -0
- package/app/controllers/messagePipeline/postProcessingMiddleware.js +41 -0
- package/app/controllers/messagePipeline/preProcessingMiddlewares.js +166 -0
- package/app/controllers/messageProcessingPipeline.js +699 -0
- package/app/modules/adminModule/AGENT.md +4056 -0
- package/app/modules/adminModule/adminAiHelpService.js +56 -0
- package/app/modules/adminModule/adminConfigRuntime.js +177 -0
- package/app/modules/adminModule/commandConfig.json +7122 -0
- package/app/modules/adminModule/groupCommandHandlers.js +1823 -0
- package/app/modules/adminModule/groupCommandHandlers.test.js +350 -0
- package/app/modules/adminModule/groupEventHandlers.js +399 -0
- package/app/modules/aiModule/AGENT.md +547 -0
- package/app/modules/aiModule/aiAiHelpService.js +14 -0
- package/app/modules/aiModule/aiConfigRuntime.js +135 -0
- package/app/modules/aiModule/catCommand.js +967 -0
- package/app/modules/aiModule/commandConfig.json +981 -0
- package/app/modules/analyticsModule/messageAnalysisEventRepository.js +83 -0
- package/app/modules/gameModule/AGENT.md +196 -0
- package/app/modules/gameModule/commandConfig.json +366 -0
- package/app/modules/gameModule/diceCommand.js +42 -0
- package/app/modules/gameModule/gameAiHelpService.js +14 -0
- package/app/modules/gameModule/gameConfigRuntime.js +68 -0
- package/app/modules/menuModule/AGENT.md +205 -0
- package/app/modules/menuModule/commandConfig.json +366 -0
- package/app/modules/menuModule/common.js +316 -0
- package/app/modules/menuModule/menuAiHelpService.js +14 -0
- package/app/modules/menuModule/menuConfigRuntime.js +68 -0
- package/app/modules/menuModule/menus.js +66 -0
- package/app/modules/playModule/AGENT.md +321 -0
- package/app/modules/playModule/commandConfig.json +584 -0
- package/app/modules/playModule/playAiHelpService.js +14 -0
- package/app/modules/playModule/playCommand.js +1417 -0
- package/app/modules/playModule/playConfigRuntime.js +68 -0
- package/app/modules/quoteModule/AGENT.md +199 -0
- package/app/modules/quoteModule/commandConfig.json +366 -0
- package/app/modules/quoteModule/quoteAiHelpService.js +14 -0
- package/app/modules/quoteModule/quoteCommand.js +842 -0
- package/app/modules/quoteModule/quoteConfigRuntime.js +68 -0
- package/app/modules/rpgPokemonModule/AGENT.md +229 -0
- package/app/modules/rpgPokemonModule/commandConfig.json +386 -0
- package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +795 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.js +2110 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.test.js +770 -0
- package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
- package/app/modules/rpgPokemonModule/rpgPokemonAiHelpService.js +14 -0
- package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +174 -0
- package/app/modules/rpgPokemonModule/rpgPokemonConfigRuntime.js +68 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
- package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
- package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
- package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1847 -0
- package/app/modules/rpgPokemonModule/rpgPokemonService.js +6839 -0
- package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
- package/app/modules/statsModule/AGENT.md +320 -0
- package/app/modules/statsModule/commandConfig.json +540 -0
- package/app/modules/statsModule/globalRankingCommand.js +64 -0
- package/app/modules/statsModule/rankingCommand.js +41 -0
- package/app/modules/statsModule/rankingCommon.js +1305 -0
- package/app/modules/statsModule/statsAiHelpService.js +14 -0
- package/app/modules/statsModule/statsConfigRuntime.js +68 -0
- package/app/modules/stickerModule/AGENT.md +692 -0
- package/app/modules/stickerModule/addStickerMetadata.js +239 -0
- package/app/modules/stickerModule/commandConfig.json +1216 -0
- package/app/modules/stickerModule/convertToWebp.js +367 -0
- package/app/modules/stickerModule/stickerAiHelpService.js +14 -0
- package/app/modules/stickerModule/stickerCommand.js +446 -0
- package/app/modules/stickerModule/stickerConfigRuntime.js +68 -0
- package/app/modules/stickerModule/stickerConvertCommand.js +159 -0
- package/app/modules/stickerModule/stickerTextCommand.js +653 -0
- package/app/modules/stickerPackModule/AGENT.md +215 -0
- package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
- package/app/modules/stickerPackModule/autoPackCollectorService.js +357 -0
- package/app/modules/stickerPackModule/commandConfig.json +387 -0
- package/app/modules/stickerPackModule/domainEventOutboxRepository.js +227 -0
- package/app/modules/stickerPackModule/domainEvents.js +52 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.js +429 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +75 -0
- package/app/modules/stickerPackModule/semanticThemeClusterService.js +544 -0
- package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +400 -0
- package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
- package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +175 -0
- package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +3702 -0
- package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +559 -0
- package/app/modules/stickerPackModule/stickerClassificationService.js +557 -0
- package/app/modules/stickerPackModule/stickerDedicatedTaskWorkerRuntime.js +249 -0
- package/app/modules/stickerPackModule/stickerDomainEventBus.js +65 -0
- package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +208 -0
- package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +99 -0
- package/app/modules/stickerPackModule/stickerObjectStorageService.js +285 -0
- package/app/modules/stickerPackModule/stickerPackAiHelpService.js +14 -0
- package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1148 -0
- package/app/modules/stickerPackModule/stickerPackConfigRuntime.js +68 -0
- package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +152 -0
- package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
- package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +101 -0
- package/app/modules/stickerPackModule/stickerPackItemRepository.js +432 -0
- package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +313 -0
- package/app/modules/stickerPackModule/stickerPackMessageService.js +268 -0
- package/app/modules/stickerPackModule/stickerPackRepository.js +450 -0
- package/app/modules/stickerPackModule/stickerPackScoreSnapshotRepository.js +179 -0
- package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +271 -0
- package/app/modules/stickerPackModule/stickerPackService.js +733 -0
- package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +32 -0
- package/app/modules/stickerPackModule/stickerPackUtils.js +107 -0
- package/app/modules/stickerPackModule/stickerStorageService.js +559 -0
- package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +242 -0
- package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +242 -0
- package/app/modules/systemMetricsModule/AGENT.md +193 -0
- package/app/modules/systemMetricsModule/commandConfig.json +344 -0
- package/app/modules/systemMetricsModule/pingCommand.js +399 -0
- package/app/modules/systemMetricsModule/systemMetricsAiHelpService.js +14 -0
- package/app/modules/systemMetricsModule/systemMetricsConfigRuntime.js +68 -0
- package/app/modules/tiktokModule/AGENT.md +196 -0
- package/app/modules/tiktokModule/commandConfig.json +366 -0
- package/app/modules/tiktokModule/tiktokAiHelpService.js +14 -0
- package/app/modules/tiktokModule/tiktokCommand.js +716 -0
- package/app/modules/tiktokModule/tiktokConfigRuntime.js +68 -0
- package/app/modules/userModule/AGENT.md +200 -0
- package/app/modules/userModule/commandConfig.json +386 -0
- package/app/modules/userModule/userAiHelpService.js +14 -0
- package/app/modules/userModule/userCommand.js +1155 -0
- package/app/modules/userModule/userConfigRuntime.js +68 -0
- package/app/modules/waifuPicsModule/AGENT.md +431 -0
- package/app/modules/waifuPicsModule/commandConfig.json +780 -0
- package/app/modules/waifuPicsModule/waifuPicsAiHelpService.js +14 -0
- package/app/modules/waifuPicsModule/waifuPicsCommand.js +586 -0
- package/app/modules/waifuPicsModule/waifuPicsConfigRuntime.js +68 -0
- package/app/observability/metrics.js +766 -0
- package/app/services/ai/aiHelpResponseCacheRepository.js +280 -0
- package/app/services/ai/aiLearningRepository.js +400 -0
- package/app/services/ai/commandConfigEnrichmentRepository.js +769 -0
- package/app/services/ai/commandConfigEnrichmentService.js +452 -0
- package/app/services/ai/commandConfigValidationService.js +443 -0
- package/app/services/ai/commandToolBuilderService.js +192 -0
- package/app/services/ai/conversationRouterService.js +516 -0
- package/app/services/ai/geminiService.js +115 -0
- package/app/services/ai/geminiService.test.js +87 -0
- package/app/services/ai/globalModuleAiHelpService.js +1412 -0
- package/app/services/ai/globalToolCallingService.js +203 -0
- package/app/services/ai/messageCommandExecutionService.js +391 -0
- package/app/services/ai/moduleAiHelpCoreService.js +1099 -0
- package/app/services/ai/moduleAiHelpWrapperFactory.js +65 -0
- package/app/services/ai/moduleCommandConfigRuntimeService.js +113 -0
- package/app/services/ai/moduleToolExecutorService.js +464 -0
- package/app/services/ai/moduleToolRegistryService.js +178 -0
- package/app/services/ai/toolCandidateSelectorService.js +781 -0
- package/app/services/auth/googleWebLinkService.js +80 -0
- package/app/services/auth/whatsappLoginLinkService.js +230 -0
- package/app/services/external/pokeApiService.js +398 -0
- package/app/services/group/groupMetadataService.js +311 -0
- package/app/services/infra/dbWriteQueue.js +874 -0
- package/app/services/infra/featureFlagService.js +131 -0
- package/app/services/infra/queueUtils.js +55 -0
- package/app/services/messaging/captchaService.js +491 -0
- package/app/services/messaging/messagePersistenceService.js +1 -0
- package/app/services/messaging/newsBroadcastService.js +347 -0
- package/app/services/sticker/stickerFocusService.js +347 -0
- package/app/services/sticker/stickerFocusService.test.js +43 -0
- package/app/store/aiPromptStore.js +38 -0
- package/app/store/conversationSessionStore.js +131 -0
- package/app/store/groupConfigStore.js +58 -0
- package/app/store/premiumUserStore.js +54 -0
- package/app/utils/antiLink/antiLinkModule.js +700 -0
- package/app/utils/http/getImageBufferModule.js +18 -0
- package/app/utils/json/jsonSanitizer.js +113 -0
- package/app/utils/json/jsonSanitizer.test.js +40 -0
- package/app/utils/systemMetrics/systemMetricsModule.js +88 -0
- package/app/workers/aiLearningWorker.js +605 -0
- package/app/workers/commandConfigEnrichmentWorker.js +242 -0
- package/database/index.js +2075 -0
- package/database/init.js +151 -0
- package/database/migrations/.gitkeep +0 -0
- package/database/migrations/20260307_d0_hardening_down.sql +64 -0
- package/database/migrations/20260307_d0_hardening_up.sql +79 -0
- package/database/migrations/20260307_d1_terms_acceptance_down.sql +11 -0
- package/database/migrations/20260307_d1_terms_acceptance_up.sql +37 -0
- package/database/migrations/20260307_d2_auth_hardening_down.sql +75 -0
- package/database/migrations/20260307_d2_auth_hardening_up.sql +100 -0
- package/database/migrations/20260314_d7_canonical_sender_down.sql +53 -0
- package/database/migrations/20260314_d7_canonical_sender_up.sql +114 -0
- package/database/migrations/20260406_d30_security_analytics_down.sql +95 -0
- package/database/migrations/20260406_d30_security_analytics_up.sql +292 -0
- package/database/migrations/20260407_d31_web_google_session_token_hardening_down.sql +2 -0
- package/database/migrations/20260407_d31_web_google_session_token_hardening_up.sql +17 -0
- package/database/migrations/20260408_d32_ai_help_response_cache_down.sql +1 -0
- package/database/migrations/20260408_d32_ai_help_response_cache_up.sql +22 -0
- package/database/migrations/20260409_d33_ai_learning_tables_down.sql +4 -0
- package/database/migrations/20260409_d33_ai_learning_tables_up.sql +52 -0
- package/database/migrations/20260410_d34_command_config_enrichment_down.sql +3 -0
- package/database/migrations/20260410_d34_command_config_enrichment_up.sql +48 -0
- package/database/schema.sql +1186 -0
- package/docker-compose.yml +104 -0
- package/docs/audits/stickerCatalogController-out-of-scope.md +103 -0
- package/docs/audits/stickerCatalogController-symbols.md +58 -0
- package/docs/compliance/acceptable-use-policy-2026-03-07.md +35 -0
- package/docs/compliance/dpa-b2b-standard-2026-03-07.md +80 -0
- package/docs/compliance/monthly-compliance-checklist-2026-03-07.md +88 -0
- package/docs/compliance/notice-and-takedown-policy-2026-03-07.md +34 -0
- package/docs/compliance/privacy-policy-2026-03-07.md +75 -0
- package/docs/compliance/subprocessors-inventory-2026-03-07.md +16 -0
- package/docs/database/production-db-evolution-runbook-2026q1.md +365 -0
- package/docs/security/dsar-lgpd-runbook-2026-03-07.md +86 -0
- package/docs/security/incident-response-lgpd-anpd-runbook-2026-03-07.md +77 -0
- package/docs/security/network-hardening-runbook-2026-03-07.md +137 -0
- package/docs/seo/omnizap-seo-playbook-br-2026-02-28.md +238 -0
- package/docs/seo/satellite-page-template.md +116 -0
- package/docs/seo/satellite-pages-phase1.json +364 -0
- package/docs/wiki/Home.md +120 -0
- package/docs/wiki/pair-extraordinaire-2026-03-08.md +3 -0
- package/docs/wiki/recent-changes-2026-03-08.md +47 -0
- package/ecosystem.prod.config.cjs +135 -0
- package/eslint.config.js +89 -0
- package/index.js +488 -0
- package/ml/clip_classifier/Dockerfile +18 -0
- package/ml/clip_classifier/README.md +118 -0
- package/ml/clip_classifier/adaptive_scoring.py +40 -0
- package/ml/clip_classifier/classifier.py +654 -0
- package/ml/clip_classifier/embedding_store.py +481 -0
- package/ml/clip_classifier/env_loader.py +15 -0
- package/ml/clip_classifier/llm_label_expander.py +144 -0
- package/ml/clip_classifier/main.py +213 -0
- package/ml/clip_classifier/requirements.txt +10 -0
- package/ml/clip_classifier/similarity_engine.py +74 -0
- package/new-logo.png +0 -0
- package/observability/alert-rules.yml +60 -0
- package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
- package/observability/grafana/dashboards/omnizap-overview.json +170 -0
- package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
- package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
- package/observability/loki-config.yml +38 -0
- package/observability/mysql-setup.sql +46 -0
- package/observability/prometheus.yml +35 -0
- package/observability/promtail-config.yml +84 -0
- package/observability/sticker-catalog-slo.md +83 -0
- package/observability/sticker-scale-hardening-rollout.md +128 -0
- package/package.json +144 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/css/commands-react.input.css +71 -0
- package/public/assets/css/create-pack-react.input.css +31 -0
- package/public/assets/css/home-react.input.css +106 -0
- package/public/assets/css/login-react.input.css +58 -0
- package/public/assets/css/stickers-react.input.css +18 -0
- package/public/assets/css/terms-react.input.css +115 -0
- package/public/assets/css/user-react.input.css +57 -0
- package/public/assets/images/brand-icon-192.png +0 -0
- package/public/assets/images/brand-logo-128.webp +0 -0
- package/public/assets/images/hero-banner-1280.jpg +0 -0
- package/public/comandos/commands-catalog.json +4517 -0
- package/public/css/api-docs.css +161 -0
- package/public/css/stickers-admin.css +1288 -0
- package/public/css/styles.css +679 -0
- package/public/css/systemadm/admin.css +474 -0
- package/public/css/systemadm/base.css +73 -0
- package/public/css/systemadm/components.css +662 -0
- package/public/css/systemadm/layout.css +229 -0
- package/public/css/systemadm/tokens.css +56 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/js/apps/apiDocsApp.js +235 -0
- package/public/js/apps/commandsReactApp.js +528 -0
- package/public/js/apps/createPackApp.js +1646 -0
- package/public/js/apps/homeReactApp.js +942 -0
- package/public/js/apps/loginReactApp.js +496 -0
- package/public/js/apps/stickersAdminApp.js +1753 -0
- package/public/js/apps/stickersApp.js +3797 -0
- package/public/js/apps/termsReactApp.js +528 -0
- package/public/js/apps/userApp.js +2540 -0
- package/public/js/apps/userProfile/actions.js +66 -0
- package/public/js/apps/userReactApp.js +547 -0
- package/public/js/catalog.js +950 -0
- package/public/pages/api-docs.html +40 -0
- package/public/pages/aup.html +158 -0
- package/public/pages/comandos.html +41 -0
- package/public/pages/dpa.html +227 -0
- package/public/pages/home.html +45 -0
- package/public/pages/licenca.html +182 -0
- package/public/pages/login.html +40 -0
- package/public/pages/notice-and-takedown.html +234 -0
- package/public/pages/politica-de-privacidade.html +251 -0
- package/public/pages/seo-bot-whatsapp-para-grupo.html +350 -0
- package/public/pages/seo-bot-whatsapp-sem-programar.html +350 -0
- package/public/pages/seo-como-automatizar-avisos-no-whatsapp.html +350 -0
- package/public/pages/seo-como-criar-comandos-whatsapp.html +350 -0
- package/public/pages/seo-como-evitar-spam-no-whatsapp.html +350 -0
- package/public/pages/seo-como-moderar-grupo-whatsapp.html +350 -0
- package/public/pages/seo-como-organizar-comunidade-whatsapp.html +350 -0
- package/public/pages/seo-melhor-bot-whatsapp-para-grupos.html +350 -0
- package/public/pages/stickers-admin.html +31 -0
- package/public/pages/stickers-create.html +41 -0
- package/public/pages/stickers.html +45 -0
- package/public/pages/suboperadores.html +237 -0
- package/public/pages/termos-de-uso-texto-integral.html +241 -0
- package/public/pages/termos-de-uso.html +41 -0
- package/public/pages/user-password-reset.html +32 -0
- package/public/pages/user-systemadm.html +508 -0
- package/public/pages/user.html +39 -0
- package/public/robots.txt +9 -0
- package/public/site.webmanifest +24 -0
- package/public/sitemap.xml +98 -0
- package/schemas/command-config.schema.json +582 -0
- package/scripts/baileys-compat-smoke.mjs +12 -0
- package/scripts/cache-bust.mjs +142 -0
- package/scripts/deploy.sh +916 -0
- package/scripts/email-broadcast-terms-update.mjs +170 -0
- package/scripts/enrich-command-discovery-fields.mjs +286 -0
- package/scripts/generate-command-config-schema.mjs +273 -0
- package/scripts/generate-commands-catalog.mjs +308 -0
- package/scripts/generate-module-agents.mjs +631 -0
- package/scripts/generate-seo-satellite-pages.mjs +400 -0
- package/scripts/github-deploy-notify.mjs +174 -0
- package/scripts/github-release-notify.mjs +219 -0
- package/scripts/release.sh +599 -0
- package/scripts/run-codeql-local.sh +116 -0
- package/scripts/run-prettier-all.mjs +25 -0
- package/scripts/security-smoketest.mjs +581 -0
- package/scripts/sticker-catalog-loadtest.mjs +210 -0
- package/scripts/sticker-worker-task.mjs +119 -0
- package/scripts/sync-readme-snapshot.mjs +133 -0
- package/scripts/validate-command-config-schema.mjs +130 -0
- package/scripts/validate-command-configs.mjs +15 -0
- package/scripts/wiki-sync.sh +191 -0
- package/server/auth/googleWebAuth/googleWebAuthRuntime.js +62 -0
- package/server/auth/googleWebAuth/googleWebAuthService.js +807 -0
- package/server/auth/jwt/webJwtService.js +147 -0
- package/server/auth/stickerCatalogAuthContext.js +165 -0
- package/server/auth/termsAcceptance/termsAcceptanceHandler.js +189 -0
- package/server/auth/userPassword/index.js +14 -0
- package/server/auth/userPassword/userPasswordAuthService.js +422 -0
- package/server/auth/userPassword/userPasswordCrypto.js +199 -0
- package/server/auth/userPassword/userPasswordCrypto.test.js +76 -0
- package/server/auth/userPassword/userPasswordRecoveryService.js +728 -0
- package/server/auth/validation/authSchemas.js +236 -0
- package/server/auth/webAccount/webAccountHandlers.js +1434 -0
- package/server/controllers/admin/adminBanService.js +138 -0
- package/server/controllers/admin/adminPanelHandlers.js +2083 -0
- package/server/controllers/admin/stickerCatalogAdminContext.js +17 -0
- package/server/controllers/admin/systemAdminController.js +201 -0
- package/server/controllers/email/emailAutomationController.js +239 -0
- package/server/controllers/metricsController.js +21 -0
- package/server/controllers/seo/stickerCatalogSeoContext.js +514 -0
- package/server/controllers/sticker/nonCatalogHandlers.js +303 -0
- package/server/controllers/sticker/stickerCatalogController.js +4700 -0
- package/server/controllers/system/contactController.js +115 -0
- package/server/controllers/system/githubController.js +137 -0
- package/server/controllers/system/stickerCatalogSystemContext.js +758 -0
- package/server/controllers/system/storageController.js +154 -0
- package/server/controllers/system/systemController.js +135 -0
- package/server/controllers/system/systemMetricsController.js +156 -0
- package/server/controllers/system/visitController.js +90 -0
- package/server/controllers/userController.js +145 -0
- package/server/email/emailAutomationRuntime.js +225 -0
- package/server/email/emailAutomationService.js +125 -0
- package/server/email/emailOutboxRepository.js +282 -0
- package/server/email/emailTemplateService.js +480 -0
- package/server/email/emailTransportService.js +156 -0
- package/server/http/clientIp.js +95 -0
- package/server/http/httpRequestUtils.js +262 -0
- package/server/http/httpRequestUtils.test.js +80 -0
- package/server/http/httpServer.js +180 -0
- package/server/http/requestContext.js +20 -0
- package/server/http/siteRoutingUtils.js +87 -0
- package/server/index.js +1 -0
- package/server/middleware/cachePolicy.js +26 -0
- package/server/middleware/cachePolicyHelpers.js +1 -0
- package/server/middleware/endpointRateLimit.js +181 -0
- package/server/middleware/rateLimit.js +70 -0
- package/server/middleware/requireAdminAuth.js +48 -0
- package/server/middleware/securityHeaders.js +97 -0
- package/server/routes/admin/systemAdminRouter.js +64 -0
- package/server/routes/email/emailAutomationRouter.js +46 -0
- package/server/routes/health/healthRouter.js +41 -0
- package/server/routes/indexRouter.js +234 -0
- package/server/routes/metrics/metricsRouter.js +58 -0
- package/server/routes/static/staticPageRouter.js +134 -0
- package/server/routes/sticker/catalogHandlers/catalogAdminHttp.js +105 -0
- package/server/routes/sticker/catalogHandlers/catalogAuthHttp.js +77 -0
- package/server/routes/sticker/catalogHandlers/catalogPublicHttp.js +120 -0
- package/server/routes/sticker/catalogHandlers/catalogUploadHttp.js +83 -0
- package/server/routes/sticker/catalogRouter.js +77 -0
- package/server/routes/sticker/stickerApiRouter.js +84 -0
- package/server/routes/sticker/stickerDataRouter.js +145 -0
- package/server/routes/sticker/stickerSiteRouter.js +43 -0
- package/server/routes/user/userApiPaths.js +66 -0
- package/server/routes/user/userRouter.js +65 -0
- package/server/utils/safePath.js +26 -0
- package/utils/logger/loggerModule.js +35 -0
- 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
|
+
};
|