@kaikybrofc/omnizap-system 2.1.8

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 (166) hide show
  1. package/.env.example +534 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/RELEASE-v2.1.2.md +83 -0
  5. package/app/config/adminIdentity.js +87 -0
  6. package/app/config/baileysConfig.js +693 -0
  7. package/app/config/groupUtils.js +388 -0
  8. package/app/connection/socketController.js +992 -0
  9. package/app/controllers/messageController.js +354 -0
  10. package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
  11. package/app/modules/adminModule/groupEventHandlers.js +355 -0
  12. package/app/modules/aiModule/catCommand.js +1006 -0
  13. package/app/modules/broadcastModule/noticeCommand.js +416 -0
  14. package/app/modules/gameModule/diceCommand.js +67 -0
  15. package/app/modules/menuModule/common.js +311 -0
  16. package/app/modules/menuModule/menus.js +59 -0
  17. package/app/modules/playModule/playCommand.js +1615 -0
  18. package/app/modules/quoteModule/quoteCommand.js +851 -0
  19. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
  20. package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
  21. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
  22. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  23. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
  24. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  25. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  26. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  27. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  28. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
  29. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
  30. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  31. package/app/modules/statsModule/globalRankingCommand.js +65 -0
  32. package/app/modules/statsModule/noMessageCommand.js +288 -0
  33. package/app/modules/statsModule/rankingCommand.js +60 -0
  34. package/app/modules/statsModule/rankingCommon.js +889 -0
  35. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  36. package/app/modules/stickerModule/convertToWebp.js +390 -0
  37. package/app/modules/stickerModule/stickerCommand.js +454 -0
  38. package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
  39. package/app/modules/stickerModule/stickerTextCommand.js +657 -0
  40. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  41. package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
  42. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
  43. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
  44. package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
  45. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
  46. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  47. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
  48. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
  49. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
  50. package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
  51. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
  52. package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
  53. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
  54. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
  55. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  56. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
  57. package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
  58. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
  59. package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
  60. package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
  61. package/app/modules/stickerPackModule/stickerPackService.js +788 -0
  62. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
  63. package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
  64. package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
  65. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
  66. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
  67. package/app/modules/systemMetricsModule/pingCommand.js +421 -0
  68. package/app/modules/tiktokModule/tiktokCommand.js +798 -0
  69. package/app/modules/userModule/userCommand.js +1217 -0
  70. package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
  71. package/app/observability/metrics.js +734 -0
  72. package/app/services/captchaService.js +492 -0
  73. package/app/services/dbWriteQueue.js +572 -0
  74. package/app/services/groupMetadataService.js +279 -0
  75. package/app/services/lidMapService.js +663 -0
  76. package/app/services/messagePersistenceService.js +56 -0
  77. package/app/services/newsBroadcastService.js +351 -0
  78. package/app/services/pokeApiService.js +398 -0
  79. package/app/services/queueUtils.js +57 -0
  80. package/app/services/socketState.js +7 -0
  81. package/app/store/aiPromptStore.js +38 -0
  82. package/app/store/groupConfigStore.js +58 -0
  83. package/app/store/premiumUserStore.js +36 -0
  84. package/app/utils/antiLink/antiLinkModule.js +804 -0
  85. package/app/utils/http/getImageBufferModule.js +18 -0
  86. package/app/utils/json/jsonSanitizer.js +113 -0
  87. package/app/utils/json/jsonSanitizer.test.js +40 -0
  88. package/app/utils/logger/loggerModule.js +262 -0
  89. package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
  90. package/database/index.js +2052 -0
  91. package/database/init.js +516 -0
  92. package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
  93. package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
  94. package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
  95. package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
  96. package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
  97. package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
  98. package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
  99. package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
  100. package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
  101. package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
  102. package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
  103. package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
  104. package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
  105. package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
  106. package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
  107. package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
  108. package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
  109. package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
  110. package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
  111. package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
  112. package/docker-compose.yml +103 -0
  113. package/ecosystem.prod.config.cjs +35 -0
  114. package/eslint.config.js +61 -0
  115. package/index.js +437 -0
  116. package/ml/clip_classifier/Dockerfile +16 -0
  117. package/ml/clip_classifier/README.md +120 -0
  118. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  119. package/ml/clip_classifier/classifier.py +654 -0
  120. package/ml/clip_classifier/embedding_store.py +481 -0
  121. package/ml/clip_classifier/env_loader.py +15 -0
  122. package/ml/clip_classifier/llm_label_expander.py +144 -0
  123. package/ml/clip_classifier/main.py +213 -0
  124. package/ml/clip_classifier/requirements.txt +10 -0
  125. package/ml/clip_classifier/similarity_engine.py +74 -0
  126. package/observability/alert-rules.yml +60 -0
  127. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  128. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  129. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  130. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  131. package/observability/loki-config.yml +38 -0
  132. package/observability/mysql-exporter.cnf +5 -0
  133. package/observability/mysql-setup.sql +46 -0
  134. package/observability/prometheus.yml +32 -0
  135. package/observability/promtail-config.yml +84 -0
  136. package/package.json +109 -0
  137. package/public/api-docs/index.html +144 -0
  138. package/public/css/github-project-panel.css +297 -0
  139. package/public/css/stickers-admin.css +1272 -0
  140. package/public/css/styles.css +671 -0
  141. package/public/index.html +1311 -0
  142. package/public/js/apps/apiDocsApp.js +310 -0
  143. package/public/js/apps/createPackApp.js +2069 -0
  144. package/public/js/apps/homeApp.js +396 -0
  145. package/public/js/apps/stickersAdminApp.js +1744 -0
  146. package/public/js/apps/stickersApp.js +4830 -0
  147. package/public/js/catalog.js +1019 -0
  148. package/public/js/github-panel/components/CommitList.js +34 -0
  149. package/public/js/github-panel/components/ErrorState.js +16 -0
  150. package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
  151. package/public/js/github-panel/components/ReleaseList.js +38 -0
  152. package/public/js/github-panel/components/SkeletonPanel.js +22 -0
  153. package/public/js/github-panel/components/StatCard.js +15 -0
  154. package/public/js/github-panel/index.js +15 -0
  155. package/public/js/github-panel/useGithubRepoData.js +154 -0
  156. package/public/js/github-panel/vendor/react.js +11 -0
  157. package/public/js/runtime/react-runtime.js +19 -0
  158. package/public/licenca/index.html +106 -0
  159. package/public/stickers/admin/index.html +23 -0
  160. package/public/stickers/create/index.html +47 -0
  161. package/public/stickers/index.html +48 -0
  162. package/public/termos-de-uso/index.html +125 -0
  163. package/scripts/cache-bust.mjs +107 -0
  164. package/scripts/deploy.sh +458 -0
  165. package/scripts/github-deploy-notify.mjs +174 -0
  166. package/scripts/release.sh +129 -0
@@ -0,0 +1,889 @@
1
+ import { createCanvas, loadImage } from 'canvas';
2
+ import { executeQuery } from '../../../database/index.js';
3
+ import { getJidUser, getProfilePicBuffer, normalizeJid } from '../../config/baileysConfig.js';
4
+ import { primeLidCache, resolveUserIdCached, isLidUserId, isWhatsAppUserId } from '../../services/lidMapService.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
+
16
+ export const MESSAGE_TYPE_SQL = `
17
+ CASE
18
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.conversation') IS NOT NULL THEN 'texto'
19
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.extendedTextMessage') IS NOT NULL THEN 'texto'
20
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.imageMessage') IS NOT NULL THEN 'imagem'
21
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.videoMessage') IS NOT NULL THEN 'video'
22
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.audioMessage') IS NOT NULL THEN 'audio'
23
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.stickerMessage') IS NOT NULL THEN 'figurinha'
24
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.documentMessage') IS NOT NULL THEN 'documento'
25
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.locationMessage') IS NOT NULL THEN 'localizacao'
26
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.reactionMessage') IS NOT NULL THEN 'reacao'
27
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.pollCreationMessage') IS NOT NULL THEN 'enquete'
28
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.listMessage') IS NOT NULL THEN 'lista'
29
+ WHEN JSON_EXTRACT(m.raw_message, '$.message.buttonsMessage') IS NOT NULL THEN 'botoes'
30
+ ELSE 'outros'
31
+ END
32
+ `;
33
+
34
+ export const TIMESTAMP_TO_DATETIME_SQL = `
35
+ CASE
36
+ WHEN m.timestamp > 1000000000000 THEN FROM_UNIXTIME(m.timestamp / 1000)
37
+ WHEN m.timestamp > 1000000000 THEN FROM_UNIXTIME(m.timestamp)
38
+ ELSE m.timestamp
39
+ END
40
+ `;
41
+
42
+ /**
43
+ * Formata data para pt-BR (America/Sao_Paulo).
44
+ * @param {Date|string|number|null|undefined} value
45
+ * @returns {string}
46
+ */
47
+ export const formatDate = (value) => {
48
+ if (!value) return 'N/D';
49
+ const date = value instanceof Date ? value : new Date(value);
50
+ if (Number.isNaN(date.getTime())) return 'N/D';
51
+ return new Intl.DateTimeFormat('pt-BR', {
52
+ dateStyle: 'short',
53
+ timeStyle: 'medium',
54
+ timeZone: 'America/Sao_Paulo',
55
+ }).format(date);
56
+ };
57
+
58
+ /**
59
+ * Converte timestamp em ms (aceita segundos/ms/string).
60
+ * @param {string|number|null|undefined} value
61
+ * @returns {number|null}
62
+ */
63
+ export const toMillis = (value) => {
64
+ if (value === null || value === undefined) return null;
65
+ if (typeof value === 'number') {
66
+ if (value > 1e12) return value;
67
+ if (value > 1e9) return value * 1000;
68
+ return value;
69
+ }
70
+ const parsed = Date.parse(value);
71
+ return Number.isNaN(parsed) ? null : parsed;
72
+ };
73
+
74
+ /**
75
+ * Retorna o nome exibido (pushName) ou fallback.
76
+ * @param {string|null|undefined} pushName
77
+ * @param {string|null|undefined} mentionId
78
+ * @returns {string}
79
+ */
80
+ export const getDisplayName = (pushName, mentionId) => {
81
+ const mentionUser = getJidUser(mentionId);
82
+ const base = mentionUser ? `@${mentionUser}` : null;
83
+ if (pushName && typeof pushName === 'string' && pushName.trim() !== '') {
84
+ const clean = pushName.trim();
85
+ return base ? `${base} (${clean})` : clean;
86
+ }
87
+ return base || 'Desconhecido';
88
+ };
89
+
90
+ const getShortName = (row) => {
91
+ if (row?.display_name && row.display_name.trim()) return row.display_name.trim();
92
+ const mentionUser = getJidUser(row?.mention_id || row?.sender_id);
93
+ return mentionUser ? `@${mentionUser}` : 'Desconhecido';
94
+ };
95
+
96
+ const resolveSenderIdsCanonical = (rawJid) => {
97
+ if (!rawJid) return { displayId: null, mentionId: null, key: null };
98
+ const canonical = resolveUserIdCached({ lid: rawJid, jid: rawJid, participantAlt: null });
99
+ const displayId = canonical || rawJid;
100
+ const mentionId = isWhatsAppUserId(canonical) ? canonical : null;
101
+ const key = canonical || rawJid;
102
+ return { displayId, mentionId, key };
103
+ };
104
+
105
+ const buildWhere = ({ scope, remoteJid, botJid }) => {
106
+ const where = ['m.sender_id IS NOT NULL'];
107
+ const params = [];
108
+ if (scope === 'group') {
109
+ where.push('m.chat_id = ?');
110
+ params.push(remoteJid);
111
+ }
112
+ if (botJid) {
113
+ const normalizedBotJid = normalizeJid(botJid) || botJid;
114
+ const botUser = getJidUser(normalizedBotJid);
115
+
116
+ // Exclui por JID exato (normalizado e bruto) e pelo usuário base
117
+ // para cobrir formatos como numero:dispositivo@s.whatsapp.net.
118
+ where.push('m.sender_id <> ?');
119
+ params.push(normalizedBotJid);
120
+ if (botJid !== normalizedBotJid) {
121
+ where.push('m.sender_id <> ?');
122
+ params.push(botJid);
123
+ }
124
+ if (botUser) {
125
+ where.push('m.sender_id NOT LIKE ?');
126
+ params.push(`${botUser}@%`);
127
+ where.push('m.sender_id NOT LIKE ?');
128
+ params.push(`${botUser}:%`);
129
+ }
130
+ }
131
+ return { where, params };
132
+ };
133
+
134
+ const getCachedProfilePic = (jid) => {
135
+ const entry = PROFILE_PIC_CACHE.get(jid);
136
+ if (!entry) return null;
137
+ const lastAccess = entry.lastAccess || entry.createdAt || 0;
138
+ if (Date.now() - lastAccess > PROFILE_CACHE_TTL_MS) {
139
+ PROFILE_PIC_CACHE.delete(jid);
140
+ return null;
141
+ }
142
+ entry.lastAccess = Date.now();
143
+ return entry.buffer || null;
144
+ };
145
+
146
+ const setCachedProfilePic = (jid, buffer) => {
147
+ if (!jid || !buffer) return;
148
+ PROFILE_PIC_CACHE.set(jid, { buffer, createdAt: Date.now(), lastAccess: Date.now() });
149
+ if (PROFILE_PIC_CACHE.size > PROFILE_CACHE_LIMIT) {
150
+ 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];
151
+ if (oldestKey) PROFILE_PIC_CACHE.delete(oldestKey);
152
+ }
153
+ };
154
+
155
+ const fetchProfileBuffer = async (sock, jid, remoteJid) => {
156
+ const cached = getCachedProfilePic(jid);
157
+ if (cached) return cached;
158
+ const buffer = await Promise.race([
159
+ getProfilePicBuffer(sock, { key: { participant: jid, remoteJid } }),
160
+ new Promise((resolve) => {
161
+ setTimeout(() => resolve(null), PROFILE_FETCH_TIMEOUT_MS);
162
+ }),
163
+ ]);
164
+ if (buffer) setCachedProfilePic(jid, buffer);
165
+ return buffer;
166
+ };
167
+
168
+ const loadProfileImages = async ({ sock, jids, remoteJid, concurrency = 6 }) => {
169
+ const results = new Map();
170
+ if (!sock) return results;
171
+ const queue = Array.from(new Set((jids || []).filter(Boolean)));
172
+ let index = 0;
173
+
174
+ const worker = async () => {
175
+ while (index < queue.length) {
176
+ const jid = queue[index];
177
+ index += 1;
178
+ if (results.has(jid)) continue;
179
+ try {
180
+ const buffer = await fetchProfileBuffer(sock, jid, remoteJid);
181
+ if (!buffer) continue;
182
+ const image = await loadImage(buffer);
183
+ results.set(jid, image);
184
+ } catch {
185
+ // Ignora falhas de imagem
186
+ }
187
+ }
188
+ };
189
+
190
+ const workers = Array.from({ length: concurrency }, () => worker());
191
+ await Promise.all(workers);
192
+ return results;
193
+ };
194
+
195
+ const drawRoundedRect = (ctx, x, y, w, h, r) => {
196
+ const radius = Math.min(r, w / 2, h / 2);
197
+ ctx.beginPath();
198
+ ctx.moveTo(x + radius, y);
199
+ ctx.arcTo(x + w, y, x + w, y + h, radius);
200
+ ctx.arcTo(x + w, y + h, x, y + h, radius);
201
+ ctx.arcTo(x, y + h, x, y, radius);
202
+ ctx.arcTo(x, y, x + w, y, radius);
203
+ ctx.closePath();
204
+ };
205
+
206
+ const drawTrackedText = (ctx, text, x, y, tracking = 0) => {
207
+ if (!text) return 0;
208
+ let cursor = x;
209
+ const chars = String(text).split('');
210
+ chars.forEach((char) => {
211
+ ctx.fillText(char, cursor, y);
212
+ cursor += ctx.measureText(char).width + tracking;
213
+ });
214
+ return cursor - x;
215
+ };
216
+
217
+ const fitText = (ctx, text, maxWidth) => {
218
+ if (!text) return '';
219
+ const base = String(text);
220
+ if (ctx.measureText(base).width <= maxWidth) return base;
221
+ let trimmed = base;
222
+ while (trimmed.length > 0 && ctx.measureText(`${trimmed}…`).width > maxWidth) {
223
+ trimmed = trimmed.slice(0, -1);
224
+ }
225
+ return trimmed ? `${trimmed}…` : '';
226
+ };
227
+
228
+ const getInitials = (label) => {
229
+ if (!label) return '?';
230
+ const clean = label.replace('@', '').trim();
231
+ if (!clean) return '?';
232
+ const parts = clean.split(/\s+/).filter(Boolean);
233
+ if (!parts.length) return '?';
234
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
235
+ return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
236
+ };
237
+
238
+ const formatCompactNumber = (value) => {
239
+ const num = Number(value || 0);
240
+ if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
241
+ if (num >= 1_000) return `${(num / 1_000).toFixed(1)}k`;
242
+ return `${num}`;
243
+ };
244
+
245
+ const pickAvatarJid = (row) => {
246
+ if (!row) return null;
247
+ if (isWhatsAppUserId(row.mention_id)) return row.mention_id;
248
+ if (isWhatsAppUserId(row.sender_id)) return row.sender_id;
249
+ return null;
250
+ };
251
+
252
+ const drawAvatar = (ctx, { x, y, radius, image, fallbackLabel, borderColor = '#38bdf8' }) => {
253
+ const glow = ctx.createRadialGradient(x - radius * 0.2, y - radius * 0.2, radius * 0.4, x, y, radius * 1.2);
254
+ glow.addColorStop(0, 'rgba(226, 232, 240, 0.25)');
255
+ glow.addColorStop(1, 'rgba(15, 23, 42, 0)');
256
+ ctx.save();
257
+ ctx.fillStyle = glow;
258
+ ctx.beginPath();
259
+ ctx.arc(x, y, radius + 6, 0, Math.PI * 2);
260
+ ctx.fill();
261
+ ctx.restore();
262
+
263
+ ctx.save();
264
+ ctx.beginPath();
265
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
266
+ ctx.closePath();
267
+ ctx.clip();
268
+ if (image) {
269
+ ctx.drawImage(image, x - radius, y - radius, radius * 2, radius * 2);
270
+ } else {
271
+ const gradient = ctx.createLinearGradient(x - radius, y - radius, x + radius, y + radius);
272
+ gradient.addColorStop(0, '#1f2937');
273
+ gradient.addColorStop(1, '#0f172a');
274
+ ctx.fillStyle = gradient;
275
+ ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
276
+ ctx.fillStyle = '#f8fafc';
277
+ ctx.font = `bold ${Math.max(16, radius * 0.7)}px Poppins, Arial`;
278
+ ctx.textAlign = 'center';
279
+ ctx.textBaseline = 'middle';
280
+ ctx.fillText(getInitials(fallbackLabel), x, y);
281
+ }
282
+ ctx.restore();
283
+
284
+ ctx.save();
285
+ ctx.strokeStyle = borderColor;
286
+ ctx.lineWidth = 2;
287
+ ctx.beginPath();
288
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
289
+ ctx.stroke();
290
+ ctx.restore();
291
+ };
292
+
293
+ const buildCanonicalWhere = ({ scope, remoteJid, botJid, canonicalId }) => {
294
+ const where = ['COALESCE(lm.jid, m.sender_id) = ?'];
295
+ const params = [canonicalId];
296
+ if (scope === 'group') {
297
+ where.push('m.chat_id = ?');
298
+ params.push(remoteJid);
299
+ }
300
+ if (botJid) {
301
+ where.push('COALESCE(lm.jid, m.sender_id) <> ?');
302
+ params.push(botJid);
303
+ }
304
+ return { where, params };
305
+ };
306
+
307
+ /**
308
+ * Busca total de mensagens conforme escopo.
309
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null}} params
310
+ * @returns {Promise<number>}
311
+ */
312
+ export const getTotalMessages = async ({ scope, remoteJid, botJid }) => {
313
+ const { where, params } = buildWhere({ scope, remoteJid, botJid });
314
+ const sql = `SELECT COUNT(*) AS total FROM messages m WHERE ${where.join(' AND ')}`;
315
+ const [row] = await executeQuery(sql, params);
316
+ return Number(row?.total || 0);
317
+ };
318
+
319
+ /**
320
+ * Busca o tipo de mensagem mais usado conforme escopo.
321
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null}} params
322
+ * @returns {Promise<{label: string, count: number}|null>}
323
+ */
324
+ export const getTopMessageType = async ({ scope, remoteJid, botJid }) => {
325
+ const { where, params } = buildWhere({ scope, remoteJid, botJid });
326
+ const [row] = await executeQuery(
327
+ `SELECT
328
+ ${MESSAGE_TYPE_SQL} AS message_type,
329
+ COUNT(*) AS total
330
+ FROM messages m
331
+ WHERE ${where.join(' AND ')}
332
+ AND m.raw_message IS NOT NULL
333
+ GROUP BY message_type
334
+ ORDER BY total DESC
335
+ LIMIT 1`,
336
+ params,
337
+ );
338
+ if (!row?.message_type) return null;
339
+ return { label: row.message_type, count: Number(row.total || 0) };
340
+ };
341
+
342
+ /**
343
+ * Busca inicio do banco conforme escopo.
344
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null}} params
345
+ * @returns {Promise<any>}
346
+ */
347
+ export const getDbStart = async ({ scope, remoteJid, botJid }) => {
348
+ const { where, params } = buildWhere({ scope, remoteJid, botJid });
349
+ const sql = `SELECT MIN(m.timestamp) AS db_start FROM messages m WHERE ${where.join(' AND ')}`;
350
+ const rows = await executeQuery(sql, params);
351
+ return rows?.[0]?.db_start || null;
352
+ };
353
+
354
+ /**
355
+ * Busca os ultimos pushNames por sender_id.
356
+ * @param {Array<string>} senderIds
357
+ * @returns {Promise<Map<string, string>>}
358
+ */
359
+ export const fetchLatestPushNames = async (senderIds) => {
360
+ if (!senderIds || !senderIds.length) return new Map();
361
+ const placeholders = senderIds.map(() => '?').join(',');
362
+ const rows = await executeQuery(
363
+ `SELECT t.sender_id,
364
+ JSON_UNQUOTE(JSON_EXTRACT(m.raw_message, '$.pushName')) AS pushName
365
+ FROM (
366
+ SELECT sender_id, MAX(id) AS max_id
367
+ FROM messages
368
+ WHERE sender_id IN (${placeholders})
369
+ AND raw_message IS NOT NULL
370
+ AND JSON_EXTRACT(raw_message, '$.pushName') IS NOT NULL
371
+ GROUP BY sender_id
372
+ ) t
373
+ JOIN messages m ON m.id = t.max_id`,
374
+ senderIds,
375
+ );
376
+ const map = new Map();
377
+ (rows || []).forEach((row) => {
378
+ if (row?.sender_id && row?.pushName) {
379
+ map.set(row.sender_id, row.pushName);
380
+ }
381
+ });
382
+ return map;
383
+ };
384
+
385
+ /**
386
+ * Monta ranking base com normalizacao por lid_map.
387
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null, limit?: number|null}} params
388
+ * @returns {Promise<{rows: Array<any>}>}
389
+ */
390
+ export const getRankingBase = async ({ scope, remoteJid, botJid, limit = null }) => {
391
+ const { where, params } = buildWhere({ scope, remoteJid, botJid });
392
+ const limitClause = limit ? `LIMIT ${Number(limit)}` : '';
393
+ const rankingRows = await executeQuery(
394
+ `SELECT
395
+ m.sender_id,
396
+ COUNT(*) AS total_messages,
397
+ MIN(m.timestamp) AS first_message,
398
+ MAX(m.timestamp) AS last_message
399
+ FROM messages m
400
+ WHERE ${where.join(' AND ')}
401
+ GROUP BY m.sender_id
402
+ ORDER BY total_messages DESC
403
+ ${limitClause}`,
404
+ params,
405
+ );
406
+
407
+ const senderIds = rankingRows.map((row) => row.sender_id).filter(Boolean);
408
+ const lidsToPrime = senderIds.filter((id) => isLidUserId(id));
409
+ if (lidsToPrime.length > 0) {
410
+ await primeLidCache(lidsToPrime);
411
+ }
412
+
413
+ const pushNameBySender = await fetchLatestPushNames(senderIds);
414
+ const normalizedTotals = new Map();
415
+
416
+ rankingRows.forEach((row) => {
417
+ const rawJid = row.sender_id || '';
418
+ if (!rawJid) return;
419
+ const { displayId, mentionId, key } = resolveSenderIdsCanonical(rawJid);
420
+ if (!displayId || !key) return;
421
+ const total = Number(row.total_messages || 0);
422
+ const firstMs = toMillis(row.first_message);
423
+ const lastMs = toMillis(row.last_message);
424
+ const current = normalizedTotals.get(key) || {
425
+ sender_id: displayId,
426
+ mention_id: mentionId,
427
+ display_name: null,
428
+ total_messages: 0,
429
+ first_message: null,
430
+ last_message: null,
431
+ };
432
+ current.total_messages += total;
433
+ if (firstMs !== null) {
434
+ current.first_message = current.first_message === null ? firstMs : Math.min(current.first_message, firstMs);
435
+ }
436
+ if (lastMs !== null) {
437
+ current.last_message = current.last_message === null ? lastMs : Math.max(current.last_message, lastMs);
438
+ }
439
+ if (!current.mention_id && mentionId) {
440
+ current.mention_id = mentionId;
441
+ }
442
+ if (isWhatsAppUserId(rawJid)) {
443
+ current.mention_id = rawJid;
444
+ }
445
+ if (!current.display_name) {
446
+ const pushName = pushNameBySender.get(rawJid);
447
+ if (pushName) current.display_name = pushName;
448
+ }
449
+ normalizedTotals.set(key, current);
450
+ });
451
+
452
+ const rows = Array.from(normalizedTotals.values()).sort((a, b) => b.total_messages - a.total_messages);
453
+ return { rows: limit ? rows.slice(0, limit) : rows };
454
+ };
455
+
456
+ const computeStreak = (days) => {
457
+ if (!days.length) return 0;
458
+ let best = 1;
459
+ let current = 1;
460
+ let prev = new Date(`${days[0]}T00:00:00Z`).getTime();
461
+ for (let i = 1; i < days.length; i += 1) {
462
+ const currentDay = new Date(`${days[i]}T00:00:00Z`).getTime();
463
+ const diff = currentDay - prev;
464
+ if (diff === DAY_MS) {
465
+ current += 1;
466
+ } else {
467
+ current = 1;
468
+ }
469
+ if (current > best) best = current;
470
+ prev = currentDay;
471
+ }
472
+ return best;
473
+ };
474
+
475
+ /**
476
+ * Enriquecer ranking com dias ativos, streak, media/dia e favorito.
477
+ * @param {{rows: Array<any>, scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null}} params
478
+ * @returns {Promise<void>}
479
+ */
480
+ export const enrichRankingRows = async ({ rows, scope, remoteJid, botJid }) => {
481
+ for (const row of rows) {
482
+ const rawJid = row.sender_id;
483
+ if (!rawJid) continue;
484
+
485
+ const { where, params } = buildCanonicalWhere({
486
+ scope,
487
+ remoteJid,
488
+ botJid,
489
+ canonicalId: rawJid,
490
+ });
491
+
492
+ const daysRows = await executeQuery(
493
+ `SELECT DISTINCT DATE(ts) AS day
494
+ FROM (
495
+ SELECT ${TIMESTAMP_TO_DATETIME_SQL} AS ts
496
+ FROM messages m
497
+ LEFT JOIN lid_map lm
498
+ ON lm.lid = m.sender_id
499
+ AND lm.jid IS NOT NULL
500
+ WHERE ${where.join(' AND ')}
501
+ AND m.timestamp IS NOT NULL
502
+ ) d
503
+ WHERE d.ts IS NOT NULL
504
+ ORDER BY day ASC`,
505
+ params,
506
+ );
507
+
508
+ const days = (daysRows || []).map((item) => item.day).filter(Boolean);
509
+ row.active_days = days.length;
510
+ row.streak = computeStreak(days);
511
+
512
+ const total = Number(row.total_messages || 0);
513
+ const firstMs = toMillis(row.first_message);
514
+ const lastMs = toMillis(row.last_message);
515
+ if (firstMs !== null && lastMs !== null && total > 0) {
516
+ const rangeDays = Math.max(1, Math.ceil((lastMs - firstMs) / DAY_MS) + 1);
517
+ row.avg_per_day = (total / rangeDays).toFixed(2);
518
+ } else {
519
+ row.avg_per_day = '0.00';
520
+ }
521
+
522
+ const [favRow] = await executeQuery(
523
+ `SELECT
524
+ ${MESSAGE_TYPE_SQL} AS message_type,
525
+ COUNT(*) AS total
526
+ FROM messages m
527
+ LEFT JOIN lid_map lm
528
+ ON lm.lid = m.sender_id
529
+ AND lm.jid IS NOT NULL
530
+ WHERE ${where.join(' AND ')}
531
+ AND m.raw_message IS NOT NULL
532
+ GROUP BY message_type
533
+ ORDER BY total DESC
534
+ LIMIT 1`,
535
+ params,
536
+ );
537
+
538
+ row.favorite_type = favRow?.message_type || null;
539
+ row.favorite_count = Number(favRow?.total || 0);
540
+ }
541
+ };
542
+
543
+ /**
544
+ * Monta um relatorio completo do ranking conforme escopo.
545
+ * @param {{scope: 'group'|'global', remoteJid?: string|null, botJid?: string|null, limit?: number|null, includeTopType?: boolean, includeDbStart?: boolean, enrichRows?: boolean}} params
546
+ * @returns {Promise<{rows: Array<any>, totalMessages: number, topType: {label: string, count: number}|null, topTotal: number, dbStart: any}>}
547
+ */
548
+ export const getRankingReport = async ({
549
+ scope,
550
+ remoteJid,
551
+ botJid,
552
+ limit = null,
553
+ includeTopType = true,
554
+ includeDbStart = true,
555
+ enrichRows = true,
556
+ }) => {
557
+ const totalMessages = await getTotalMessages({ scope, remoteJid, botJid });
558
+ const topType = includeTopType ? await getTopMessageType({ scope, remoteJid, botJid }) : null;
559
+ const { rows } = await getRankingBase({ scope, remoteJid, botJid, limit });
560
+ if (enrichRows) {
561
+ await enrichRankingRows({ rows, scope, remoteJid, botJid });
562
+ }
563
+ const topTotal = rows.reduce((acc, row) => acc + Number(row.total_messages || 0), 0);
564
+ const dbStart = includeDbStart ? await getDbStart({ scope, remoteJid, botJid }) : null;
565
+ return { rows, totalMessages, topType, topTotal, dbStart };
566
+ };
567
+
568
+ /**
569
+ * Monta mensagem detalhada do ranking.
570
+ * @param {{scope: 'group'|'global', limit: number, rows: Array<any>, totalMessages: number, topTotal: number, topType: {label: string, count: number}|null, dbStart: any}} params
571
+ * @returns {string}
572
+ */
573
+ export const buildRankingMessage = ({ scope, limit, rows, totalMessages, topTotal, topType, dbStart }) => {
574
+ const scopeTitle = scope === 'global' ? 'Global' : 'Grupo';
575
+ const scopeLabel = scope === 'global' ? 'global' : 'grupo';
576
+
577
+ if (!rows.length) {
578
+ return `Nao ha mensagens suficientes para gerar o ranking ${scopeLabel}.\n\nInicio do banco (primeira mensagem): ${formatDate(dbStart)}`;
579
+ }
580
+
581
+ const totalLabel = Number(totalMessages || 0);
582
+ const topShare = totalLabel > 0 ? ((Number(topTotal || 0) / totalLabel) * 100).toFixed(2) : '0.00';
583
+ const topTypeLabel = topType?.label ? `${topType.label} (${topType.count})` : 'N/D';
584
+
585
+ const lines = [`🏆 *Ranking ${scopeTitle} Top ${limit} (mensagens)*`, `📦 Total de mensagens (${scopeLabel}): ${totalLabel}`, `📊 Top ${limit} = ${topShare}% do total`, `🔥 Tipo mais usado: ${topTypeLabel}`, ''];
586
+
587
+ rows.forEach((row, index) => {
588
+ const handle = getDisplayName(row.display_name, row.mention_id || row.sender_id);
589
+ const total = row.total_messages || 0;
590
+ const percent = totalLabel > 0 ? ((Number(total || 0) / totalLabel) * 100).toFixed(2) : '0.00';
591
+ const first = formatDate(row.first_message);
592
+ const last = formatDate(row.last_message);
593
+ const avgPerDay = row.avg_per_day || '0.00';
594
+ const activeDays = row.active_days ?? 0;
595
+ const streak = row.streak ?? 0;
596
+ const favoriteType = row.favorite_type ? `${row.favorite_type} (${row.favorite_count || 0})` : 'N/D';
597
+ const position = `${index + 1}`.padStart(2, '0');
598
+ 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}`, '');
599
+ });
600
+
601
+ lines.push(`Inicio do banco (primeira mensagem): ${formatDate(dbStart)}`);
602
+ return lines.join('\n');
603
+ };
604
+
605
+ /**
606
+ * Renderiza uma imagem de ranking horizontal.
607
+ * @param {object} params
608
+ * @param {object} params.sock
609
+ * @param {string} params.remoteJid
610
+ * @param {Array<object>} params.rows
611
+ * @param {number} params.totalMessages
612
+ * @param {{label: string, count: number}|null} params.topType
613
+ * @param {'group'|'global'} params.scope
614
+ * @param {number} params.limit
615
+ * @returns {Promise<Buffer>}
616
+ */
617
+ export const renderRankingImage = async ({ sock, remoteJid, rows, totalMessages, topType, scope, limit }) => {
618
+ const width = RANKING_IMAGE_WIDTH;
619
+ const height = RANKING_IMAGE_HEIGHT;
620
+ const scale = RANKING_IMAGE_SCALE;
621
+ const canvas = createCanvas(width * scale, height * scale);
622
+ const ctx = canvas.getContext('2d');
623
+ ctx.scale(scale, scale);
624
+ ctx.imageSmoothingEnabled = true;
625
+ ctx.imageSmoothingQuality = 'high';
626
+
627
+ const baseGradient = ctx.createLinearGradient(0, 0, width, height);
628
+ baseGradient.addColorStop(0, '#0f172a');
629
+ baseGradient.addColorStop(1, '#111827');
630
+ ctx.fillStyle = baseGradient;
631
+ ctx.fillRect(0, 0, width, height);
632
+
633
+ const radial = ctx.createRadialGradient(width * 0.3, height * 0.15, 120, width * 0.5, height * 0.45, width);
634
+ radial.addColorStop(0, 'rgba(148, 163, 184, 0.2)');
635
+ radial.addColorStop(1, 'rgba(15, 23, 42, 0)');
636
+ ctx.fillStyle = radial;
637
+ ctx.fillRect(0, 0, width, height);
638
+
639
+ const drawBlob = (x, y, r, color, alpha) => {
640
+ ctx.save();
641
+ ctx.globalAlpha = alpha;
642
+ ctx.fillStyle = color;
643
+ ctx.beginPath();
644
+ ctx.arc(x, y, r, 0, Math.PI * 2);
645
+ ctx.fill();
646
+ ctx.restore();
647
+ };
648
+
649
+ drawBlob(width * 0.82, height * 0.22, 180, '#1d4ed8', 0.08);
650
+ drawBlob(width * 0.12, height * 0.75, 220, '#22c55e', 0.05);
651
+
652
+ const noiseSize = 180;
653
+ const noiseCanvas = createCanvas(noiseSize, noiseSize);
654
+ const noiseCtx = noiseCanvas.getContext('2d');
655
+ const noiseData = noiseCtx.createImageData(noiseSize, noiseSize);
656
+ for (let i = 0; i < noiseData.data.length; i += 4) {
657
+ const value = 200 + Math.floor(Math.random() * 55);
658
+ noiseData.data[i] = value;
659
+ noiseData.data[i + 1] = value;
660
+ noiseData.data[i + 2] = value;
661
+ noiseData.data[i + 3] = 12;
662
+ }
663
+ noiseCtx.putImageData(noiseData, 0, 0);
664
+ const noisePattern = ctx.createPattern(noiseCanvas, 'repeat');
665
+ if (noisePattern) {
666
+ ctx.save();
667
+ ctx.globalAlpha = 0.04;
668
+ ctx.fillStyle = noisePattern;
669
+ ctx.fillRect(0, 0, width, height);
670
+ ctx.restore();
671
+ }
672
+
673
+ const title = scope === 'global' ? `Ranking Global Top ${limit}` : `Ranking do Grupo Top ${limit}`;
674
+ ctx.fillStyle = '#f8fafc';
675
+ ctx.font = 'bold 40px Poppins, Arial';
676
+ ctx.textAlign = 'left';
677
+ const titleWidth = drawTrackedText(ctx, title, 40, 60, 1.2);
678
+ const titleAccentX = 40 + titleWidth + 12;
679
+ ctx.strokeStyle = 'rgba(148, 163, 184, 0.6)';
680
+ ctx.lineWidth = 3;
681
+ ctx.beginPath();
682
+ ctx.moveTo(titleAccentX, 48);
683
+ ctx.lineTo(titleAccentX + 36, 48);
684
+ ctx.stroke();
685
+ if (scope === 'global') {
686
+ ctx.fillStyle = 'rgba(148, 163, 184, 0.8)';
687
+ ctx.font = '22px Poppins, Arial';
688
+ ctx.fillText('🌍', titleAccentX + 42, 56);
689
+ }
690
+
691
+ ctx.font = '16px Poppins, Arial';
692
+ ctx.fillStyle = 'rgba(148, 163, 184, 0.75)';
693
+ const topTypeLabel = topType?.label ? `${topType.label} (${topType.count})` : 'N/D';
694
+ ctx.fillText(`${formatCompactNumber(totalMessages)} mensagens • Tipo mais usado: ${topTypeLabel}`, 40, 92);
695
+
696
+ const topRows = rows.slice(0, 2);
697
+ const restRows = rows.slice(2);
698
+ const avatarJids = rows.map((row) => pickAvatarJid(row)).filter(Boolean);
699
+ const avatars = await loadProfileImages({ sock, jids: avatarJids, remoteJid });
700
+
701
+ const margin = 40;
702
+ const gap = 24;
703
+ const headerHeight = 130;
704
+ const podiumHeight = 300;
705
+
706
+ const availableWidth = width - margin * 2;
707
+ const baseTopCardWidth = (availableWidth - gap) / 2;
708
+ const baseTopCardHeight = podiumHeight;
709
+ const topCardY = headerHeight;
710
+ const baseBottom = topCardY + baseTopCardHeight;
711
+ const rank1Scale = 1.05;
712
+ const rank2Scale = 0.96;
713
+ const rank1Width = baseTopCardWidth * rank1Scale;
714
+ const rank2Width = baseTopCardWidth * rank2Scale;
715
+ const topRowWidth = rank1Width + rank2Width + gap;
716
+ const topRowStartX = margin + Math.max(0, (availableWidth - topRowWidth) / 2);
717
+ const rank1Height = baseTopCardHeight * rank1Scale;
718
+ const rank2Height = baseTopCardHeight * rank2Scale;
719
+
720
+ const rankColors = {
721
+ 1: '#facc15',
722
+ 2: '#38bdf8',
723
+ 3: '#34d399',
724
+ 4: '#94a3b8',
725
+ 5: '#64748b',
726
+ };
727
+
728
+ const drawMetricLine = ({ icon, label, x, y, iconColor, textColor, font }) => {
729
+ ctx.save();
730
+ ctx.textAlign = 'left';
731
+ ctx.textBaseline = 'middle';
732
+ ctx.font = '18px Poppins, Arial';
733
+ ctx.fillStyle = iconColor || 'rgba(148, 163, 184, 0.75)';
734
+ ctx.fillText(icon, x, y);
735
+ const iconWidth = ctx.measureText(icon).width;
736
+ ctx.font = font;
737
+ ctx.fillStyle = textColor;
738
+ ctx.fillText(label, x + iconWidth + 6, y);
739
+ ctx.restore();
740
+ };
741
+
742
+ const drawCard = ({ row, x, y, w, h, rank }) => {
743
+ if (!row) return;
744
+ const accentColor = rankColors[rank] || '#94a3b8';
745
+ const isTop = rank === 1;
746
+ ctx.save();
747
+ ctx.shadowColor = isTop ? 'rgba(250, 204, 21, 0.55)' : 'rgba(15, 23, 42, 0.35)';
748
+ ctx.shadowBlur = isTop ? 32 : 18;
749
+ drawRoundedRect(ctx, x, y, w, h, 24);
750
+ ctx.fillStyle = '#111827';
751
+ ctx.fill();
752
+ ctx.shadowBlur = 0;
753
+ ctx.lineWidth = 2;
754
+ ctx.strokeStyle = accentColor || '#1f2937';
755
+ ctx.stroke();
756
+ ctx.restore();
757
+
758
+ const pad = 22;
759
+ const avatarRadius = Math.min(74, h * 0.36);
760
+ const avatarX = x + pad + avatarRadius;
761
+ const avatarY = y + h / 2;
762
+ const label = getShortName(row);
763
+ const avatarImage = avatars.get(pickAvatarJid(row)) || null;
764
+ drawAvatar(ctx, {
765
+ x: avatarX,
766
+ y: avatarY,
767
+ radius: avatarRadius,
768
+ image: avatarImage,
769
+ fallbackLabel: label,
770
+ borderColor: accentColor,
771
+ });
772
+
773
+ const rankBadgeSize = 46;
774
+ ctx.save();
775
+ ctx.fillStyle = accentColor || '#22d3ee';
776
+ ctx.beginPath();
777
+ ctx.arc(x + pad + rankBadgeSize / 2, y + pad + rankBadgeSize / 2, rankBadgeSize / 2, 0, Math.PI * 2);
778
+ ctx.fill();
779
+ ctx.fillStyle = '#0f172a';
780
+ ctx.font = 'bold 20px Poppins, Arial';
781
+ ctx.textAlign = 'center';
782
+ ctx.textBaseline = 'middle';
783
+ ctx.fillText(String(rank), x + pad + rankBadgeSize / 2, y + pad + rankBadgeSize / 2);
784
+ ctx.restore();
785
+ if (rank === 1) {
786
+ ctx.save();
787
+ ctx.font = '18px Poppins, Arial';
788
+ ctx.fillStyle = '#f8fafc';
789
+ ctx.fillText('👑', x + pad + rankBadgeSize + 6, y + pad + 16);
790
+ const badgeW = 64;
791
+ const badgeH = 24;
792
+ const badgeX = x + w - pad - badgeW;
793
+ const badgeY = y + pad - 2;
794
+ drawRoundedRect(ctx, badgeX, badgeY, badgeW, badgeH, 12);
795
+ ctx.fillStyle = 'rgba(250, 204, 21, 0.15)';
796
+ ctx.fill();
797
+ ctx.strokeStyle = 'rgba(250, 204, 21, 0.7)';
798
+ ctx.lineWidth = 1;
799
+ ctx.stroke();
800
+ ctx.fillStyle = '#facc15';
801
+ ctx.font = 'bold 12px Poppins, Arial';
802
+ ctx.textAlign = 'center';
803
+ ctx.textBaseline = 'middle';
804
+ ctx.fillText('TOP 1', badgeX + badgeW / 2, badgeY + badgeH / 2);
805
+ ctx.restore();
806
+ }
807
+
808
+ const textX = avatarX + avatarRadius + 18;
809
+ const textWidth = x + w - pad - textX;
810
+
811
+ ctx.fillStyle = '#f8fafc';
812
+ ctx.font = 'bold 28px Poppins, Arial';
813
+ ctx.textAlign = 'left';
814
+ ctx.textBaseline = 'top';
815
+ ctx.fillText(fitText(ctx, label, textWidth), textX, y + h / 2 - 40);
816
+
817
+ const total = formatCompactNumber(row.total_messages || 0);
818
+ const percent = totalMessages > 0 ? ((Number(row.total_messages || 0) / totalMessages) * 100).toFixed(1) : '0.0';
819
+ const lineOneY = y + h / 2 + 6;
820
+ drawMetricLine({
821
+ icon: '💬',
822
+ label: `Mensagens: ${total}`,
823
+ x: textX,
824
+ y: lineOneY,
825
+ iconColor: 'rgba(148, 163, 184, 0.75)',
826
+ textColor: '#e2e8f0',
827
+ font: 'bold 20px Poppins, Arial',
828
+ });
829
+ drawMetricLine({
830
+ icon: '📊',
831
+ label: `% do total: ${percent}%`,
832
+ x: textX,
833
+ y: lineOneY + 26,
834
+ iconColor: 'rgba(148, 163, 184, 0.75)',
835
+ textColor: 'rgba(148, 163, 184, 0.85)',
836
+ font: '18px Poppins, Arial',
837
+ });
838
+ };
839
+
840
+ drawCard({
841
+ row: topRows[0],
842
+ x: topRowStartX,
843
+ y: baseBottom - rank1Height,
844
+ w: rank1Width,
845
+ h: rank1Height,
846
+ rank: 1,
847
+ });
848
+ drawCard({
849
+ row: topRows[1],
850
+ x: topRowStartX + rank1Width + gap,
851
+ y: baseBottom - rank2Height,
852
+ w: rank2Width,
853
+ h: rank2Height,
854
+ rank: 2,
855
+ });
856
+
857
+ const restTop = headerHeight + podiumHeight + 40;
858
+ if (restRows.length) {
859
+ const restCount = restRows.length;
860
+ const restGap = 18;
861
+ const restWidth = (width - margin * 2 - restGap * (restCount - 1)) / restCount;
862
+ const restHeight = Math.min(220, height - restTop - margin);
863
+ restRows.forEach((row, idx) => {
864
+ const x = margin + idx * (restWidth + restGap);
865
+ const y = restTop;
866
+ drawCard({
867
+ row,
868
+ x,
869
+ y,
870
+ w: restWidth,
871
+ h: restHeight,
872
+ rank: idx + 3,
873
+ });
874
+ });
875
+ }
876
+
877
+ const footerY = height - 34;
878
+ ctx.fillStyle = 'rgba(148, 163, 184, 0.8)';
879
+ ctx.font = '14px Poppins, Arial';
880
+ const updatedAt = formatDate(new Date());
881
+ ctx.textAlign = 'left';
882
+ ctx.fillText(`📅 Atualizado em: ${updatedAt}`, 40, footerY);
883
+ ctx.fillText('⚙️ Dados coletados automaticamente', 40, footerY + 18);
884
+ ctx.textAlign = 'right';
885
+ ctx.fillText('🤖 Powered by OmniZap System', width - 40, footerY + 18);
886
+ ctx.textAlign = 'left';
887
+
888
+ return canvas.toBuffer('image/png');
889
+ };