@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.
- package/.env.example +534 -0
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/RELEASE-v2.1.2.md +83 -0
- package/app/config/adminIdentity.js +87 -0
- package/app/config/baileysConfig.js +693 -0
- package/app/config/groupUtils.js +388 -0
- package/app/connection/socketController.js +992 -0
- package/app/controllers/messageController.js +354 -0
- package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
- package/app/modules/adminModule/groupEventHandlers.js +355 -0
- package/app/modules/aiModule/catCommand.js +1006 -0
- package/app/modules/broadcastModule/noticeCommand.js +416 -0
- package/app/modules/gameModule/diceCommand.js +67 -0
- package/app/modules/menuModule/common.js +311 -0
- package/app/modules/menuModule/menus.js +59 -0
- package/app/modules/playModule/playCommand.js +1615 -0
- package/app/modules/quoteModule/quoteCommand.js +851 -0
- package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
- package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
- package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
- package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
- package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
- package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
- package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
- package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
- package/app/modules/statsModule/globalRankingCommand.js +65 -0
- package/app/modules/statsModule/noMessageCommand.js +288 -0
- package/app/modules/statsModule/rankingCommand.js +60 -0
- package/app/modules/statsModule/rankingCommon.js +889 -0
- package/app/modules/stickerModule/addStickerMetadata.js +239 -0
- package/app/modules/stickerModule/convertToWebp.js +390 -0
- package/app/modules/stickerModule/stickerCommand.js +454 -0
- package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
- package/app/modules/stickerModule/stickerTextCommand.js +657 -0
- package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
- package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
- package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
- package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
- package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
- package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
- package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
- package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
- package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
- package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
- package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
- package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
- package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
- package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
- package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
- package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
- package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
- package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
- package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
- package/app/modules/stickerPackModule/stickerPackService.js +788 -0
- package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
- package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
- package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
- package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
- package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
- package/app/modules/systemMetricsModule/pingCommand.js +421 -0
- package/app/modules/tiktokModule/tiktokCommand.js +798 -0
- package/app/modules/userModule/userCommand.js +1217 -0
- package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
- package/app/observability/metrics.js +734 -0
- package/app/services/captchaService.js +492 -0
- package/app/services/dbWriteQueue.js +572 -0
- package/app/services/groupMetadataService.js +279 -0
- package/app/services/lidMapService.js +663 -0
- package/app/services/messagePersistenceService.js +56 -0
- package/app/services/newsBroadcastService.js +351 -0
- package/app/services/pokeApiService.js +398 -0
- package/app/services/queueUtils.js +57 -0
- package/app/services/socketState.js +7 -0
- package/app/store/aiPromptStore.js +38 -0
- package/app/store/groupConfigStore.js +58 -0
- package/app/store/premiumUserStore.js +36 -0
- package/app/utils/antiLink/antiLinkModule.js +804 -0
- package/app/utils/http/getImageBufferModule.js +18 -0
- package/app/utils/json/jsonSanitizer.js +113 -0
- package/app/utils/json/jsonSanitizer.test.js +40 -0
- package/app/utils/logger/loggerModule.js +262 -0
- package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
- package/database/index.js +2052 -0
- package/database/init.js +516 -0
- package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
- package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
- package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
- package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
- package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
- package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
- package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
- package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
- package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
- package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
- package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
- package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
- package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
- package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
- package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
- package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
- package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
- package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
- package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
- package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
- package/docker-compose.yml +103 -0
- package/ecosystem.prod.config.cjs +35 -0
- package/eslint.config.js +61 -0
- package/index.js +437 -0
- package/ml/clip_classifier/Dockerfile +16 -0
- package/ml/clip_classifier/README.md +120 -0
- package/ml/clip_classifier/adaptive_scoring.py +40 -0
- package/ml/clip_classifier/classifier.py +654 -0
- package/ml/clip_classifier/embedding_store.py +481 -0
- package/ml/clip_classifier/env_loader.py +15 -0
- package/ml/clip_classifier/llm_label_expander.py +144 -0
- package/ml/clip_classifier/main.py +213 -0
- package/ml/clip_classifier/requirements.txt +10 -0
- package/ml/clip_classifier/similarity_engine.py +74 -0
- package/observability/alert-rules.yml +60 -0
- package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
- package/observability/grafana/dashboards/omnizap-overview.json +170 -0
- package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
- package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
- package/observability/loki-config.yml +38 -0
- package/observability/mysql-exporter.cnf +5 -0
- package/observability/mysql-setup.sql +46 -0
- package/observability/prometheus.yml +32 -0
- package/observability/promtail-config.yml +84 -0
- package/package.json +109 -0
- package/public/api-docs/index.html +144 -0
- package/public/css/github-project-panel.css +297 -0
- package/public/css/stickers-admin.css +1272 -0
- package/public/css/styles.css +671 -0
- package/public/index.html +1311 -0
- package/public/js/apps/apiDocsApp.js +310 -0
- package/public/js/apps/createPackApp.js +2069 -0
- package/public/js/apps/homeApp.js +396 -0
- package/public/js/apps/stickersAdminApp.js +1744 -0
- package/public/js/apps/stickersApp.js +4830 -0
- package/public/js/catalog.js +1019 -0
- package/public/js/github-panel/components/CommitList.js +34 -0
- package/public/js/github-panel/components/ErrorState.js +16 -0
- package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
- package/public/js/github-panel/components/ReleaseList.js +38 -0
- package/public/js/github-panel/components/SkeletonPanel.js +22 -0
- package/public/js/github-panel/components/StatCard.js +15 -0
- package/public/js/github-panel/index.js +15 -0
- package/public/js/github-panel/useGithubRepoData.js +154 -0
- package/public/js/github-panel/vendor/react.js +11 -0
- package/public/js/runtime/react-runtime.js +19 -0
- package/public/licenca/index.html +106 -0
- package/public/stickers/admin/index.html +23 -0
- package/public/stickers/create/index.html +47 -0
- package/public/stickers/index.html +48 -0
- package/public/termos-de-uso/index.html +125 -0
- package/scripts/cache-bust.mjs +107 -0
- package/scripts/deploy.sh +458 -0
- package/scripts/github-deploy-notify.mjs +174 -0
- 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
|
+
};
|