@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.
Files changed (261) hide show
  1. package/.env.example +58 -13
  2. package/.github/workflows/ci.yml +5 -5
  3. package/.github/workflows/codeql.yml +1 -1
  4. package/.github/workflows/db-migration-check.yml +2 -2
  5. package/.github/workflows/dependency-review.yml +1 -1
  6. package/.github/workflows/deploy.yml +2 -2
  7. package/.github/workflows/release.yml +2 -2
  8. package/.github/workflows/security-attest-provenance.yml +2 -2
  9. package/.github/workflows/security-gitleaks.yml +13 -4
  10. package/.github/workflows/security-runner-hardening.yml +2 -2
  11. package/.github/workflows/security-scorecard.yml +1 -1
  12. package/.github/workflows/security-zap-baseline.yml +1 -1
  13. package/.github/workflows/security-zap-full-scan.yml +2 -1
  14. package/.github/workflows/security-zizmor.yml +1 -1
  15. package/.github/workflows/wiki-sync.yml +1 -1
  16. package/.gitleaksignore +9 -0
  17. package/CODE_OF_CONDUCT.md +2 -2
  18. package/GEMINI.md +64 -0
  19. package/README.md +52 -82
  20. package/SECURITY.md +1 -1
  21. package/app/config/index.js +2 -0
  22. package/app/configParts/adminIdentity.js +5 -5
  23. package/app/configParts/baileysConfig.js +230 -58
  24. package/app/configParts/groupUtils.js +5 -0
  25. package/app/configParts/messagePersistenceService.js +145 -4
  26. package/app/configParts/sessionConfig.js +157 -0
  27. package/app/connection/baileysCompatibility.test.js +1 -1
  28. package/app/connection/groupOwnerWriteStateResolver.js +109 -0
  29. package/app/connection/socketController.js +660 -158
  30. package/app/connection/socketController.multiSession.test.js +108 -0
  31. package/app/controllers/messageController.js +1 -1
  32. package/app/controllers/messagePipeline/commandMiddleware.js +12 -10
  33. package/app/controllers/messagePipeline/conversationMiddleware.js +2 -1
  34. package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +104 -0
  35. package/app/controllers/messagePipeline/preProcessingMiddlewares.js +80 -2
  36. package/app/controllers/messageProcessingPipeline.js +93 -13
  37. package/app/controllers/messageProcessingPipeline.test.js +200 -0
  38. package/app/modules/adminModule/AGENT.md +1 -1
  39. package/app/modules/adminModule/commandConfig.json +3318 -1347
  40. package/app/modules/adminModule/groupCommandHandlers.js +858 -15
  41. package/app/modules/adminModule/groupCommandHandlers.test.js +378 -11
  42. package/app/modules/adminModule/groupWarningRepository.js +152 -0
  43. package/app/modules/aiModule/AGENT.md +47 -30
  44. package/app/modules/aiModule/aiConfigRuntime.js +1 -0
  45. package/app/modules/aiModule/catCommand.js +135 -27
  46. package/app/modules/aiModule/commandConfig.json +114 -28
  47. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +54 -6
  48. package/app/modules/gameModule/AGENT.md +1 -1
  49. package/app/modules/gameModule/commandConfig.json +29 -0
  50. package/app/modules/menuModule/AGENT.md +1 -1
  51. package/app/modules/menuModule/commandConfig.json +45 -10
  52. package/app/modules/menuModule/menuCatalogService.js +190 -0
  53. package/app/modules/menuModule/menuCommandUsageRepository.js +109 -0
  54. package/app/modules/menuModule/menuDynamicService.js +511 -0
  55. package/app/modules/menuModule/menuDynamicService.test.js +141 -0
  56. package/app/modules/menuModule/menus.js +36 -5
  57. package/app/modules/playModule/AGENT.md +10 -5
  58. package/app/modules/playModule/commandConfig.json +140 -12
  59. package/app/modules/playModule/playCommand.js +1 -1417
  60. package/app/modules/playModule/playCommandConstants.js +80 -0
  61. package/app/modules/playModule/playCommandCore.js +361 -0
  62. package/app/modules/playModule/playCommandHandlers.js +41 -0
  63. package/app/modules/playModule/playCommandMediaClient.js +1872 -0
  64. package/app/modules/playModule/playConfigRuntime.js +245 -4
  65. package/app/modules/playModule/playModuleCriticalFlows.test.js +152 -0
  66. package/app/modules/quoteModule/AGENT.md +1 -1
  67. package/app/modules/quoteModule/commandConfig.json +29 -0
  68. package/app/modules/quoteModule/quoteCommand.js +3 -2
  69. package/app/modules/rpgPokemonModule/AGENT.md +1 -1
  70. package/app/modules/rpgPokemonModule/commandConfig.json +29 -0
  71. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +5 -4
  72. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +2 -1
  73. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +2 -1
  74. package/app/modules/rpgPokemonModule/rpgPokemonService.js +38 -37
  75. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +4 -3
  76. package/app/modules/statsModule/AGENT.md +1 -1
  77. package/app/modules/statsModule/commandConfig.json +58 -0
  78. package/app/modules/statsModule/rankingCommon.js +5 -4
  79. package/app/modules/stickerModule/AGENT.md +1 -1
  80. package/app/modules/stickerModule/addStickerMetadata.js +4 -3
  81. package/app/modules/stickerModule/commandConfig.json +145 -0
  82. package/app/modules/stickerModule/stickerCommand.js +1 -1
  83. package/app/modules/stickerPackModule/AGENT.md +1 -1
  84. package/app/modules/stickerPackModule/autoPackCollectorService.js +5 -1
  85. package/app/modules/stickerPackModule/commandConfig.json +29 -0
  86. package/app/modules/stickerPackModule/semanticThemeClusterService.js +7 -6
  87. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +10 -9
  88. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +9 -8
  89. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +3 -2
  90. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +2 -1
  91. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +80 -58
  92. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +2 -1
  93. package/app/modules/stickerPackModule/stickerPackRepository.js +2 -1
  94. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +5 -4
  95. package/app/modules/stickerPackModule/stickerPackService.js +13 -6
  96. package/app/modules/stickerPackModule/stickerStorageService.js +3 -2
  97. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +2 -1
  98. package/app/modules/systemMetricsModule/AGENT.md +1 -1
  99. package/app/modules/systemMetricsModule/commandConfig.json +29 -0
  100. package/app/modules/systemMetricsModule/pingCommand.js +6 -5
  101. package/app/modules/tiktokModule/AGENT.md +1 -1
  102. package/app/modules/tiktokModule/commandConfig.json +29 -0
  103. package/app/modules/tiktokModule/tiktokCommand.js +2 -1
  104. package/app/modules/userModule/AGENT.md +1 -1
  105. package/app/modules/userModule/commandConfig.json +29 -0
  106. package/app/modules/userModule/userCommand.js +72 -23
  107. package/app/modules/waifuPicsModule/AGENT.md +57 -27
  108. package/app/modules/waifuPicsModule/commandConfig.json +87 -0
  109. package/app/modules/waifuPicsModule/waifuPicsCommand.js +3 -2
  110. package/app/observability/metrics.js +136 -0
  111. package/app/services/ai/commandConfigEnrichmentService.js +229 -47
  112. package/app/services/ai/conversationRouterService.js +4 -3
  113. package/app/services/ai/geminiService.js +132 -7
  114. package/app/services/ai/geminiService.test.js +59 -2
  115. package/app/services/ai/globalModuleAiHelpService.js +3 -2
  116. package/app/services/ai/messageCommandExecutionService.js +2 -1
  117. package/app/services/ai/moduleAiHelpCoreService.js +45 -14
  118. package/app/services/ai/moduleToolExecutorService.js +3 -2
  119. package/app/services/ai/moduleToolRegistryService.js +2 -1
  120. package/app/services/ai/toolCandidateSelectorService.js +6 -5
  121. package/app/services/auth/googleWebLinkService.js +3 -2
  122. package/app/services/auth/whatsappLoginLinkService.js +3 -2
  123. package/app/services/external/pokeApiService.js +4 -3
  124. package/app/services/group/groupMetadataService.js +24 -1
  125. package/app/services/infra/dbWriteQueue.js +57 -26
  126. package/app/services/infra/featureFlagService.js +2 -1
  127. package/app/services/messaging/captchaService.js +3 -2
  128. package/app/services/messaging/newsBroadcastService.js +846 -29
  129. package/app/services/multiSession/assignmentBalancerService.js +457 -0
  130. package/app/services/multiSession/groupOwnershipRepository.js +381 -0
  131. package/app/services/multiSession/groupOwnershipService.js +890 -0
  132. package/app/services/multiSession/groupOwnershipService.test.js +309 -0
  133. package/app/services/multiSession/sessionRegistryService.js +293 -0
  134. package/app/services/sticker/stickerFocusService.js +11 -10
  135. package/app/store/aiPromptStore.js +36 -19
  136. package/app/store/conversationSessionStore.js +7 -6
  137. package/app/store/groupConfigStore.js +41 -5
  138. package/app/store/premiumUserStore.js +21 -7
  139. package/app/utils/antiLink/antiLinkModule.js +352 -16
  140. package/app/workers/aiHelperContinuousLearningWorker.js +512 -0
  141. package/app/workers/aiLearningWorker.js +6 -5
  142. package/app/workers/commandConfigEnrichmentWorker.js +4 -3
  143. package/database/index.js +14 -8
  144. package/database/migrations/20260307_d0_hardening_down.sql +1 -1
  145. package/database/migrations/20260314_d7_canonical_sender_down.sql +1 -1
  146. package/database/migrations/20260406_d30_security_analytics_down.sql +1 -1
  147. package/database/migrations/20260411_d35_group_community_metadata_down.sql +59 -0
  148. package/database/migrations/20260411_d35_group_community_metadata_up.sql +62 -0
  149. package/database/migrations/20260412_d36_system_config_tables_down.sql +32 -0
  150. package/database/migrations/20260412_d36_system_config_tables_up.sql +66 -0
  151. package/database/migrations/20260413_d37_group_user_warnings_down.sql +11 -0
  152. package/database/migrations/20260413_d37_group_user_warnings_up.sql +24 -0
  153. package/database/migrations/20260414_d38_multi_session_foundation_down.sql +72 -0
  154. package/database/migrations/20260414_d38_multi_session_foundation_up.sql +125 -0
  155. package/database/migrations/20260414_d39_multi_session_cutover_down.sql +103 -0
  156. package/database/migrations/20260414_d39_multi_session_cutover_up.sql +83 -0
  157. package/database/schema.sql +102 -1
  158. package/docker-compose.yml +4 -1
  159. package/docs/compliance/acceptable-use-policy-2026-03-07.md +1 -1
  160. package/docs/compliance/dpa-b2b-standard-2026-03-07.md +1 -1
  161. package/docs/compliance/privacy-policy-2026-03-07.md +4 -4
  162. package/docs/security/dsar-lgpd-runbook-2026-03-07.md +1 -1
  163. package/docs/security/incident-response-lgpd-anpd-runbook-2026-03-07.md +1 -1
  164. package/docs/security/network-hardening-runbook-2026-03-07.md +53 -0
  165. package/docs/security/omnizap-static-security-headers.conf +25 -0
  166. package/docs/wiki/Home.md +1 -1
  167. package/ecosystem.prod.config.cjs +32 -12
  168. package/index.js +57 -23
  169. package/observability/alert-rules.yml +20 -0
  170. package/observability/grafana/dashboards/omnizap-system-admin.json +229 -0
  171. package/observability/mysql-setup.sql +4 -4
  172. package/observability/system-admin-observability.md +26 -0
  173. package/package.json +20 -6
  174. package/public/apple-touch-icon.png +0 -0
  175. package/public/comandos/commands-catalog.json +2853 -3326
  176. package/public/favicon-16x16.png +0 -0
  177. package/public/favicon-32x32.png +0 -0
  178. package/public/favicon.ico +0 -0
  179. package/public/js/apps/apiDocsApp.js +3 -2
  180. package/public/js/apps/commandsReactApp.js +280 -99
  181. package/public/js/apps/createPackApp.js +11 -10
  182. package/public/js/apps/homeReactApp.js +181 -130
  183. package/public/js/apps/loginReactApp.js +1 -1
  184. package/public/js/apps/stickersApp.js +263 -110
  185. package/public/js/apps/termsReactApp.js +73 -24
  186. package/public/js/apps/userApp.js +4 -3
  187. package/public/js/apps/userPasswordResetReactApp.js +406 -0
  188. package/public/js/apps/userReactApp.js +355 -280
  189. package/public/js/apps/userSystemAdmReactApp.js +1506 -0
  190. package/public/pages/api-docs.html +1 -1
  191. package/public/pages/aup.html +2 -2
  192. package/public/pages/dpa.html +3 -3
  193. package/public/pages/licenca.html +4 -4
  194. package/public/pages/login.html +1 -1
  195. package/public/pages/notice-and-takedown.html +2 -2
  196. package/public/pages/politica-de-privacidade.html +6 -6
  197. package/public/pages/seo-bot-whatsapp-para-grupo.html +3 -3
  198. package/public/pages/seo-bot-whatsapp-sem-programar.html +3 -3
  199. package/public/pages/seo-como-automatizar-avisos-no-whatsapp.html +3 -3
  200. package/public/pages/seo-como-criar-comandos-whatsapp.html +3 -3
  201. package/public/pages/seo-como-evitar-spam-no-whatsapp.html +3 -3
  202. package/public/pages/seo-como-moderar-grupo-whatsapp.html +3 -3
  203. package/public/pages/seo-como-organizar-comunidade-whatsapp.html +3 -3
  204. package/public/pages/seo-melhor-bot-whatsapp-para-grupos.html +3 -3
  205. package/public/pages/stickers-admin.html +1 -1
  206. package/public/pages/stickers-create.html +1 -1
  207. package/public/pages/stickers.html +6 -6
  208. package/public/pages/suboperadores.html +2 -2
  209. package/public/pages/termos-de-uso-texto-integral.html +6 -6
  210. package/public/pages/termos-de-uso.html +3 -3
  211. package/public/pages/user-password-reset.html +4 -5
  212. package/public/pages/user-systemadm.html +9 -463
  213. package/public/pages/user.html +2 -2
  214. package/scripts/clear-whatsapp-session.sh +123 -0
  215. package/scripts/core-ai-mode.mjs +163 -0
  216. package/scripts/deploy.sh +11 -1
  217. package/scripts/email-broadcast-terms-update.mjs +2 -1
  218. package/scripts/enrich-command-config-ux-openai.mjs +492 -0
  219. package/scripts/generate-commands-catalog.mjs +166 -2
  220. package/scripts/generate-module-agents.mjs +2 -1
  221. package/scripts/generate-seo-satellite-pages.mjs +5 -4
  222. package/scripts/github-deploy-notify.mjs +2 -1
  223. package/scripts/github-release-notify.mjs +25 -10
  224. package/scripts/new-whatsapp-session.sh +317 -0
  225. package/scripts/release.sh +2 -19
  226. package/scripts/security-smoketest.mjs +6 -5
  227. package/scripts/security-web-surface-check.mjs +218 -0
  228. package/scripts/sticker-catalog-loadtest.mjs +5 -4
  229. package/server/auth/googleWebAuth/googleWebAuthService.js +8 -7
  230. package/server/auth/jwt/webJwtService.js +1 -1
  231. package/server/auth/stickerCatalogAuthContext.js +2 -1
  232. package/server/auth/termsAcceptance/termsAcceptanceHandler.js +2 -1
  233. package/server/auth/userPassword/userPasswordAuthService.js +2 -1
  234. package/server/auth/userPassword/userPasswordRecoveryService.js +4 -3
  235. package/server/auth/webAccount/webAccountHandlers.js +9 -10
  236. package/server/controllers/admin/adminPanelHandlers.js +267 -16
  237. package/server/controllers/admin/systemAdminController.js +267 -0
  238. package/server/controllers/seo/stickerCatalogSeoContext.js +10 -9
  239. package/server/controllers/sticker/nonCatalogHandlers.js +2 -1
  240. package/server/controllers/sticker/stickerCatalogController.js +23 -36
  241. package/server/controllers/system/contactController.js +9 -17
  242. package/server/controllers/system/githubController.js +3 -2
  243. package/server/controllers/system/stickerCatalogSystemContext.js +41 -19
  244. package/server/controllers/system/systemController.js +254 -1
  245. package/server/controllers/system/systemMetricsController.js +2 -1
  246. package/server/controllers/userController.js +6 -0
  247. package/server/email/emailTemplateService.js +5 -3
  248. package/server/http/httpServer.js +11 -6
  249. package/server/middleware/rateLimit.js +2 -1
  250. package/server/middleware/securityHeaders.js +20 -1
  251. package/server/routes/admin/systemAdminRouter.js +6 -0
  252. package/server/routes/indexRouter.js +30 -6
  253. package/server/routes/observability/grafanaProxyRouter.js +254 -0
  254. package/server/routes/static/staticPageRouter.js +27 -1
  255. package/server/utils/publicContact.js +31 -0
  256. package/utils/time/timeModule.js +135 -0
  257. package/utils/time/timeModule.test.js +65 -0
  258. package/utils/whatsapp/contactEnv.js +39 -0
  259. package/vite.config.mjs +7 -1
  260. package/public/assets/images/brand-icon-192.png +0 -0
  261. 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) User
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
- // 5) System admin + legacy /stickers/admin
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
- // 6) Sticker catalog apenas nos prefixes permitidos
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
- // 7) Paginas estaticas (templates em public/pages)
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
- // 8) 404 global
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
+ };