@omnizap-system/omnizap 2.6.0 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +58 -13
- package/.github/workflows/ci.yml +5 -5
- package/.github/workflows/codeql.yml +1 -1
- package/.github/workflows/db-migration-check.yml +2 -2
- package/.github/workflows/dependency-review.yml +1 -1
- package/.github/workflows/deploy.yml +2 -2
- package/.github/workflows/release.yml +2 -2
- package/.github/workflows/security-attest-provenance.yml +2 -2
- package/.github/workflows/security-gitleaks.yml +13 -4
- package/.github/workflows/security-runner-hardening.yml +2 -2
- package/.github/workflows/security-scorecard.yml +1 -1
- package/.github/workflows/security-zap-baseline.yml +1 -1
- package/.github/workflows/security-zap-full-scan.yml +2 -1
- package/.github/workflows/security-zizmor.yml +1 -1
- package/.github/workflows/wiki-sync.yml +1 -1
- package/.gitleaksignore +9 -0
- package/CODE_OF_CONDUCT.md +2 -2
- package/GEMINI.md +64 -0
- package/README.md +52 -82
- package/SECURITY.md +1 -1
- package/app/config/index.js +2 -0
- package/app/configParts/adminIdentity.js +5 -5
- package/app/configParts/baileysConfig.js +230 -58
- package/app/configParts/groupUtils.js +5 -0
- package/app/configParts/messagePersistenceService.js +145 -4
- package/app/configParts/sessionConfig.js +157 -0
- package/app/connection/baileysCompatibility.test.js +1 -1
- package/app/connection/groupOwnerWriteStateResolver.js +109 -0
- package/app/connection/socketController.js +660 -158
- package/app/connection/socketController.multiSession.test.js +108 -0
- package/app/controllers/messageController.js +1 -1
- package/app/controllers/messagePipeline/commandMiddleware.js +12 -10
- package/app/controllers/messagePipeline/conversationMiddleware.js +2 -1
- package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +104 -0
- package/app/controllers/messagePipeline/preProcessingMiddlewares.js +80 -2
- package/app/controllers/messageProcessingPipeline.js +93 -13
- package/app/controllers/messageProcessingPipeline.test.js +200 -0
- package/app/modules/adminModule/AGENT.md +1 -1
- package/app/modules/adminModule/commandConfig.json +3318 -1347
- package/app/modules/adminModule/groupCommandHandlers.js +858 -15
- package/app/modules/adminModule/groupCommandHandlers.test.js +378 -11
- package/app/modules/adminModule/groupWarningRepository.js +152 -0
- package/app/modules/aiModule/AGENT.md +47 -30
- package/app/modules/aiModule/aiConfigRuntime.js +1 -0
- package/app/modules/aiModule/catCommand.js +135 -27
- package/app/modules/aiModule/commandConfig.json +114 -28
- package/app/modules/analyticsModule/messageAnalysisEventRepository.js +54 -6
- package/app/modules/gameModule/AGENT.md +1 -1
- package/app/modules/gameModule/commandConfig.json +29 -0
- package/app/modules/menuModule/AGENT.md +1 -1
- package/app/modules/menuModule/commandConfig.json +45 -10
- package/app/modules/menuModule/menuCatalogService.js +190 -0
- package/app/modules/menuModule/menuCommandUsageRepository.js +109 -0
- package/app/modules/menuModule/menuDynamicService.js +511 -0
- package/app/modules/menuModule/menuDynamicService.test.js +141 -0
- package/app/modules/menuModule/menus.js +36 -5
- package/app/modules/playModule/AGENT.md +10 -5
- package/app/modules/playModule/commandConfig.json +140 -12
- package/app/modules/playModule/playCommand.js +1 -1417
- package/app/modules/playModule/playCommandConstants.js +80 -0
- package/app/modules/playModule/playCommandCore.js +361 -0
- package/app/modules/playModule/playCommandHandlers.js +41 -0
- package/app/modules/playModule/playCommandMediaClient.js +1872 -0
- package/app/modules/playModule/playConfigRuntime.js +245 -4
- package/app/modules/playModule/playModuleCriticalFlows.test.js +152 -0
- package/app/modules/quoteModule/AGENT.md +1 -1
- package/app/modules/quoteModule/commandConfig.json +29 -0
- package/app/modules/quoteModule/quoteCommand.js +3 -2
- package/app/modules/rpgPokemonModule/AGENT.md +1 -1
- package/app/modules/rpgPokemonModule/commandConfig.json +29 -0
- package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +5 -4
- package/app/modules/rpgPokemonModule/rpgBattleService.test.js +2 -1
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +2 -1
- package/app/modules/rpgPokemonModule/rpgPokemonService.js +38 -37
- package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +4 -3
- package/app/modules/statsModule/AGENT.md +1 -1
- package/app/modules/statsModule/commandConfig.json +58 -0
- package/app/modules/statsModule/rankingCommon.js +5 -4
- package/app/modules/stickerModule/AGENT.md +1 -1
- package/app/modules/stickerModule/addStickerMetadata.js +4 -3
- package/app/modules/stickerModule/commandConfig.json +145 -0
- package/app/modules/stickerModule/stickerCommand.js +1 -1
- package/app/modules/stickerPackModule/AGENT.md +1 -1
- package/app/modules/stickerPackModule/autoPackCollectorService.js +5 -1
- package/app/modules/stickerPackModule/commandConfig.json +29 -0
- package/app/modules/stickerPackModule/semanticThemeClusterService.js +7 -6
- package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +10 -9
- package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +9 -8
- package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +3 -2
- package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +2 -1
- package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +80 -58
- package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +2 -1
- package/app/modules/stickerPackModule/stickerPackRepository.js +2 -1
- package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +5 -4
- package/app/modules/stickerPackModule/stickerPackService.js +13 -6
- package/app/modules/stickerPackModule/stickerStorageService.js +3 -2
- package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +2 -1
- package/app/modules/systemMetricsModule/AGENT.md +1 -1
- package/app/modules/systemMetricsModule/commandConfig.json +29 -0
- package/app/modules/systemMetricsModule/pingCommand.js +6 -5
- package/app/modules/tiktokModule/AGENT.md +1 -1
- package/app/modules/tiktokModule/commandConfig.json +29 -0
- package/app/modules/tiktokModule/tiktokCommand.js +2 -1
- package/app/modules/userModule/AGENT.md +1 -1
- package/app/modules/userModule/commandConfig.json +29 -0
- package/app/modules/userModule/userCommand.js +72 -23
- package/app/modules/waifuPicsModule/AGENT.md +57 -27
- package/app/modules/waifuPicsModule/commandConfig.json +87 -0
- package/app/modules/waifuPicsModule/waifuPicsCommand.js +3 -2
- package/app/observability/metrics.js +136 -0
- package/app/services/ai/commandConfigEnrichmentService.js +229 -47
- package/app/services/ai/conversationRouterService.js +4 -3
- package/app/services/ai/geminiService.js +132 -7
- package/app/services/ai/geminiService.test.js +59 -2
- package/app/services/ai/globalModuleAiHelpService.js +3 -2
- package/app/services/ai/messageCommandExecutionService.js +2 -1
- package/app/services/ai/moduleAiHelpCoreService.js +45 -14
- package/app/services/ai/moduleToolExecutorService.js +3 -2
- package/app/services/ai/moduleToolRegistryService.js +2 -1
- package/app/services/ai/toolCandidateSelectorService.js +6 -5
- package/app/services/auth/googleWebLinkService.js +3 -2
- package/app/services/auth/whatsappLoginLinkService.js +3 -2
- package/app/services/external/pokeApiService.js +4 -3
- package/app/services/group/groupMetadataService.js +24 -1
- package/app/services/infra/dbWriteQueue.js +57 -26
- package/app/services/infra/featureFlagService.js +2 -1
- package/app/services/messaging/captchaService.js +3 -2
- package/app/services/messaging/newsBroadcastService.js +846 -29
- package/app/services/multiSession/assignmentBalancerService.js +457 -0
- package/app/services/multiSession/groupOwnershipRepository.js +381 -0
- package/app/services/multiSession/groupOwnershipService.js +890 -0
- package/app/services/multiSession/groupOwnershipService.test.js +309 -0
- package/app/services/multiSession/sessionRegistryService.js +293 -0
- package/app/services/sticker/stickerFocusService.js +11 -10
- package/app/store/aiPromptStore.js +36 -19
- package/app/store/conversationSessionStore.js +7 -6
- package/app/store/groupConfigStore.js +41 -5
- package/app/store/premiumUserStore.js +21 -7
- package/app/utils/antiLink/antiLinkModule.js +352 -16
- package/app/workers/aiHelperContinuousLearningWorker.js +512 -0
- package/app/workers/aiLearningWorker.js +6 -5
- package/app/workers/commandConfigEnrichmentWorker.js +4 -3
- package/database/index.js +14 -8
- package/database/migrations/20260307_d0_hardening_down.sql +1 -1
- package/database/migrations/20260314_d7_canonical_sender_down.sql +1 -1
- package/database/migrations/20260406_d30_security_analytics_down.sql +1 -1
- package/database/migrations/20260411_d35_group_community_metadata_down.sql +59 -0
- package/database/migrations/20260411_d35_group_community_metadata_up.sql +62 -0
- package/database/migrations/20260412_d36_system_config_tables_down.sql +32 -0
- package/database/migrations/20260412_d36_system_config_tables_up.sql +66 -0
- package/database/migrations/20260413_d37_group_user_warnings_down.sql +11 -0
- package/database/migrations/20260413_d37_group_user_warnings_up.sql +24 -0
- package/database/migrations/20260414_d38_multi_session_foundation_down.sql +72 -0
- package/database/migrations/20260414_d38_multi_session_foundation_up.sql +125 -0
- package/database/migrations/20260414_d39_multi_session_cutover_down.sql +103 -0
- package/database/migrations/20260414_d39_multi_session_cutover_up.sql +83 -0
- package/database/schema.sql +102 -1
- package/docker-compose.yml +4 -1
- package/docs/compliance/acceptable-use-policy-2026-03-07.md +1 -1
- package/docs/compliance/dpa-b2b-standard-2026-03-07.md +1 -1
- package/docs/compliance/privacy-policy-2026-03-07.md +4 -4
- package/docs/security/dsar-lgpd-runbook-2026-03-07.md +1 -1
- package/docs/security/incident-response-lgpd-anpd-runbook-2026-03-07.md +1 -1
- package/docs/security/network-hardening-runbook-2026-03-07.md +53 -0
- package/docs/security/omnizap-static-security-headers.conf +25 -0
- package/docs/wiki/Home.md +1 -1
- package/ecosystem.prod.config.cjs +32 -12
- package/index.js +57 -23
- package/observability/alert-rules.yml +20 -0
- package/observability/grafana/dashboards/omnizap-system-admin.json +229 -0
- package/observability/mysql-setup.sql +4 -4
- package/observability/system-admin-observability.md +26 -0
- package/package.json +20 -6
- package/public/apple-touch-icon.png +0 -0
- package/public/comandos/commands-catalog.json +2853 -3326
- 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 +3 -2
- package/public/js/apps/commandsReactApp.js +280 -99
- package/public/js/apps/createPackApp.js +11 -10
- package/public/js/apps/homeReactApp.js +181 -130
- package/public/js/apps/loginReactApp.js +1 -1
- package/public/js/apps/stickersApp.js +263 -110
- package/public/js/apps/termsReactApp.js +73 -24
- package/public/js/apps/userApp.js +4 -3
- package/public/js/apps/userPasswordResetReactApp.js +406 -0
- package/public/js/apps/userReactApp.js +355 -280
- package/public/js/apps/userSystemAdmReactApp.js +1506 -0
- package/public/pages/api-docs.html +1 -1
- package/public/pages/aup.html +2 -2
- package/public/pages/dpa.html +3 -3
- package/public/pages/licenca.html +4 -4
- package/public/pages/login.html +1 -1
- package/public/pages/notice-and-takedown.html +2 -2
- package/public/pages/politica-de-privacidade.html +6 -6
- package/public/pages/seo-bot-whatsapp-para-grupo.html +3 -3
- package/public/pages/seo-bot-whatsapp-sem-programar.html +3 -3
- package/public/pages/seo-como-automatizar-avisos-no-whatsapp.html +3 -3
- package/public/pages/seo-como-criar-comandos-whatsapp.html +3 -3
- package/public/pages/seo-como-evitar-spam-no-whatsapp.html +3 -3
- package/public/pages/seo-como-moderar-grupo-whatsapp.html +3 -3
- package/public/pages/seo-como-organizar-comunidade-whatsapp.html +3 -3
- package/public/pages/seo-melhor-bot-whatsapp-para-grupos.html +3 -3
- package/public/pages/stickers-admin.html +1 -1
- package/public/pages/stickers-create.html +1 -1
- package/public/pages/stickers.html +6 -6
- package/public/pages/suboperadores.html +2 -2
- package/public/pages/termos-de-uso-texto-integral.html +6 -6
- package/public/pages/termos-de-uso.html +3 -3
- package/public/pages/user-password-reset.html +4 -5
- package/public/pages/user-systemadm.html +9 -463
- package/public/pages/user.html +2 -2
- package/scripts/clear-whatsapp-session.sh +123 -0
- package/scripts/core-ai-mode.mjs +163 -0
- package/scripts/deploy.sh +11 -1
- package/scripts/email-broadcast-terms-update.mjs +2 -1
- package/scripts/enrich-command-config-ux-openai.mjs +492 -0
- package/scripts/generate-commands-catalog.mjs +166 -2
- package/scripts/generate-module-agents.mjs +2 -1
- package/scripts/generate-seo-satellite-pages.mjs +5 -4
- package/scripts/github-deploy-notify.mjs +2 -1
- package/scripts/github-release-notify.mjs +25 -10
- package/scripts/new-whatsapp-session.sh +317 -0
- package/scripts/release.sh +2 -19
- package/scripts/security-smoketest.mjs +6 -5
- package/scripts/security-web-surface-check.mjs +218 -0
- package/scripts/sticker-catalog-loadtest.mjs +5 -4
- package/server/auth/googleWebAuth/googleWebAuthService.js +8 -7
- package/server/auth/jwt/webJwtService.js +1 -1
- package/server/auth/stickerCatalogAuthContext.js +2 -1
- package/server/auth/termsAcceptance/termsAcceptanceHandler.js +2 -1
- package/server/auth/userPassword/userPasswordAuthService.js +2 -1
- package/server/auth/userPassword/userPasswordRecoveryService.js +4 -3
- package/server/auth/webAccount/webAccountHandlers.js +9 -10
- package/server/controllers/admin/adminPanelHandlers.js +267 -16
- package/server/controllers/admin/systemAdminController.js +267 -0
- package/server/controllers/seo/stickerCatalogSeoContext.js +10 -9
- package/server/controllers/sticker/nonCatalogHandlers.js +2 -1
- package/server/controllers/sticker/stickerCatalogController.js +23 -36
- package/server/controllers/system/contactController.js +9 -17
- package/server/controllers/system/githubController.js +3 -2
- package/server/controllers/system/stickerCatalogSystemContext.js +41 -19
- package/server/controllers/system/systemController.js +254 -1
- package/server/controllers/system/systemMetricsController.js +2 -1
- package/server/controllers/userController.js +6 -0
- package/server/email/emailTemplateService.js +5 -3
- package/server/http/httpServer.js +11 -6
- package/server/middleware/rateLimit.js +2 -1
- package/server/middleware/securityHeaders.js +20 -1
- package/server/routes/admin/systemAdminRouter.js +6 -0
- package/server/routes/indexRouter.js +30 -6
- package/server/routes/observability/grafanaProxyRouter.js +254 -0
- package/server/routes/static/staticPageRouter.js +27 -1
- package/server/utils/publicContact.js +31 -0
- package/utils/time/timeModule.js +135 -0
- package/utils/time/timeModule.test.js +65 -0
- package/utils/whatsapp/contactEnv.js +39 -0
- package/vite.config.mjs +7 -1
- package/public/assets/images/brand-icon-192.png +0 -0
- package/scripts/sync-readme-snapshot.mjs +0 -133
|
@@ -9,6 +9,7 @@ import { getStickerSiteRouterConfig, maybeHandleStickerSiteRequest, shouldHandle
|
|
|
9
9
|
import { getStickerDataRouterConfig, maybeHandleStickerDataRequest, shouldHandleStickerDataPath } from './sticker/stickerDataRouter.js';
|
|
10
10
|
import { getStickerApiRouterConfig, maybeHandleStickerApiRequest, shouldHandleStickerApiPath } from './sticker/stickerApiRouter.js';
|
|
11
11
|
import { maybeHandleStaticPageRequest, shouldHandleStaticPagePath } from './static/staticPageRouter.js';
|
|
12
|
+
import { getGrafanaProxyRouterConfig, maybeHandleGrafanaProxyRequest, shouldHandleGrafanaProxyPath } from './observability/grafanaProxyRouter.js';
|
|
12
13
|
|
|
13
14
|
const startsWithPath = (pathname, prefix) => {
|
|
14
15
|
if (!pathname || !prefix) return false;
|
|
@@ -112,12 +113,27 @@ const loadStickerApiConfigSafe = async () => {
|
|
|
112
113
|
}
|
|
113
114
|
};
|
|
114
115
|
|
|
116
|
+
const loadGrafanaProxyConfigSafe = async () => {
|
|
117
|
+
try {
|
|
118
|
+
return getGrafanaProxyRouterConfig();
|
|
119
|
+
} catch {
|
|
120
|
+
return {
|
|
121
|
+
enabled: false,
|
|
122
|
+
basePath: '/api/grafana',
|
|
123
|
+
legacyBasePath: '/grafana',
|
|
124
|
+
timeoutMs: 15000,
|
|
125
|
+
target: null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
115
130
|
export const getIndexRouteConfigs = async () => {
|
|
116
131
|
if (!indexRouteConfigsPromise) {
|
|
117
|
-
indexRouteConfigsPromise = Promise.all([loadUserConfigSafe(), loadSystemAdminConfigSafe(), loadEmailAutomationConfigSafe(), loadStickerSiteConfigSafe(), loadStickerDataConfigSafe(), loadStickerApiConfigSafe()]).then(([userConfig, systemAdminConfig, emailAutomationConfig, stickerSiteConfig, stickerDataConfig, stickerApiConfig]) => ({
|
|
132
|
+
indexRouteConfigsPromise = Promise.all([loadUserConfigSafe(), loadSystemAdminConfigSafe(), loadEmailAutomationConfigSafe(), loadStickerSiteConfigSafe(), loadStickerDataConfigSafe(), loadStickerApiConfigSafe(), loadGrafanaProxyConfigSafe()]).then(([userConfig, systemAdminConfig, emailAutomationConfig, stickerSiteConfig, stickerDataConfig, stickerApiConfig, grafanaProxyConfig]) => ({
|
|
118
133
|
userConfig,
|
|
119
134
|
systemAdminConfig,
|
|
120
135
|
emailAutomationConfig,
|
|
136
|
+
grafanaProxyConfig,
|
|
121
137
|
stickerConfig: {
|
|
122
138
|
...stickerSiteConfig,
|
|
123
139
|
...stickerDataConfig,
|
|
@@ -140,6 +156,7 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
|
|
|
140
156
|
const userConfig = resolvedConfigs?.userConfig || null;
|
|
141
157
|
const systemAdminConfig = resolvedConfigs?.systemAdminConfig || null;
|
|
142
158
|
const emailAutomationConfig = resolvedConfigs?.emailAutomationConfig || null;
|
|
159
|
+
const grafanaProxyConfig = resolvedConfigs?.grafanaProxyConfig || null;
|
|
143
160
|
const stickerConfig = resolvedConfigs?.stickerConfig || null;
|
|
144
161
|
|
|
145
162
|
// 1) Metrics
|
|
@@ -163,7 +180,14 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
|
|
|
163
180
|
return sendNotFound(req, res);
|
|
164
181
|
}
|
|
165
182
|
|
|
166
|
-
// 4)
|
|
183
|
+
// 4) Grafana proxy (/api/grafana e alias /grafana)
|
|
184
|
+
if (shouldHandleGrafanaProxyPath(pathname, grafanaProxyConfig)) {
|
|
185
|
+
const handled = await maybeHandleGrafanaProxyRequest(req, res, { pathname, url, config: grafanaProxyConfig });
|
|
186
|
+
if (handled) return true;
|
|
187
|
+
return sendNotFound(req, res);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 5) User
|
|
167
191
|
const systemAdminCandidate = shouldHandleSystemAdminStep(pathname, systemAdminConfig);
|
|
168
192
|
if (shouldHandleUserStep(pathname, userConfig)) {
|
|
169
193
|
const handled = await maybeHandleUserRequest(req, res, { pathname, url });
|
|
@@ -173,14 +197,14 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
|
|
|
173
197
|
if (!systemAdminCandidate) return sendNotFound(req, res);
|
|
174
198
|
}
|
|
175
199
|
|
|
176
|
-
//
|
|
200
|
+
// 6) System admin + legacy /stickers/admin
|
|
177
201
|
if (systemAdminCandidate) {
|
|
178
202
|
const handled = await maybeHandleSystemAdminRequest(req, res, { pathname, url });
|
|
179
203
|
if (handled) return true;
|
|
180
204
|
return sendNotFound(req, res);
|
|
181
205
|
}
|
|
182
206
|
|
|
183
|
-
//
|
|
207
|
+
// 7) Sticker catalog apenas nos prefixes permitidos
|
|
184
208
|
if (shouldHandleStickerSitePath(pathname, stickerConfig)) {
|
|
185
209
|
const handled = await maybeHandleStickerSiteRequest(req, res, { pathname, url });
|
|
186
210
|
if (handled) return true;
|
|
@@ -212,14 +236,14 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
|
|
|
212
236
|
return sendNotFound(req, res);
|
|
213
237
|
}
|
|
214
238
|
|
|
215
|
-
//
|
|
239
|
+
// 8) Paginas estaticas (templates em public/pages)
|
|
216
240
|
if (shouldHandleStaticPagePath(pathname)) {
|
|
217
241
|
const handled = await maybeHandleStaticPageRequest(req, res, { pathname });
|
|
218
242
|
if (handled) return true;
|
|
219
243
|
return sendNotFound(req, res);
|
|
220
244
|
}
|
|
221
245
|
|
|
222
|
-
//
|
|
246
|
+
// 9) 404 global
|
|
223
247
|
return sendNotFound(req, res);
|
|
224
248
|
};
|
|
225
249
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { URL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import logger from '#logger';
|
|
6
|
+
|
|
7
|
+
const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
|
|
8
|
+
|
|
9
|
+
const DEFAULT_GRAFANA_PROXY_BASE_PATH = '/api/grafana';
|
|
10
|
+
const DEFAULT_GRAFANA_PROXY_LEGACY_BASE_PATH = '/grafana';
|
|
11
|
+
const DEFAULT_GRAFANA_PROXY_TIMEOUT_MS = 15000;
|
|
12
|
+
|
|
13
|
+
const normalizeBasePath = (value, fallback) => {
|
|
14
|
+
const raw = String(value || '').trim() || fallback;
|
|
15
|
+
const withLeadingSlash = raw.startsWith('/') ? raw : `/${raw}`;
|
|
16
|
+
const withoutTrailingSlash = withLeadingSlash.length > 1 && withLeadingSlash.endsWith('/') ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
|
|
17
|
+
return withoutTrailingSlash || fallback;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const startsWithPath = (pathname, prefix) => {
|
|
21
|
+
if (!pathname || !prefix) return false;
|
|
22
|
+
if (pathname === prefix) return true;
|
|
23
|
+
return pathname.startsWith(`${prefix}/`);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const parseEnvBool = (value, fallback = true) => {
|
|
27
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
28
|
+
const normalized = String(value).trim().toLowerCase();
|
|
29
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
|
|
30
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
|
|
31
|
+
return fallback;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const parseEnvNumber = (value, fallback) => {
|
|
35
|
+
const parsed = Number(value);
|
|
36
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const normalizeTargetUrl = (rawUrl) => {
|
|
40
|
+
const raw = String(rawUrl || '').trim();
|
|
41
|
+
if (!raw) return null;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = new URL(raw);
|
|
44
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
|
45
|
+
parsed.search = '';
|
|
46
|
+
parsed.hash = '';
|
|
47
|
+
parsed.pathname = String(parsed.pathname || '/').replace(/\/+$/, '') || '/';
|
|
48
|
+
return parsed;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const normalizeHeaderValue = (value) => {
|
|
55
|
+
if (Array.isArray(value))
|
|
56
|
+
return value
|
|
57
|
+
.map((item) => String(item || '').trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join(', ');
|
|
60
|
+
return String(value || '').trim();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const getDefaultTargetUrl = () => {
|
|
64
|
+
const port = parseEnvNumber(process.env.GRAFANA_PORT, 3003);
|
|
65
|
+
return `http://127.0.0.1:${port}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const joinUrlPath = (leftPath, rightPath) => {
|
|
69
|
+
const left = String(leftPath || '').replace(/\/+$/, '');
|
|
70
|
+
const right = String(rightPath || '').replace(/^\/+/, '');
|
|
71
|
+
if (!left) return `/${right}`.replace(/\/{2,}/g, '/');
|
|
72
|
+
if (!right) return left.startsWith('/') ? left : `/${left}`;
|
|
73
|
+
const joined = `${left}/${right}`.replace(/\/{2,}/g, '/');
|
|
74
|
+
return joined.startsWith('/') ? joined : `/${joined}`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const buildUpstreamPath = ({ pathname, search = '', matchedBasePath, upstreamBasePath, canonicalBasePath }) => {
|
|
78
|
+
const suffix = pathname === matchedBasePath ? '' : pathname.slice(matchedBasePath.length);
|
|
79
|
+
const normalizedSuffix = suffix ? (suffix.startsWith('/') ? suffix : `/${suffix}`) : '';
|
|
80
|
+
const canonicalPath = `${canonicalBasePath}${normalizedSuffix}` || canonicalBasePath;
|
|
81
|
+
const path = joinUrlPath(upstreamBasePath === '/' ? '' : upstreamBasePath, canonicalPath);
|
|
82
|
+
return `${path}${search || ''}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const getMatchedBasePath = (pathname, config) => {
|
|
86
|
+
if (!config?.enabled) return null;
|
|
87
|
+
if (startsWithPath(pathname, config.basePath)) return config.basePath;
|
|
88
|
+
if (config.legacyBasePath && startsWithPath(pathname, config.legacyBasePath)) return config.legacyBasePath;
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const copyResponseHeaders = (res, upstreamHeaders = {}) => {
|
|
93
|
+
for (const [name, value] of Object.entries(upstreamHeaders)) {
|
|
94
|
+
if (value === undefined) continue;
|
|
95
|
+
const lower = String(name || '').toLowerCase();
|
|
96
|
+
if (!lower || HOP_BY_HOP_HEADERS.has(lower)) continue;
|
|
97
|
+
res.setHeader(name, value);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const buildUpstreamHeaders = (req, { matchedBasePath, target }) => {
|
|
102
|
+
const headers = {};
|
|
103
|
+
for (const [name, value] of Object.entries(req.headers || {})) {
|
|
104
|
+
if (value === undefined) continue;
|
|
105
|
+
const lower = String(name || '').toLowerCase();
|
|
106
|
+
if (!lower || lower === 'host' || HOP_BY_HOP_HEADERS.has(lower)) continue;
|
|
107
|
+
headers[lower] = value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const incomingForwardedFor = normalizeHeaderValue(req.headers?.['x-forwarded-for']);
|
|
111
|
+
const remoteAddress = String(req.socket?.remoteAddress || '').trim();
|
|
112
|
+
const forwardedFor = [incomingForwardedFor, remoteAddress].filter(Boolean).join(', ');
|
|
113
|
+
if (forwardedFor) headers['x-forwarded-for'] = forwardedFor;
|
|
114
|
+
|
|
115
|
+
const forwardedProto = normalizeHeaderValue(req.headers?.['x-forwarded-proto']) || (req.socket?.encrypted ? 'https' : 'http');
|
|
116
|
+
headers['x-forwarded-proto'] = forwardedProto;
|
|
117
|
+
|
|
118
|
+
const incomingHost = normalizeHeaderValue(req.headers?.host);
|
|
119
|
+
const forwardedHost = normalizeHeaderValue(req.headers?.['x-forwarded-host']) || incomingHost;
|
|
120
|
+
if (forwardedHost) headers['x-forwarded-host'] = forwardedHost;
|
|
121
|
+
|
|
122
|
+
headers['x-forwarded-prefix'] = matchedBasePath;
|
|
123
|
+
headers.host = incomingHost || target.host;
|
|
124
|
+
return headers;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const pipeToUpstream = ({ req, res, target, targetPathWithQuery, timeoutMs, headers }) =>
|
|
128
|
+
new Promise((resolve, reject) => {
|
|
129
|
+
let settled = false;
|
|
130
|
+
const finalize = (error = null) => {
|
|
131
|
+
if (settled) return;
|
|
132
|
+
settled = true;
|
|
133
|
+
if (error) reject(error);
|
|
134
|
+
else resolve();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const transport = target.protocol === 'https:' ? https : http;
|
|
138
|
+
const upstreamReq = transport.request(
|
|
139
|
+
{
|
|
140
|
+
protocol: target.protocol,
|
|
141
|
+
hostname: target.hostname,
|
|
142
|
+
port: target.port || (target.protocol === 'https:' ? 443 : 80),
|
|
143
|
+
method: req.method,
|
|
144
|
+
path: targetPathWithQuery,
|
|
145
|
+
headers,
|
|
146
|
+
},
|
|
147
|
+
(upstreamRes) => {
|
|
148
|
+
res.statusCode = Number(upstreamRes.statusCode) || 502;
|
|
149
|
+
if (upstreamRes.statusMessage) res.statusMessage = upstreamRes.statusMessage;
|
|
150
|
+
copyResponseHeaders(res, upstreamRes.headers);
|
|
151
|
+
|
|
152
|
+
if (String(req.method || '').toUpperCase() === 'HEAD') {
|
|
153
|
+
upstreamRes.resume();
|
|
154
|
+
upstreamRes.once('end', () => {
|
|
155
|
+
if (!res.writableEnded) res.end();
|
|
156
|
+
finalize();
|
|
157
|
+
});
|
|
158
|
+
upstreamRes.once('error', finalize);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
upstreamRes.once('error', finalize);
|
|
163
|
+
res.once('error', finalize);
|
|
164
|
+
upstreamRes.pipe(res);
|
|
165
|
+
upstreamRes.once('end', finalize);
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
upstreamReq.setTimeout(timeoutMs, () => {
|
|
170
|
+
upstreamReq.destroy(new Error(`Grafana proxy timeout (${timeoutMs}ms)`));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
upstreamReq.once('error', finalize);
|
|
174
|
+
req.once('error', (error) => upstreamReq.destroy(error));
|
|
175
|
+
req.once('aborted', () => upstreamReq.destroy(new Error('Client aborted request')));
|
|
176
|
+
|
|
177
|
+
if (['GET', 'HEAD'].includes(String(req.method || '').toUpperCase())) {
|
|
178
|
+
upstreamReq.end();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
req.pipe(upstreamReq);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
export const getGrafanaProxyRouterConfig = () => {
|
|
186
|
+
const enabled = parseEnvBool(process.env.GRAFANA_PROXY_ENABLED, true);
|
|
187
|
+
const basePath = normalizeBasePath(process.env.GRAFANA_PROXY_BASE_PATH, DEFAULT_GRAFANA_PROXY_BASE_PATH);
|
|
188
|
+
const legacyBasePath = normalizeBasePath(process.env.GRAFANA_PROXY_LEGACY_BASE_PATH, DEFAULT_GRAFANA_PROXY_LEGACY_BASE_PATH);
|
|
189
|
+
const timeoutMs = parseEnvNumber(process.env.GRAFANA_PROXY_TIMEOUT_MS, DEFAULT_GRAFANA_PROXY_TIMEOUT_MS);
|
|
190
|
+
const target = normalizeTargetUrl(process.env.GRAFANA_PROXY_TARGET_URL || getDefaultTargetUrl());
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
enabled: Boolean(enabled && target),
|
|
194
|
+
basePath,
|
|
195
|
+
legacyBasePath,
|
|
196
|
+
timeoutMs,
|
|
197
|
+
target,
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const shouldHandleGrafanaProxyPath = (pathname, config = null) => Boolean(getMatchedBasePath(pathname, config || getGrafanaProxyRouterConfig()));
|
|
202
|
+
|
|
203
|
+
const sendProxyError = (req, res, message) => {
|
|
204
|
+
if (res.writableEnded) return;
|
|
205
|
+
res.statusCode = 502;
|
|
206
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
207
|
+
if (req.method === 'HEAD') {
|
|
208
|
+
res.end();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
res.end(JSON.stringify({ error: message }));
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const maybeHandleGrafanaProxyRequest = async (req, res, { pathname, url, config = null } = {}) => {
|
|
215
|
+
const resolvedConfig = config || getGrafanaProxyRouterConfig();
|
|
216
|
+
const matchedBasePath = getMatchedBasePath(pathname, resolvedConfig);
|
|
217
|
+
if (!matchedBasePath) return false;
|
|
218
|
+
|
|
219
|
+
if (!resolvedConfig?.target) {
|
|
220
|
+
sendProxyError(req, res, 'Grafana proxy indisponivel.');
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const targetPathWithQuery = buildUpstreamPath({
|
|
225
|
+
pathname,
|
|
226
|
+
search: url?.search || '',
|
|
227
|
+
matchedBasePath,
|
|
228
|
+
upstreamBasePath: resolvedConfig.target.pathname || '/',
|
|
229
|
+
canonicalBasePath: resolvedConfig.basePath,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const headers = buildUpstreamHeaders(req, { matchedBasePath, target: resolvedConfig.target });
|
|
234
|
+
await pipeToUpstream({
|
|
235
|
+
req,
|
|
236
|
+
res,
|
|
237
|
+
target: resolvedConfig.target,
|
|
238
|
+
targetPathWithQuery,
|
|
239
|
+
timeoutMs: resolvedConfig.timeoutMs,
|
|
240
|
+
headers,
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
logger.warn('Falha ao encaminhar request para o Grafana.', {
|
|
244
|
+
action: 'grafana_proxy_failed',
|
|
245
|
+
path: pathname,
|
|
246
|
+
method: req.method,
|
|
247
|
+
target_path: targetPathWithQuery,
|
|
248
|
+
error: error?.message,
|
|
249
|
+
});
|
|
250
|
+
sendProxyError(req, res, 'Grafana indisponivel no momento.');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return true;
|
|
254
|
+
};
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import logger from '#logger';
|
|
5
|
+
import { buildWhatsappUrl, formatWhatsappDisplay, resolvePublicWhatsappNumber } from '../../utils/publicContact.js';
|
|
5
6
|
|
|
6
7
|
const normalizeBasePath = (value, fallback) => {
|
|
7
8
|
const raw = String(value || '').trim() || fallback;
|
|
@@ -21,6 +22,11 @@ const PUBLIC_PAGES_DIR = path.join(process.cwd(), 'public', 'pages');
|
|
|
21
22
|
const SEO_ROUTE_PREFIX = '/seo/';
|
|
22
23
|
const SEO_SLUG_REGEX = /^[a-z0-9-]+$/;
|
|
23
24
|
const INDEX_FILE_SUFFIX = '/index.html';
|
|
25
|
+
const LGPD_DEFAULT_TEXT = 'Olá, gostaria de exercer meus direitos de titular de dados (LGPD).';
|
|
26
|
+
const SUPPORT_WHATSAPP_NUMBER = resolvePublicWhatsappNumber();
|
|
27
|
+
const SUPPORT_WHATSAPP_URL = buildWhatsappUrl(SUPPORT_WHATSAPP_NUMBER);
|
|
28
|
+
const SUPPORT_WHATSAPP_URL_LGPD = buildWhatsappUrl(SUPPORT_WHATSAPP_NUMBER, process.env.WHATSAPP_SUPPORT_LGPD_TEXT || LGPD_DEFAULT_TEXT);
|
|
29
|
+
const SUPPORT_WHATSAPP_DISPLAY = formatWhatsappDisplay(SUPPORT_WHATSAPP_NUMBER);
|
|
24
30
|
|
|
25
31
|
const STATIC_PAGE_ROUTE_TO_FILE = new Map(
|
|
26
32
|
[
|
|
@@ -89,6 +95,26 @@ const sendJson = (req, res, statusCode, payload) => {
|
|
|
89
95
|
res.end(body);
|
|
90
96
|
};
|
|
91
97
|
|
|
98
|
+
const injectStaticTemplateTokens = (html) => {
|
|
99
|
+
let rendered = String(html || '');
|
|
100
|
+
const replacements = {
|
|
101
|
+
__WHATSAPP_SUPPORT_NUMBER__: SUPPORT_WHATSAPP_NUMBER,
|
|
102
|
+
__WHATSAPP_SUPPORT_URL__: SUPPORT_WHATSAPP_URL,
|
|
103
|
+
__WHATSAPP_SUPPORT_URL_LGPD__: SUPPORT_WHATSAPP_URL_LGPD,
|
|
104
|
+
__WHATSAPP_SUPPORT_DISPLAY__: SUPPORT_WHATSAPP_DISPLAY,
|
|
105
|
+
__WHATSAPP_PUBLIC_CONTACT_NUMBER__: SUPPORT_WHATSAPP_NUMBER,
|
|
106
|
+
__WHATSAPP_PUBLIC_CONTACT_URL__: SUPPORT_WHATSAPP_URL,
|
|
107
|
+
__WHATSAPP_PUBLIC_CONTACT_URL_LGPD__: SUPPORT_WHATSAPP_URL_LGPD,
|
|
108
|
+
__WHATSAPP_PUBLIC_CONTACT_DISPLAY__: SUPPORT_WHATSAPP_DISPLAY,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (const [token, value] of Object.entries(replacements)) {
|
|
112
|
+
rendered = rendered.replaceAll(token, String(value || ''));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return rendered;
|
|
116
|
+
};
|
|
117
|
+
|
|
92
118
|
export const shouldHandleStaticPagePath = (pathname) => Boolean(resolveStaticTemplateName(pathname));
|
|
93
119
|
|
|
94
120
|
export const maybeHandleStaticPageRequest = async (req, res, { pathname } = {}) => {
|
|
@@ -116,7 +142,7 @@ export const maybeHandleStaticPageRequest = async (req, res, { pathname } = {})
|
|
|
116
142
|
const templatePath = path.join(PUBLIC_PAGES_DIR, templateName);
|
|
117
143
|
try {
|
|
118
144
|
const html = await fs.readFile(templatePath, 'utf8');
|
|
119
|
-
sendHtml(req, res, html);
|
|
145
|
+
sendHtml(req, res, injectStaticTemplateTokens(html));
|
|
120
146
|
} catch (error) {
|
|
121
147
|
if (error?.code === 'ENOENT') {
|
|
122
148
|
sendJson(req, res, 404, { error: 'Template da pagina nao encontrado.' });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isLikelyWhatsAppPhone, normalizePhoneDigits, resolveSupportPhoneFromEnv } from '../../utils/whatsapp/contactEnv.js';
|
|
2
|
+
|
|
3
|
+
const FALLBACK_WHATSAPP_SUPPORT_NUMBER = '5511999999999';
|
|
4
|
+
|
|
5
|
+
export { normalizePhoneDigits };
|
|
6
|
+
|
|
7
|
+
export const resolvePublicWhatsappNumber = ({ fallback = FALLBACK_WHATSAPP_SUPPORT_NUMBER } = {}) => resolveSupportPhoneFromEnv({ fallback });
|
|
8
|
+
|
|
9
|
+
export const formatWhatsappDisplay = (value) => {
|
|
10
|
+
const digits = normalizePhoneDigits(value || '');
|
|
11
|
+
if (!isLikelyWhatsAppPhone(digits)) return '';
|
|
12
|
+
|
|
13
|
+
if (digits.startsWith('55') && digits.length === 12) {
|
|
14
|
+
return `+55 ${digits.slice(2, 4)} ${digits.slice(4, 8)}-${digits.slice(8)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (digits.startsWith('55') && digits.length === 13) {
|
|
18
|
+
return `+55 ${digits.slice(2, 4)} ${digits.slice(4, 9)}-${digits.slice(9)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return `+${digits}`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const buildWhatsappUrl = (value, text = '') => {
|
|
25
|
+
const digits = normalizePhoneDigits(value || '');
|
|
26
|
+
if (!isLikelyWhatsAppPhone(digits)) return '';
|
|
27
|
+
|
|
28
|
+
const normalizedText = String(text || '').trim();
|
|
29
|
+
if (!normalizedText) return `https://wa.me/${digits}`;
|
|
30
|
+
return `https://wa.me/${digits}?text=${encodeURIComponent(normalizedText)}`;
|
|
31
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { DateTime, Duration } from 'luxon';
|
|
2
|
+
|
|
3
|
+
function resolveTimeZone(timeZone = 'UTC') {
|
|
4
|
+
if (typeof timeZone !== 'string' || timeZone.trim().length === 0) {
|
|
5
|
+
return 'UTC';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return timeZone;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeDateTime(dateInput = new Date(), { locale = 'pt-BR', timeZone = 'UTC' } = {}) {
|
|
12
|
+
const zone = resolveTimeZone(timeZone);
|
|
13
|
+
let dateTime = null;
|
|
14
|
+
|
|
15
|
+
if (DateTime.isDateTime(dateInput)) {
|
|
16
|
+
dateTime = dateInput.setZone(zone);
|
|
17
|
+
} else if (dateInput instanceof Date) {
|
|
18
|
+
dateTime = DateTime.fromJSDate(dateInput, { zone });
|
|
19
|
+
} else if (typeof dateInput === 'number') {
|
|
20
|
+
dateTime = DateTime.fromMillis(dateInput, { zone });
|
|
21
|
+
} else if (typeof dateInput === 'string') {
|
|
22
|
+
dateTime = DateTime.fromISO(dateInput, { zone });
|
|
23
|
+
|
|
24
|
+
if (!dateTime.isValid) {
|
|
25
|
+
const parsedDate = new Date(dateInput);
|
|
26
|
+
if (!Number.isNaN(parsedDate.getTime())) {
|
|
27
|
+
dateTime = DateTime.fromJSDate(parsedDate, { zone });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
dateTime = DateTime.fromJSDate(new Date(dateInput), { zone });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!dateTime || !dateTime.isValid) {
|
|
35
|
+
dateTime = DateTime.now().setZone(zone);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return dateTime.setLocale(locale);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function now() {
|
|
42
|
+
return new Date();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function nowIso() {
|
|
46
|
+
return DateTime.utc().toISO();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function toUnixMs(dateInput = new Date()) {
|
|
50
|
+
return normalizeDateTime(dateInput, { locale: 'en-US', timeZone: 'UTC' }).toMillis();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toUnixSeconds(dateInput = new Date()) {
|
|
54
|
+
return Math.floor(toUnixMs(dateInput) / 1000);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function elapsedMs(startDateInput, endDateInput = new Date()) {
|
|
58
|
+
return toUnixMs(endDateInput) - toUnixMs(startDateInput);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatInTimeZone(dateInput = new Date(), { locale = 'pt-BR', timeZone = 'UTC', options = {} } = {}) {
|
|
62
|
+
return normalizeDateTime(dateInput, { locale, timeZone }).toLocaleString(options);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatTimeAmPm(dateInput = new Date(), { locale = 'en-US', timeZone = 'UTC', includeSeconds = false } = {}) {
|
|
66
|
+
return normalizeDateTime(dateInput, { locale, timeZone }).toFormat(includeSeconds ? 'hh:mm:ss a' : 'hh:mm a');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatDateTimeExtenso(dateInput = new Date(), { locale = 'pt-BR', timeZone = 'UTC', includeWeekday = true } = {}) {
|
|
70
|
+
return normalizeDateTime(dateInput, { locale, timeZone }).toLocaleString({
|
|
71
|
+
...(includeWeekday ? { weekday: 'long' } : {}),
|
|
72
|
+
day: 'numeric',
|
|
73
|
+
month: 'long',
|
|
74
|
+
year: 'numeric',
|
|
75
|
+
hour: '2-digit',
|
|
76
|
+
minute: '2-digit',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatTimeExtenso(dateInput = new Date(), { locale = 'pt-BR', timeZone = 'UTC', includeSeconds = false } = {}) {
|
|
81
|
+
const dateTime = normalizeDateTime(dateInput, { locale, timeZone });
|
|
82
|
+
const duration = Duration.fromObject(
|
|
83
|
+
{
|
|
84
|
+
hours: dateTime.hour,
|
|
85
|
+
minutes: dateTime.minute,
|
|
86
|
+
...(includeSeconds ? { seconds: dateTime.second } : {}),
|
|
87
|
+
},
|
|
88
|
+
{ locale },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return duration.toHuman({
|
|
92
|
+
unitDisplay: 'long',
|
|
93
|
+
listStyle: 'long',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildTimeFormats(dateInput = new Date(), { locale = 'pt-BR', timeZone = 'UTC' } = {}) {
|
|
98
|
+
const iso = normalizeDateTime(dateInput, { locale: 'en-US', timeZone: 'UTC' }).toUTC().toISO();
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
iso,
|
|
102
|
+
unixMs: toUnixMs(dateInput),
|
|
103
|
+
unixSeconds: toUnixSeconds(dateInput),
|
|
104
|
+
amPm: formatTimeAmPm(dateInput, {
|
|
105
|
+
locale: 'en-US',
|
|
106
|
+
timeZone,
|
|
107
|
+
includeSeconds: false,
|
|
108
|
+
}),
|
|
109
|
+
extenso: formatDateTimeExtenso(dateInput, {
|
|
110
|
+
locale,
|
|
111
|
+
timeZone,
|
|
112
|
+
includeWeekday: true,
|
|
113
|
+
}),
|
|
114
|
+
horaExtenso: formatTimeExtenso(dateInput, {
|
|
115
|
+
locale,
|
|
116
|
+
timeZone,
|
|
117
|
+
includeSeconds: false,
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const timeModule = {
|
|
123
|
+
now,
|
|
124
|
+
nowIso,
|
|
125
|
+
toUnixMs,
|
|
126
|
+
toUnixSeconds,
|
|
127
|
+
elapsedMs,
|
|
128
|
+
formatInTimeZone,
|
|
129
|
+
formatTimeAmPm,
|
|
130
|
+
formatDateTimeExtenso,
|
|
131
|
+
formatTimeExtenso,
|
|
132
|
+
buildTimeFormats,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export default timeModule;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { buildTimeFormats, elapsedMs, formatDateTimeExtenso, formatTimeAmPm, formatTimeExtenso, now, nowIso, toUnixMs, toUnixSeconds } from './timeModule.js';
|
|
5
|
+
|
|
6
|
+
const FIXED_ISO = '2026-03-16T15:45:30.000Z';
|
|
7
|
+
|
|
8
|
+
test('now e nowIso retornam valores válidos de data/hora', () => {
|
|
9
|
+
const nowValue = now();
|
|
10
|
+
const nowIsoValue = nowIso();
|
|
11
|
+
|
|
12
|
+
assert.ok(nowValue instanceof Date);
|
|
13
|
+
assert.ok(Number.isFinite(nowValue.getTime()));
|
|
14
|
+
assert.ok(Number.isFinite(Date.parse(nowIsoValue)));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('conversão unix e elapsedMs funcionam conforme esperado', () => {
|
|
18
|
+
assert.equal(toUnixMs(FIXED_ISO), 1773675930000);
|
|
19
|
+
assert.equal(toUnixSeconds(FIXED_ISO), 1773675930);
|
|
20
|
+
assert.equal(elapsedMs('2026-03-16T15:45:00.000Z', FIXED_ISO), 30000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('formatTimeAmPm gera horário em AM/PM', () => {
|
|
24
|
+
const result = formatTimeAmPm(FIXED_ISO, {
|
|
25
|
+
locale: 'en-US',
|
|
26
|
+
timeZone: 'UTC',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
assert.equal(result, '03:45 PM');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('formatDateTimeExtenso gera data/hora por extenso em pt-BR', () => {
|
|
33
|
+
const result = formatDateTimeExtenso(FIXED_ISO, {
|
|
34
|
+
locale: 'pt-BR',
|
|
35
|
+
timeZone: 'America/Sao_Paulo',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.match(result, /16 de março de 2026/i);
|
|
39
|
+
assert.match(result, /12:45/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('formatTimeExtenso gera hora por extenso em pt-BR', () => {
|
|
43
|
+
const result = formatTimeExtenso(FIXED_ISO, {
|
|
44
|
+
locale: 'pt-BR',
|
|
45
|
+
timeZone: 'America/Sao_Paulo',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(result, '12 horas e 45 minutos');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('buildTimeFormats retorna pacote completo de formatos', () => {
|
|
52
|
+
const result = buildTimeFormats(FIXED_ISO, {
|
|
53
|
+
locale: 'pt-BR',
|
|
54
|
+
timeZone: 'America/Sao_Paulo',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(result, {
|
|
58
|
+
iso: '2026-03-16T15:45:30.000Z',
|
|
59
|
+
unixMs: 1773675930000,
|
|
60
|
+
unixSeconds: 1773675930,
|
|
61
|
+
amPm: '12:45 PM',
|
|
62
|
+
extenso: 'segunda-feira, 16 de março de 2026 às 12:45',
|
|
63
|
+
horaExtenso: '12 horas e 45 minutos',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const MAX_PHONE_DIGITS = 20;
|
|
2
|
+
const DEFAULT_FALLBACK_NUMBER = '5511999999999';
|
|
3
|
+
|
|
4
|
+
const BOT_PHONE_ENV_KEYS = ['WHATSAPP_BOT_NUMBER', 'WHATSAPP_SUPPORT_NUMBER', 'BOT_NUMBER', 'BOT_PHONE_NUMBER', 'PHONE_NUMBER'];
|
|
5
|
+
const SUPPORT_PHONE_ENV_KEYS = ['WHATSAPP_SUPPORT_NUMBER', 'WHATSAPP_ADMIN_NUMBER', 'WHATSAPP_PUBLIC_CONTACT_NUMBER', 'EMAIL_BRAND_SUPPORT_PHONE', 'OWNER_NUMBER'];
|
|
6
|
+
const ADMIN_PHONE_ENV_KEYS = ['WHATSAPP_ADMIN_NUMBER', 'WHATSAPP_ADMIN_JID', 'USER_ADMIN', 'OWNER_NUMBER', 'WHATSAPP_SUPPORT_NUMBER', 'WHATSAPP_PUBLIC_CONTACT_NUMBER'];
|
|
7
|
+
const ADMIN_IDENTITY_ENV_KEYS = ['WHATSAPP_ADMIN_JID', 'USER_ADMIN', 'WHATSAPP_ADMIN_NUMBER'];
|
|
8
|
+
|
|
9
|
+
export const normalizePhoneDigits = (value, maxLength = MAX_PHONE_DIGITS) =>
|
|
10
|
+
String(value || '')
|
|
11
|
+
.replace(/\D+/g, '')
|
|
12
|
+
.slice(0, Math.max(1, Number(maxLength) || MAX_PHONE_DIGITS));
|
|
13
|
+
|
|
14
|
+
export const isLikelyWhatsAppPhone = (value) => /^\d{10,15}$/.test(normalizePhoneDigits(value, 20));
|
|
15
|
+
|
|
16
|
+
const resolvePhoneFromEnvKeys = (keys, { fallback = '' } = {}) => {
|
|
17
|
+
for (const envKey of keys) {
|
|
18
|
+
const digits = normalizePhoneDigits(process.env[envKey] || '');
|
|
19
|
+
if (isLikelyWhatsAppPhone(digits)) return digits;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fallbackDigits = normalizePhoneDigits(fallback || '');
|
|
23
|
+
if (isLikelyWhatsAppPhone(fallbackDigits)) return fallbackDigits;
|
|
24
|
+
return '';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const resolveBotPhoneFromEnv = ({ fallback = DEFAULT_FALLBACK_NUMBER } = {}) => resolvePhoneFromEnvKeys(BOT_PHONE_ENV_KEYS, { fallback });
|
|
28
|
+
|
|
29
|
+
export const resolveSupportPhoneFromEnv = ({ fallback = DEFAULT_FALLBACK_NUMBER } = {}) => resolvePhoneFromEnvKeys(SUPPORT_PHONE_ENV_KEYS, { fallback });
|
|
30
|
+
|
|
31
|
+
export const resolveAdminPhoneFromEnv = ({ fallback = DEFAULT_FALLBACK_NUMBER } = {}) => resolvePhoneFromEnvKeys(ADMIN_PHONE_ENV_KEYS, { fallback });
|
|
32
|
+
|
|
33
|
+
export const resolveAdminIdentityRawFromEnv = ({ fallback = '' } = {}) => {
|
|
34
|
+
for (const envKey of ADMIN_IDENTITY_ENV_KEYS) {
|
|
35
|
+
const value = String(process.env[envKey] || '').trim();
|
|
36
|
+
if (value) return value;
|
|
37
|
+
}
|
|
38
|
+
return String(fallback || '').trim();
|
|
39
|
+
};
|