@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,851 @@
1
+ import { createCanvas, loadImage } from 'canvas';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+
6
+ import logger from '../../utils/logger/loggerModule.js';
7
+ import { getJidUser } from '../../config/baileysConfig.js';
8
+ import { resolveUserId } from '../../services/lidMapService.js';
9
+ import { convertToWebp } from '../stickerModule/convertToWebp.js';
10
+ import { addStickerMetadata } from '../stickerModule/addStickerMetadata.js';
11
+ import { fetchLatestPushNames } from '../statsModule/rankingCommon.js';
12
+ import { sendAndStore } from '../../services/messagePersistenceService.js';
13
+
14
+ const DEFAULT_COMMAND_PREFIX = process.env.COMMAND_PREFIX || '/';
15
+ const QUOTE_BUBBLE_COLOR = process.env.QUOTE_BG_COLOR || '#144d37';
16
+ const QUOTE_NAME_COLOR = process.env.QUOTE_NAME_COLOR || '#facc01';
17
+ const QUOTE_TEXT_COLOR = process.env.QUOTE_TEXT_COLOR || '#e8eef6';
18
+ const QUOTE_TIMEOUT_MS = Number.parseInt(process.env.QUOTE_TIMEOUT_MS || '10000', 10);
19
+ const QUOTE_EMOJI_TIMEOUT_MS = Number.parseInt(process.env.QUOTE_EMOJI_TIMEOUT_MS || '4000', 10);
20
+ const QUOTE_EMOJI_BASE_URL =
21
+ process.env.QUOTE_EMOJI_BASE_URL || 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/png/128';
22
+ const QUOTE_FONT_FAMILY = process.env.QUOTE_FONT_FAMILY || '"Noto Sans","Segoe UI","Arial","Noto Color Emoji","Apple Color Emoji","Segoe UI Emoji",sans-serif';
23
+
24
+ const QUOTE_CANVAS_MAX_WIDTH = 920;
25
+ const QUOTE_CANVAS_MIN_WIDTH = 520;
26
+ const QUOTE_CANVAS_MAX_HEIGHT = 760;
27
+ const QUOTE_CANVAS_MIN_HEIGHT = 220;
28
+
29
+ const QUOTE_BUBBLE_X = 132;
30
+ const QUOTE_BUBBLE_Y = 26;
31
+ const QUOTE_BUBBLE_RADIUS = 44;
32
+ const QUOTE_BUBBLE_RIGHT_MARGIN = 18;
33
+ const QUOTE_BUBBLE_BOTTOM_MARGIN = 24;
34
+ const QUOTE_BUBBLE_PADDING_X = 34;
35
+ const QUOTE_BUBBLE_PADDING_TOP = 24;
36
+ const QUOTE_BUBBLE_PADDING_BOTTOM = 28;
37
+
38
+ const QUOTE_AVATAR_X = 10;
39
+ const QUOTE_AVATAR_SIZE = 104;
40
+ const QUOTE_AVATAR_BORDER = 2;
41
+
42
+ const QUOTE_NAME_FONT_MAX = 56;
43
+ const QUOTE_NAME_FONT_MIN = 32;
44
+ const QUOTE_TEXT_FONT_MAX = 58;
45
+ const QUOTE_TEXT_FONT_MIN = 34;
46
+ const QUOTE_MAX_LINES = 6;
47
+ const QUOTE_NAME_TEXT_GAP = 12;
48
+
49
+ const TEMP_DIR = path.join(process.cwd(), 'temp', 'quotes');
50
+ const GRAPHEME_SEGMENTER = typeof Intl?.Segmenter === 'function' ? new Intl.Segmenter('en', { granularity: 'grapheme' }) : null;
51
+ const EMOJI_SEGMENT_REGEX = /\p{Extended_Pictographic}/u;
52
+ const EMOJI_VARIATION_SELECTOR = 'fe0f';
53
+ const EMOJI_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
54
+ const EMOJI_FAIL_TTL_MS = 30 * 60 * 1000;
55
+ const EMOJI_IMAGE_CACHE = globalThis.__omnizapQuoteEmojiImageCache || new Map();
56
+ globalThis.__omnizapQuoteEmojiImageCache = EMOJI_IMAGE_CACHE;
57
+
58
+ const isValidJid = (jid) => typeof jid === 'string' && jid.includes('@');
59
+ const normalizeMentionedJids = (mentionedJids) => (Array.isArray(mentionedJids) ? mentionedJids.filter(Boolean) : []);
60
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
61
+
62
+ /**
63
+ * Extrai texto bruto de diferentes formatos de mensagem WhatsApp.
64
+ *
65
+ * @param {object} [message={}] Conteúdo normalizado de mensagem.
66
+ * @returns {string} Texto encontrado (ou string vazia).
67
+ */
68
+ const extractTextFromMessage = (message = {}) => {
69
+ const text = message.conversation?.trim() || message.extendedTextMessage?.text;
70
+ if (text) return text;
71
+ if (message.imageMessage?.caption) return message.imageMessage.caption;
72
+ if (message.videoMessage?.caption) return message.videoMessage.caption;
73
+ if (message.documentMessage?.fileName) return message.documentMessage.fileName;
74
+ return '';
75
+ };
76
+
77
+ const getContactNameFromSock = (sock, jid) => {
78
+ const contact = sock?.contacts?.[jid];
79
+ return contact?.notify || contact?.name || contact?.short || null;
80
+ };
81
+
82
+ /**
83
+ * Resolve o nome de exibição preferindo contatos em memória e fallback de ranking.
84
+ *
85
+ * @param {object} sock Instância do Baileys socket.
86
+ * @param {string|null} jid JID alvo.
87
+ * @returns {Promise<string|null>} Nome resolvido ou `null`.
88
+ */
89
+ const resolveDisplayName = async (sock, jid) => {
90
+ if (!jid) return null;
91
+ const fromSock = getContactNameFromSock(sock, jid);
92
+ if (fromSock) return fromSock;
93
+ try {
94
+ const map = await fetchLatestPushNames([jid]);
95
+ return map.get(jid) || null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ };
100
+
101
+ const hashString = (value) => {
102
+ const input = `${value || ''}`;
103
+ let hash = 0;
104
+ for (let i = 0; i < input.length; i += 1) {
105
+ hash = (hash * 31 + input.charCodeAt(i)) | 0;
106
+ }
107
+ return Math.abs(hash);
108
+ };
109
+
110
+ const toInitials = (value) => {
111
+ const clean = `${value || ''}`.trim().replace(/\s+/g, ' ');
112
+ if (!clean) return 'O';
113
+ const parts = clean.split(' ').filter(Boolean);
114
+ if (!parts.length) return 'O';
115
+ if (parts.length === 1) {
116
+ return (
117
+ parts[0]
118
+ .replace(/[^a-zA-Z0-9]/g, '')
119
+ .slice(0, 2)
120
+ .toUpperCase() || 'O'
121
+ );
122
+ }
123
+ const left = parts[0].replace(/[^a-zA-Z0-9]/g, '').charAt(0);
124
+ const right = parts[parts.length - 1].replace(/[^a-zA-Z0-9]/g, '').charAt(0);
125
+ return `${left}${right}`.toUpperCase() || 'O';
126
+ };
127
+
128
+ /**
129
+ * Separa uma menção inicial no formato "@12345 texto".
130
+ *
131
+ * @param {string} text Texto informado pelo usuário.
132
+ * @returns {{ mention: string|null, rest: string }} Menção sem `@` e restante da frase.
133
+ */
134
+ const parseLeadingMention = (text) => {
135
+ const trimmed = text?.trim() || '';
136
+ if (!trimmed) return { mention: null, rest: '' };
137
+ const parts = trimmed.split(/\s+/);
138
+ const first = parts[0];
139
+ if (first && first.startsWith('@') && first.length > 1) {
140
+ return { mention: first.slice(1), rest: parts.slice(1).join(' ').trim() };
141
+ }
142
+ return { mention: null, rest: trimmed };
143
+ };
144
+
145
+ /**
146
+ * Converte menção numérica para JID padrão do WhatsApp.
147
+ *
148
+ * @param {string|null} mention Menção sem `@`.
149
+ * @returns {string|null} JID no formato `123@s.whatsapp.net`.
150
+ */
151
+ const buildJidFromMention = (mention) => {
152
+ if (!mention) return null;
153
+ const digits = mention.replace(/\D/g, '');
154
+ if (!digits) return null;
155
+ return `${digits}@s.whatsapp.net`;
156
+ };
157
+
158
+ /**
159
+ * Extrai possíveis participantes de uma mensagem citada.
160
+ *
161
+ * @param {object} [contextInfo={}] Contexto da mensagem.
162
+ * @returns {{
163
+ * participant: string|null,
164
+ * participantAlt: string|null,
165
+ * keyParticipant: string|null,
166
+ * keyParticipantAlt: string|null
167
+ * }} Identificadores candidatos do autor citado.
168
+ */
169
+ const resolveQuotedTarget = (contextInfo = {}) => {
170
+ const participant = contextInfo?.participant || null;
171
+ const participantAlt = contextInfo?.participantAlt || null;
172
+ const quotedKey = contextInfo?.quotedMessageKey || contextInfo?.quotedMessage?.key || contextInfo?.quotedMessage?.contextInfo?.quotedMessageKey || null;
173
+ const keyParticipant = quotedKey?.participant || null;
174
+ const keyParticipantAlt = quotedKey?.participantAlt || null;
175
+ return {
176
+ participant,
177
+ participantAlt,
178
+ keyParticipant,
179
+ keyParticipantAlt,
180
+ };
181
+ };
182
+
183
+ /**
184
+ * Resolve JID final/alternativo considerando cenário LID e falhas de resolução.
185
+ *
186
+ * @param {{ primaryJid: string|null, altJid: string|null }} params Identificadores candidatos.
187
+ * @returns {Promise<{ targetJid: string|null, resolvedJid: string|null }>} IDs prontos para uso.
188
+ */
189
+ const resolveTargetJids = async ({ primaryJid, altJid }) => {
190
+ const primary = isValidJid(primaryJid) ? primaryJid : null;
191
+ const alt = isValidJid(altJid) ? altJid : null;
192
+ try {
193
+ const resolved = await resolveUserId({ jid: primary, participantAlt: alt, lid: primary });
194
+ return { targetJid: primary || alt || null, resolvedJid: resolved || alt || primary || null };
195
+ } catch (error) {
196
+ logger.warn('quote: falha ao resolver LID', { error: error.message });
197
+ return { targetJid: primary || alt || null, resolvedJid: alt || primary || null };
198
+ }
199
+ };
200
+
201
+ /**
202
+ * Segmenta uma string em grafemas para preservar emojis compostos.
203
+ *
204
+ * @param {string} text Texto de entrada.
205
+ * @returns {string[]} Lista de grafemas.
206
+ */
207
+ const segmentGraphemes = (text) => {
208
+ const input = `${text || ''}`;
209
+ if (!input) return [];
210
+ if (GRAPHEME_SEGMENTER) {
211
+ return [...GRAPHEME_SEGMENTER.segment(input)].map((chunk) => chunk.segment);
212
+ }
213
+ return Array.from(input);
214
+ };
215
+
216
+ const isEmojiSegment = (segment) => EMOJI_SEGMENT_REGEX.test(segment);
217
+
218
+ /**
219
+ * Retorna code points hexadecimais de um grafema.
220
+ *
221
+ * @param {string} segment Grafema individual.
222
+ * @returns {string[]} Lista de code points.
223
+ */
224
+ const toCodePoints = (segment) => [...segment].map((char) => char.codePointAt(0).toString(16));
225
+
226
+ /**
227
+ * Monta variações de chave para buscar asset de emoji (com/sem VS16).
228
+ *
229
+ * @param {string} segment Emoji/grafema.
230
+ * @returns {string[]} Chaves candidatas no formato `1f600` ou `2764_fe0f`.
231
+ */
232
+ const buildEmojiAssetKeys = (segment) => {
233
+ const original = toCodePoints(segment);
234
+ const noVariation = original.filter((value) => value !== EMOJI_VARIATION_SELECTOR);
235
+ const variants = [original.join('_')];
236
+ if (noVariation.length && noVariation.join('_') !== variants[0]) {
237
+ variants.push(noVariation.join('_'));
238
+ }
239
+ return variants.filter(Boolean);
240
+ };
241
+
242
+ /**
243
+ * Constrói URL do asset PNG de emoji Android (Noto Emoji).
244
+ *
245
+ * @param {string} assetKey Chave de code points.
246
+ * @returns {string} URL final para download.
247
+ */
248
+ const getEmojiAssetUrl = (assetKey) =>
249
+ `${QUOTE_EMOJI_BASE_URL.replace(/\/+$/, '')}/emoji_u${assetKey}.png`;
250
+
251
+ /**
252
+ * Busca um emoji do cache com TTL para sucesso/falha.
253
+ *
254
+ * @param {string} cacheKey Chave do emoji.
255
+ * @returns {import('canvas').Image|null|undefined} `undefined` quando expirado/inexistente.
256
+ */
257
+ const getCachedEmojiImage = (cacheKey) => {
258
+ const entry = EMOJI_IMAGE_CACHE.get(cacheKey);
259
+ if (!entry) return undefined;
260
+
261
+ const ttl = entry.image ? EMOJI_CACHE_TTL_MS : EMOJI_FAIL_TTL_MS;
262
+ if (Date.now() - entry.createdAt > ttl) {
263
+ EMOJI_IMAGE_CACHE.delete(cacheKey);
264
+ return undefined;
265
+ }
266
+
267
+ return entry.image;
268
+ };
269
+
270
+ /**
271
+ * Salva resultado de resolução de emoji no cache global.
272
+ *
273
+ * @param {string} cacheKey Chave do emoji.
274
+ * @param {import('canvas').Image|null} image Imagem carregada ou `null` para cache negativo.
275
+ * @returns {void}
276
+ */
277
+ const setCachedEmojiImage = (cacheKey, image) => {
278
+ EMOJI_IMAGE_CACHE.set(cacheKey, { image, createdAt: Date.now() });
279
+ };
280
+
281
+ /**
282
+ * Resolve imagem PNG de emoji no estilo Android com fallback de cache negativo.
283
+ *
284
+ * @param {string} segment Grafema de emoji.
285
+ * @returns {Promise<import('canvas').Image|null>} Imagem carregada ou `null`.
286
+ */
287
+ const resolveEmojiImage = async (segment) => {
288
+ const cached = getCachedEmojiImage(segment);
289
+ if (cached !== undefined) return cached;
290
+
291
+ const variants = buildEmojiAssetKeys(segment);
292
+ for (const key of variants) {
293
+ const buffer = await fetchImageBuffer(getEmojiAssetUrl(key), QUOTE_EMOJI_TIMEOUT_MS);
294
+ if (!buffer || buffer.length === 0) continue;
295
+
296
+ try {
297
+ const image = await loadImage(buffer);
298
+ setCachedEmojiImage(segment, image);
299
+ return image;
300
+ } catch {
301
+ continue;
302
+ }
303
+ }
304
+
305
+ setCachedEmojiImage(segment, null);
306
+ return null;
307
+ };
308
+
309
+ /**
310
+ * Coleta todos os grafemas de emoji presentes em múltiplos textos.
311
+ *
312
+ * @param {string[]} texts Lista de textos.
313
+ * @returns {string[]} Emojis únicos encontrados.
314
+ */
315
+ const collectEmojiSegments = (texts) => {
316
+ const emojiSet = new Set();
317
+ for (const text of texts) {
318
+ for (const segment of segmentGraphemes(text)) {
319
+ if (isEmojiSegment(segment)) emojiSet.add(segment);
320
+ }
321
+ }
322
+ return [...emojiSet];
323
+ };
324
+
325
+ /**
326
+ * Mede largura visual do texto tratando emojis como avanço fixo.
327
+ *
328
+ * @param {import('canvas').CanvasRenderingContext2D} ctx Contexto de renderização.
329
+ * @param {string} text Texto a medir.
330
+ * @param {number} fontSize Tamanho da fonte atual.
331
+ * @returns {number} Largura total em pixels.
332
+ */
333
+ const measureTextVisualWidth = (ctx, text, fontSize) => {
334
+ const graphemes = segmentGraphemes(text);
335
+ const emojiAdvance = Math.round(fontSize * 1.06);
336
+ let width = 0;
337
+
338
+ for (const segment of graphemes) {
339
+ if (isEmojiSegment(segment)) {
340
+ width += emojiAdvance;
341
+ continue;
342
+ }
343
+ width += ctx.measureText(segment).width;
344
+ }
345
+
346
+ return width;
347
+ };
348
+
349
+ /**
350
+ * Desenha texto com suporte a emojis Android renderizados por imagem.
351
+ *
352
+ * @param {import('canvas').CanvasRenderingContext2D} ctx Contexto de renderização.
353
+ * @param {string} text Texto final.
354
+ * @param {number} x Posição X inicial.
355
+ * @param {number} y Posição Y (baseline top).
356
+ * @param {number} fontSize Tamanho da fonte corrente.
357
+ * @returns {Promise<number>} Largura final desenhada.
358
+ */
359
+ const drawTextWithEmoji = async (ctx, text, x, y, fontSize) => {
360
+ const graphemes = segmentGraphemes(text);
361
+ const emojiAdvance = Math.round(fontSize * 1.06);
362
+ const emojiSize = Math.round(fontSize * 1.1);
363
+ const emojiYOffset = Math.round((fontSize - emojiSize) * 0.5);
364
+ let cursorX = x;
365
+ let plainBuffer = '';
366
+
367
+ const flushPlain = () => {
368
+ if (!plainBuffer) return;
369
+ ctx.fillText(plainBuffer, cursorX, y);
370
+ cursorX += ctx.measureText(plainBuffer).width;
371
+ plainBuffer = '';
372
+ };
373
+
374
+ for (const segment of graphemes) {
375
+ if (isEmojiSegment(segment)) {
376
+ flushPlain();
377
+ const emojiImage = await resolveEmojiImage(segment);
378
+ if (emojiImage) {
379
+ ctx.drawImage(emojiImage, cursorX, y + emojiYOffset, emojiSize, emojiSize);
380
+ cursorX += emojiAdvance;
381
+ } else {
382
+ ctx.fillText(segment, cursorX, y);
383
+ const measured = ctx.measureText(segment).width;
384
+ cursorX += Math.max(measured, emojiAdvance);
385
+ }
386
+ continue;
387
+ }
388
+ plainBuffer += segment;
389
+ }
390
+
391
+ flushPlain();
392
+ return cursorX - x;
393
+ };
394
+
395
+ /**
396
+ * Quebra texto em linhas respeitando largura máxima e grafemas.
397
+ *
398
+ * @param {import('canvas').CanvasRenderingContext2D} ctx Contexto de medição.
399
+ * @param {string} text Texto original.
400
+ * @param {number} maxWidth Largura máxima em pixels.
401
+ * @param {number} fontSize Fonte usada para a medição.
402
+ * @returns {string[]} Linhas quebradas.
403
+ */
404
+ const wrapTextLines = (ctx, text, maxWidth, fontSize) => {
405
+ const lines = [];
406
+ const paragraphs = `${text || ''}`.split(/\r?\n/);
407
+
408
+ for (const paragraph of paragraphs) {
409
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
410
+ if (!words.length) {
411
+ if (lines.length > 0) lines.push('');
412
+ continue;
413
+ }
414
+
415
+ let line = '';
416
+ for (const word of words) {
417
+ const candidate = line ? `${line} ${word}` : word;
418
+ if (measureTextVisualWidth(ctx, candidate, fontSize) <= maxWidth) {
419
+ line = candidate;
420
+ continue;
421
+ }
422
+
423
+ if (line) {
424
+ lines.push(line);
425
+ }
426
+
427
+ if (measureTextVisualWidth(ctx, word, fontSize) <= maxWidth) {
428
+ line = word;
429
+ continue;
430
+ }
431
+
432
+ let chunk = '';
433
+ const graphemes = segmentGraphemes(word);
434
+ for (const grapheme of graphemes) {
435
+ const test = `${chunk}${grapheme}`;
436
+ if (measureTextVisualWidth(ctx, test, fontSize) > maxWidth && chunk) {
437
+ lines.push(chunk);
438
+ chunk = grapheme;
439
+ } else {
440
+ chunk = test;
441
+ }
442
+ }
443
+ line = chunk;
444
+ }
445
+
446
+ if (line) {
447
+ lines.push(line);
448
+ }
449
+ }
450
+
451
+ return lines.length ? lines : [''];
452
+ };
453
+
454
+ /**
455
+ * Aplica reticências na linha para caber no espaço disponível.
456
+ *
457
+ * @param {import('canvas').CanvasRenderingContext2D} ctx Contexto de medição.
458
+ * @param {string} line Linha original.
459
+ * @param {number} maxWidth Largura máxima em pixels.
460
+ * @param {number} fontSize Fonte usada para medição.
461
+ * @returns {string} Linha truncada.
462
+ */
463
+ const ellipsizeLine = (ctx, line, maxWidth, fontSize) => {
464
+ const safeLine = `${line || ''}`;
465
+ if (!safeLine || measureTextVisualWidth(ctx, safeLine, fontSize) <= maxWidth) {
466
+ return safeLine;
467
+ }
468
+
469
+ const ellipsis = '...';
470
+ let trimmed = safeLine;
471
+ while (trimmed.length > 0 && measureTextVisualWidth(ctx, `${trimmed}${ellipsis}`, fontSize) > maxWidth) {
472
+ trimmed = trimmed.slice(0, -1).trimEnd();
473
+ }
474
+ return trimmed ? `${trimmed}${ellipsis}` : ellipsis;
475
+ };
476
+
477
+ /**
478
+ * Reduz fonte do nome do autor até caber na largura disponível.
479
+ *
480
+ * @param {import('canvas').CanvasRenderingContext2D} ctx Contexto de medição.
481
+ * @param {string} authorName Nome do autor.
482
+ * @param {number} maxWidth Largura máxima em pixels.
483
+ * @returns {number} Tamanho de fonte escolhido.
484
+ */
485
+ const fitAuthorFontSize = (ctx, authorName, maxWidth) => {
486
+ let fontSize = QUOTE_NAME_FONT_MAX;
487
+ while (fontSize > QUOTE_NAME_FONT_MIN) {
488
+ ctx.font = `700 ${fontSize}px ${QUOTE_FONT_FAMILY}`;
489
+ if (measureTextVisualWidth(ctx, authorName, fontSize) <= maxWidth) break;
490
+ fontSize -= 2;
491
+ }
492
+ return fontSize;
493
+ };
494
+
495
+ /**
496
+ * Ajusta tamanho de fonte e linhas do texto principal.
497
+ *
498
+ * @param {import('canvas').CanvasRenderingContext2D} ctx Contexto de medição.
499
+ * @param {string} quoteText Texto da citação.
500
+ * @param {number} maxWidth Largura útil da bolha.
501
+ * @returns {{ fontSize: number, lines: string[] }} Resultado final de tipografia.
502
+ */
503
+ const fitQuoteLines = (ctx, quoteText, maxWidth) => {
504
+ let fontSize = QUOTE_TEXT_FONT_MAX;
505
+ let lines = [''];
506
+
507
+ while (fontSize > QUOTE_TEXT_FONT_MIN) {
508
+ ctx.font = `500 ${fontSize}px ${QUOTE_FONT_FAMILY}`;
509
+ lines = wrapTextLines(ctx, quoteText, maxWidth, fontSize);
510
+ if (lines.length <= QUOTE_MAX_LINES) {
511
+ return { fontSize, lines };
512
+ }
513
+ fontSize -= 2;
514
+ }
515
+
516
+ ctx.font = `500 ${QUOTE_TEXT_FONT_MIN}px ${QUOTE_FONT_FAMILY}`;
517
+ lines = wrapTextLines(ctx, quoteText, maxWidth, QUOTE_TEXT_FONT_MIN);
518
+ if (lines.length > QUOTE_MAX_LINES) {
519
+ lines = lines.slice(0, QUOTE_MAX_LINES);
520
+ lines[QUOTE_MAX_LINES - 1] = ellipsizeLine(ctx, lines[QUOTE_MAX_LINES - 1], maxWidth, QUOTE_TEXT_FONT_MIN);
521
+ }
522
+
523
+ return { fontSize: QUOTE_TEXT_FONT_MIN, lines };
524
+ };
525
+
526
+ const buildFallbackAvatarBuffer = (seed) => {
527
+ const size = 256;
528
+ const canvas = createCanvas(size, size);
529
+ const ctx = canvas.getContext('2d');
530
+ const safeSeed = `${seed || 'OmniZap'}`.trim() || 'OmniZap';
531
+
532
+ ctx.clearRect(0, 0, size, size);
533
+
534
+ const hue = hashString(safeSeed) % 360;
535
+ const gradient = ctx.createLinearGradient(0, 0, size, size);
536
+ gradient.addColorStop(0, `hsl(${hue}, 74%, 58%)`);
537
+ gradient.addColorStop(1, `hsl(${(hue + 30) % 360}, 72%, 44%)`);
538
+
539
+ ctx.fillStyle = gradient;
540
+ ctx.beginPath();
541
+ ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
542
+ ctx.fill();
543
+
544
+ ctx.fillStyle = '#ffffff';
545
+ ctx.font = `700 96px ${QUOTE_FONT_FAMILY}`;
546
+ ctx.textAlign = 'center';
547
+ ctx.textBaseline = 'middle';
548
+ ctx.fillText(toInitials(safeSeed), size / 2, size / 2 + 6);
549
+
550
+ return canvas.toBuffer('image/png');
551
+ };
552
+
553
+ const fetchImageBuffer = async (url, timeoutMs = QUOTE_TIMEOUT_MS) => {
554
+ const controller = new globalThis.AbortController();
555
+ const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
556
+
557
+ try {
558
+ const response = await globalThis.fetch(url, { signal: controller.signal });
559
+ if (!response.ok) return null;
560
+ const data = await response.arrayBuffer();
561
+ return Buffer.from(data);
562
+ } catch {
563
+ return null;
564
+ } finally {
565
+ clearTimeout(timeoutHandle);
566
+ }
567
+ };
568
+
569
+ const resolveAvatarBuffer = async (sock, jid, fallbackSeed) => {
570
+ const fallbackBuffer = buildFallbackAvatarBuffer(fallbackSeed);
571
+ if (!jid) return fallbackBuffer;
572
+
573
+ try {
574
+ const url = await sock.profilePictureUrl(jid, 'image');
575
+ if (!url) return fallbackBuffer;
576
+
577
+ const avatarBuffer = await fetchImageBuffer(url);
578
+ if (!avatarBuffer || avatarBuffer.length === 0) return fallbackBuffer;
579
+ return avatarBuffer;
580
+ } catch {
581
+ return fallbackBuffer;
582
+ }
583
+ };
584
+
585
+ const isNearWhite = (r, g, b, a) => a > 0 && r >= 242 && g >= 242 && b >= 242;
586
+
587
+ const clearNearWhiteEdges = (ctx, size) => {
588
+ const imageData = ctx.getImageData(0, 0, size, size);
589
+ const { data } = imageData;
590
+ const visited = new Uint8Array(size * size);
591
+ const queue = new Uint32Array(size * size);
592
+ let head = 0;
593
+ let tail = 0;
594
+
595
+ const push = (x, y) => {
596
+ if (x < 0 || y < 0 || x >= size || y >= size) return;
597
+ const pixelIndex = y * size + x;
598
+ if (visited[pixelIndex]) return;
599
+
600
+ const dataIndex = pixelIndex * 4;
601
+ if (!isNearWhite(data[dataIndex], data[dataIndex + 1], data[dataIndex + 2], data[dataIndex + 3])) {
602
+ return;
603
+ }
604
+
605
+ visited[pixelIndex] = 1;
606
+ queue[tail] = pixelIndex;
607
+ tail += 1;
608
+ };
609
+
610
+ for (let x = 0; x < size; x += 1) {
611
+ push(x, 0);
612
+ push(x, size - 1);
613
+ }
614
+ for (let y = 1; y < size - 1; y += 1) {
615
+ push(0, y);
616
+ push(size - 1, y);
617
+ }
618
+
619
+ while (head < tail) {
620
+ const pixelIndex = queue[head];
621
+ head += 1;
622
+
623
+ const dataIndex = pixelIndex * 4;
624
+ data[dataIndex + 3] = 0;
625
+
626
+ const x = pixelIndex % size;
627
+ const y = Math.floor(pixelIndex / size);
628
+
629
+ push(x - 1, y);
630
+ push(x + 1, y);
631
+ push(x, y - 1);
632
+ push(x, y + 1);
633
+ }
634
+
635
+ ctx.putImageData(imageData, 0, 0);
636
+ };
637
+
638
+ const buildAvatarCanvas = async (avatarBuffer, fallbackSeed) => {
639
+ const canvas = createCanvas(QUOTE_AVATAR_SIZE, QUOTE_AVATAR_SIZE);
640
+ const ctx = canvas.getContext('2d');
641
+
642
+ let sourceImage = null;
643
+ try {
644
+ sourceImage = await loadImage(avatarBuffer);
645
+ } catch {
646
+ sourceImage = await loadImage(buildFallbackAvatarBuffer(fallbackSeed));
647
+ }
648
+
649
+ ctx.clearRect(0, 0, QUOTE_AVATAR_SIZE, QUOTE_AVATAR_SIZE);
650
+ ctx.drawImage(sourceImage, 0, 0, QUOTE_AVATAR_SIZE, QUOTE_AVATAR_SIZE);
651
+ clearNearWhiteEdges(ctx, QUOTE_AVATAR_SIZE);
652
+
653
+ return canvas;
654
+ };
655
+
656
+ const drawRoundedRect = (ctx, x, y, width, height, radius) => {
657
+ const safeRadius = Math.min(radius, width / 2, height / 2);
658
+
659
+ ctx.beginPath();
660
+ ctx.moveTo(x + safeRadius, y);
661
+ ctx.lineTo(x + width - safeRadius, y);
662
+ ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
663
+ ctx.lineTo(x + width, y + height - safeRadius);
664
+ ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
665
+ ctx.lineTo(x + safeRadius, y + height);
666
+ ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
667
+ ctx.lineTo(x, y + safeRadius);
668
+ ctx.quadraticCurveTo(x, y, x + safeRadius, y);
669
+ ctx.closePath();
670
+ };
671
+
672
+ const renderQuoteImage = async ({ authorName, quoteText, avatarBuffer }) => {
673
+ const safeAuthorName = `${authorName || ''}`.trim() || 'user';
674
+ const safeQuoteText = `${quoteText || ''}`.trim() || '...';
675
+
676
+ const measureCanvas = createCanvas(QUOTE_CANVAS_MAX_WIDTH, 320);
677
+ const measureCtx = measureCanvas.getContext('2d');
678
+
679
+ const maxInnerWidth = QUOTE_CANVAS_MAX_WIDTH - QUOTE_BUBBLE_X - QUOTE_BUBBLE_RIGHT_MARGIN - QUOTE_BUBBLE_PADDING_X * 2;
680
+
681
+ const nameFontSize = fitAuthorFontSize(measureCtx, safeAuthorName, maxInnerWidth);
682
+ const quoteFit = fitQuoteLines(measureCtx, safeQuoteText, maxInnerWidth);
683
+ const emojiSegments = collectEmojiSegments([safeAuthorName, ...quoteFit.lines]);
684
+ await Promise.all(emojiSegments.map((segment) => resolveEmojiImage(segment)));
685
+
686
+ measureCtx.font = `700 ${nameFontSize}px ${QUOTE_FONT_FAMILY}`;
687
+ const measuredNameWidth = measureTextVisualWidth(measureCtx, safeAuthorName, nameFontSize);
688
+
689
+ measureCtx.font = `500 ${quoteFit.fontSize}px ${QUOTE_FONT_FAMILY}`;
690
+ const measuredTextWidth = Math.max(...quoteFit.lines.map((line) => measureTextVisualWidth(measureCtx, line, quoteFit.fontSize)));
691
+
692
+ const innerContentWidth = clamp(Math.ceil(Math.max(measuredNameWidth, measuredTextWidth)), 180, maxInnerWidth);
693
+ const bubbleWidth = innerContentWidth + QUOTE_BUBBLE_PADDING_X * 2;
694
+
695
+ const nameLineHeight = Math.round(nameFontSize * 1.08);
696
+ const textLineHeight = Math.round(quoteFit.fontSize * 1.22);
697
+
698
+ const bubbleHeight = QUOTE_BUBBLE_PADDING_TOP + nameLineHeight + QUOTE_NAME_TEXT_GAP + quoteFit.lines.length * textLineHeight + QUOTE_BUBBLE_PADDING_BOTTOM;
699
+
700
+ const canvasWidth = clamp(Math.ceil(QUOTE_BUBBLE_X + bubbleWidth + QUOTE_BUBBLE_RIGHT_MARGIN), QUOTE_CANVAS_MIN_WIDTH, QUOTE_CANVAS_MAX_WIDTH);
701
+ const canvasHeight = clamp(Math.ceil(QUOTE_BUBBLE_Y + bubbleHeight + QUOTE_BUBBLE_BOTTOM_MARGIN), QUOTE_CANVAS_MIN_HEIGHT, QUOTE_CANVAS_MAX_HEIGHT);
702
+
703
+ const canvas = createCanvas(canvasWidth, canvasHeight);
704
+ const ctx = canvas.getContext('2d');
705
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
706
+
707
+ drawRoundedRect(ctx, QUOTE_BUBBLE_X, QUOTE_BUBBLE_Y, bubbleWidth, bubbleHeight, QUOTE_BUBBLE_RADIUS);
708
+ ctx.fillStyle = QUOTE_BUBBLE_COLOR;
709
+ ctx.fill();
710
+
711
+ const avatarY = Math.round(QUOTE_BUBBLE_Y + (bubbleHeight - QUOTE_AVATAR_SIZE) / 2);
712
+ const avatarRadius = QUOTE_AVATAR_SIZE / 2;
713
+ const avatarCenterX = QUOTE_AVATAR_X + avatarRadius;
714
+ const avatarCenterY = avatarY + avatarRadius;
715
+ const avatarCanvas = await buildAvatarCanvas(avatarBuffer, safeAuthorName);
716
+
717
+ ctx.save();
718
+ ctx.beginPath();
719
+ ctx.arc(avatarCenterX, avatarCenterY, avatarRadius, 0, Math.PI * 2);
720
+ ctx.closePath();
721
+ ctx.clip();
722
+ ctx.drawImage(avatarCanvas, QUOTE_AVATAR_X, avatarY, QUOTE_AVATAR_SIZE, QUOTE_AVATAR_SIZE);
723
+ ctx.restore();
724
+
725
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.30)';
726
+ ctx.lineWidth = QUOTE_AVATAR_BORDER;
727
+ ctx.beginPath();
728
+ ctx.arc(avatarCenterX, avatarCenterY, avatarRadius - QUOTE_AVATAR_BORDER / 2, 0, Math.PI * 2);
729
+ ctx.stroke();
730
+
731
+ const textX = QUOTE_BUBBLE_X + QUOTE_BUBBLE_PADDING_X;
732
+ let cursorY = QUOTE_BUBBLE_Y + QUOTE_BUBBLE_PADDING_TOP;
733
+
734
+ ctx.textAlign = 'left';
735
+ ctx.textBaseline = 'top';
736
+ ctx.fillStyle = QUOTE_NAME_COLOR;
737
+ ctx.font = `700 ${nameFontSize}px ${QUOTE_FONT_FAMILY}`;
738
+ await drawTextWithEmoji(ctx, safeAuthorName, textX, cursorY, nameFontSize);
739
+
740
+ cursorY += nameLineHeight + QUOTE_NAME_TEXT_GAP;
741
+ ctx.fillStyle = QUOTE_TEXT_COLOR;
742
+ ctx.font = `500 ${quoteFit.fontSize}px ${QUOTE_FONT_FAMILY}`;
743
+
744
+ for (const line of quoteFit.lines) {
745
+ await drawTextWithEmoji(ctx, line, textX, cursorY, quoteFit.fontSize);
746
+ cursorY += textLineHeight;
747
+ }
748
+
749
+ return canvas.toBuffer('image/png');
750
+ };
751
+
752
+ const writeTempFile = async (buffer, extension) => {
753
+ await fs.mkdir(TEMP_DIR, { recursive: true });
754
+ const filename = `quote_${uuidv4()}.${extension}`;
755
+ const filePath = path.join(TEMP_DIR, filename);
756
+ await fs.writeFile(filePath, buffer);
757
+ return filePath;
758
+ };
759
+
760
+ const sendUsage = async (sock, remoteJid, messageInfo, expirationMessage, commandPrefix = DEFAULT_COMMAND_PREFIX) => {
761
+ await sendAndStore(
762
+ sock,
763
+ remoteJid,
764
+ {
765
+ text: ['🖼️ *Quote*', '', 'Use assim:', `*${commandPrefix}quote* sua mensagem`, '', 'Ou responda uma mensagem com:', `*${commandPrefix}quote*`].join('\n'),
766
+ },
767
+ { quoted: messageInfo, ephemeralExpiration: expirationMessage },
768
+ );
769
+ };
770
+
771
+ export async function handleQuoteCommand({ sock, remoteJid, messageInfo, expirationMessage, senderJid, senderName, text, commandPrefix = DEFAULT_COMMAND_PREFIX }) {
772
+ const contextInfo = messageInfo.message?.extendedTextMessage?.contextInfo;
773
+ const quotedMessage = contextInfo?.quotedMessage;
774
+ const hasQuoted = Boolean(quotedMessage || contextInfo?.stanzaId);
775
+ const mentionedJids = normalizeMentionedJids(contextInfo?.mentionedJid);
776
+
777
+ let targetJid = null;
778
+ let targetAltJid = null;
779
+ let quoteText = '';
780
+
781
+ if (hasQuoted) {
782
+ const quotedInfo = resolveQuotedTarget(contextInfo);
783
+ targetJid = quotedInfo.participant || quotedInfo.keyParticipant || null;
784
+ targetAltJid = quotedInfo.participantAlt || quotedInfo.keyParticipantAlt || null;
785
+ quoteText = extractTextFromMessage(quotedMessage);
786
+ if (!targetJid && targetAltJid) {
787
+ targetJid = targetAltJid;
788
+ }
789
+ } else {
790
+ const { mention, rest } = parseLeadingMention(text);
791
+ const mentionFromContext = mentionedJids[0] || null;
792
+ const mentionFromText = buildJidFromMention(mention);
793
+ const mentionTarget = mentionFromContext || mentionFromText;
794
+ if (mentionTarget) {
795
+ targetJid = mentionTarget;
796
+ quoteText = rest;
797
+ } else {
798
+ targetJid = senderJid;
799
+ quoteText = text?.trim();
800
+ }
801
+ }
802
+
803
+ if (!targetJid) {
804
+ targetJid = senderJid;
805
+ }
806
+ if (!quoteText) {
807
+ await sendUsage(sock, remoteJid, messageInfo, expirationMessage, commandPrefix);
808
+ return;
809
+ }
810
+
811
+ const { targetJid: finalTargetJid, resolvedJid } = await resolveTargetJids({
812
+ primaryJid: targetJid,
813
+ altJid: targetAltJid,
814
+ });
815
+ const jidForProfile = resolvedJid || finalTargetJid || targetJid;
816
+
817
+ const resolvedName = await resolveDisplayName(sock, jidForProfile);
818
+ const fallbackName = `@${getJidUser(jidForProfile || targetJid) || 'user'}`;
819
+ const senderNameFallback = jidForProfile === senderJid ? senderName : null;
820
+ const authorName = resolvedName || senderNameFallback || fallbackName;
821
+
822
+ try {
823
+ const avatarBuffer = await resolveAvatarBuffer(sock, jidForProfile, authorName);
824
+ const imageBuffer = await renderQuoteImage({ authorName, quoteText, avatarBuffer });
825
+
826
+ const pngPath = await writeTempFile(imageBuffer, 'png');
827
+ const userId = getJidUser(senderJid) || senderJid || 'unknown';
828
+ let stickerPath;
829
+
830
+ try {
831
+ const webpPath = await convertToWebp(pngPath, 'image', userId, uuidv4(), { stretch: false });
832
+ stickerPath = await addStickerMetadata(webpPath, 'OmniZap Quotes', senderName || 'OmniZap', {
833
+ senderName,
834
+ userId: senderJid,
835
+ });
836
+ } catch {
837
+ stickerPath = null;
838
+ }
839
+
840
+ if (stickerPath) {
841
+ const stickerBuffer = await fs.readFile(stickerPath);
842
+ await sendAndStore(sock, remoteJid, { sticker: stickerBuffer }, { quoted: messageInfo, ephemeralExpiration: expirationMessage });
843
+ return;
844
+ }
845
+
846
+ await sendAndStore(sock, remoteJid, { image: imageBuffer, caption: '🖼️ Quote' }, { quoted: messageInfo, ephemeralExpiration: expirationMessage });
847
+ } catch (error) {
848
+ logger.error('handleQuoteCommand: erro ao gerar quote.', error);
849
+ await sendAndStore(sock, remoteJid, { text: '❌ Não foi possível gerar o quote agora. Tente novamente.' }, { quoted: messageInfo, ephemeralExpiration: expirationMessage });
850
+ }
851
+ }