-
Notifications
You must be signed in to change notification settings - Fork 6.1k
Feature: Endpoint para Descriptografar e Visualizar Votos de Enquetes #2297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
5faf3d1
076449e
67c4aa6
2fee505
6ede76f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5119,4 +5119,247 @@ | |
| }, | ||
| }; | ||
| } | ||
|
|
||
| public async baileysDecryptPollVote(pollCreationMessageKey: proto.IMessageKey) { | ||
| try { | ||
| this.logger.verbose('Starting poll vote decryption process'); | ||
|
|
||
| // Buscar a mensagem de criação da enquete | ||
| const pollCreationMessage = (await this.getMessage(pollCreationMessageKey, true)) as proto.IWebMessageInfo; | ||
|
|
||
| if (!pollCreationMessage) { | ||
| throw new NotFoundException('Poll creation message not found'); | ||
| } | ||
|
|
||
| // Extrair opções da enquete | ||
| const pollOptions = | ||
| (pollCreationMessage.message as any)?.pollCreationMessage?.options || | ||
| (pollCreationMessage.message as any)?.pollCreationMessageV3?.options || | ||
| []; | ||
|
|
||
| if (!pollOptions || pollOptions.length === 0) { | ||
| throw new NotFoundException('Poll options not found'); | ||
| } | ||
|
|
||
| // Recuperar chave de criptografia | ||
| const pollMessageSecret = (await this.getMessage(pollCreationMessageKey)) as any; | ||
| let pollEncKey = pollMessageSecret?.messageContextInfo?.messageSecret; | ||
|
|
||
| if (!pollEncKey) { | ||
| throw new NotFoundException('Poll encryption key not found'); | ||
| } | ||
|
|
||
| // Normalizar chave de criptografia | ||
| if (typeof pollEncKey === 'string') { | ||
| pollEncKey = Buffer.from(pollEncKey, 'base64'); | ||
| } else if (pollEncKey?.type === 'Buffer' && Array.isArray(pollEncKey.data)) { | ||
| pollEncKey = Buffer.from(pollEncKey.data); | ||
| } | ||
|
|
||
| if (Buffer.isBuffer(pollEncKey) && pollEncKey.length === 44) { | ||
| pollEncKey = Buffer.from(pollEncKey.toString('utf8'), 'base64'); | ||
| } | ||
|
|
||
| // Buscar todas as mensagens de atualização de votos | ||
| const allPollUpdateMessages = await this.prismaRepository.message.findMany({ | ||
| where: { | ||
| instanceId: this.instanceId, | ||
| messageType: 'pollUpdateMessage', | ||
| }, | ||
| select: { | ||
| id: true, | ||
| key: true, | ||
| message: true, | ||
| messageTimestamp: true, | ||
| }, | ||
| }); | ||
|
|
||
| this.logger.verbose(`Found ${allPollUpdateMessages.length} pollUpdateMessage messages in database`); | ||
|
|
||
| // Filtrar apenas mensagens relacionadas a esta enquete específica | ||
| const pollUpdateMessages = allPollUpdateMessages.filter((msg) => { | ||
| const pollUpdate = (msg.message as any)?.pollUpdateMessage; | ||
| if (!pollUpdate) return false; | ||
|
|
||
| const creationKey = pollUpdate.pollCreationMessageKey; | ||
| if (!creationKey) return false; | ||
|
|
||
| return ( | ||
| creationKey.id === pollCreationMessageKey.id && | ||
| jidNormalizedUser(creationKey.remoteJid || '') === jidNormalizedUser(pollCreationMessageKey.remoteJid || '') | ||
| ); | ||
| }); | ||
|
|
||
| this.logger.verbose(`Filtered to ${pollUpdateMessages.length} matching poll update messages`); | ||
|
|
||
| // Preparar candidatos de JID para descriptografia | ||
| const creatorCandidates = [ | ||
| this.instance.wuid, | ||
| this.client.user?.lid, | ||
| pollCreationMessage.key.participant, | ||
| (pollCreationMessage.key as any).participantAlt, | ||
| pollCreationMessage.key.remoteJid, | ||
| (pollCreationMessage.key as any).remoteJidAlt, | ||
| ].filter(Boolean); | ||
|
|
||
| const uniqueCreators = [...new Set(creatorCandidates.map((id) => jidNormalizedUser(id)))]; | ||
|
|
||
| // Processar votos | ||
| const votesByUser = new Map<string, { timestamp: number; selectedOptions: string[]; voterJid: string }>(); | ||
|
|
||
| this.logger.verbose(`Processing ${pollUpdateMessages.length} poll update messages for decryption`); | ||
|
|
||
| for (const pollUpdateMsg of pollUpdateMessages) { | ||
| const pollVote = (pollUpdateMsg.message as any)?.pollUpdateMessage?.vote; | ||
| if (!pollVote) continue; | ||
|
|
||
| const key = pollUpdateMsg.key as any; | ||
| const voterCandidates = [ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a bug in this: It blindly picks the first entry. If The fix: The actual voter JID should come from key.participant (in group messages, this is who sent the poll update) or fall back to key.remoteJid. The instance owner's JID and group JID should NOT be in the voter candidates — they belong in the creatorCandidates list only. |
||
| this.instance.wuid, | ||
| this.client.user?.lid, | ||
| key.participant, | ||
| key.participantAlt, | ||
| key.remoteJidAlt, | ||
| key.remoteJid, | ||
| ].filter(Boolean); | ||
|
|
||
| const uniqueVoters = [...new Set(voterCandidates.map((id) => jidNormalizedUser(id)))]; | ||
|
|
||
| let selectedOptionNames: string[] = []; | ||
| let successfulVoterJid: string | undefined; | ||
|
|
||
| // Verificar se o voto já está descriptografado | ||
| if (pollVote.selectedOptions && Array.isArray(pollVote.selectedOptions)) { | ||
| const selectedOptions = pollVote.selectedOptions; | ||
| this.logger.verbose('Vote already has selectedOptions, checking format'); | ||
|
|
||
| // Verificar se são strings (já descriptografado) ou buffers (precisa descriptografar) | ||
| if (selectedOptions.length > 0 && typeof selectedOptions[0] === 'string') { | ||
| // Já está descriptografado como nomes de opções | ||
| selectedOptionNames = selectedOptions; | ||
| successfulVoterJid = uniqueVoters[0]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (bug_risk): When using pre-decrypted In the branch where Suggested implementation: // Verificar se são strings (já descriptografado) ou buffers (precisa descriptografar)
if (selectedOptions.length > 0 && typeof selectedOptions[0] === 'string') {
// Já está descriptografado como nomes de opções
selectedOptionNames = selectedOptions;
// Preferir um JID de participante confiável a partir dos metadados da mensagem
// antes de recorrer a uma suposição baseada em listas agregadas.
const definitiveVoterJid =
// JID do participante definido pelo próprio evento/mensagem (mais confiável)
(pollVote.participant as string | undefined) ||
(pollVote.sender as string | undefined) ||
// Se houver candidatos de votante, assumir que estão ordenados por confiabilidade
(Array.isArray(voterCandidates) && voterCandidates.length > 0
? (voterCandidates[0] as string)
: undefined) ||
// Fallback final para a lista de votantes únicos
(Array.isArray(uniqueVoters) && uniqueVoters.length > 0
? (uniqueVoters[0] as string)
: undefined);
successfulVoterJid = definitiveVoterJid;
if (!successfulVoterJid) {
this.logger.warn(
`Unable to reliably determine voter JID for already decrypted vote; options=${selectedOptionNames.join(',')}`,
);
} else if (Array.isArray(voterCandidates) && voterCandidates.length > 1) {
this.logger.verbose(
`Multiple voterCandidates found for already decrypted vote; candidates=${voterCandidates.join(
',',
)}, chosen=${successfulVoterJid}`,
);
}
this.logger.verbose(
`Using already decrypted vote: voter=${successfulVoterJid ?? 'unknown'}, options=${selectedOptionNames.join(',')}`,
);
} else {
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bom demais veiiiiiiiiiiii!!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Booora! 😄 Tá ficando muito massa mesmo. Só reforçando um ponto rápido antes de ir pra produção: na parte em que usamos Se vocês já validaram que na prática sempre vem um único candidato ou que a ordem dos candidatos é confiável, tranquilo seguir assim. Caso contrário, vale considerar depois aquela melhoria de escolher o JID mais “definitivo” a partir dos metadados da mensagem (tipo Mas, no geral, a feature tá muito bem amarrada — logs bons, tratamento de erro decente e agregação correta dos votos. 👏 Se quiser, posso ajudar a desenhar uns testes específicos pra esse cenário de múltiplos JIDs antes/depois de subir em produção. |
||
| this.logger.verbose(`Using already decrypted vote: voter=${successfulVoterJid}, options=${selectedOptionNames.join(',')}`); | ||
|
Check failure on line 5241 in src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
|
||
| } else { | ||
| // Está como hash, precisa converter para nomes | ||
| selectedOptionNames = pollOptions | ||
| .filter((option: any) => { | ||
| const hash = createHash('sha256').update(option.optionName).digest(); | ||
| return selectedOptions.some((selected: any) => { | ||
| if (Buffer.isBuffer(selected)) { | ||
| return Buffer.compare(selected, hash) === 0; | ||
| } | ||
| return false; | ||
| }); | ||
| }) | ||
| .map((option: any) => option.optionName); | ||
| successfulVoterJid = uniqueVoters[0]; | ||
| } | ||
| } else if (pollVote.encPayload && pollEncKey) { | ||
| // Tentar descriptografar | ||
| let decryptedVote: any = null; | ||
|
|
||
| for (const creator of uniqueCreators) { | ||
| for (const voter of uniqueVoters) { | ||
| try { | ||
| decryptedVote = decryptPollVote(pollVote, { | ||
| pollCreatorJid: creator, | ||
| pollMsgId: pollCreationMessage.key.id, | ||
| pollEncKey, | ||
| voterJid: voter, | ||
| } as any); | ||
|
|
||
| if (decryptedVote) { | ||
| successfulVoterJid = voter; | ||
| break; | ||
| } | ||
| } catch (error) { | ||
| // Continue tentando outras combinações | ||
| } | ||
| } | ||
| if (decryptedVote) break; | ||
| } | ||
|
|
||
| if (decryptedVote && decryptedVote.selectedOptions) { | ||
| // Converter hashes para nomes de opções | ||
| selectedOptionNames = pollOptions | ||
| .filter((option: any) => { | ||
| const hash = createHash('sha256').update(option.optionName).digest(); | ||
| return decryptedVote.selectedOptions.some((selected: any) => { | ||
| if (Buffer.isBuffer(selected)) { | ||
| return Buffer.compare(selected, hash) === 0; | ||
| } | ||
| return false; | ||
| }); | ||
| }) | ||
| .map((option: any) => option.optionName); | ||
|
|
||
| this.logger.verbose(`Successfully decrypted vote for voter: ${successfulVoterJid}, creator: ${uniqueCreators[0]}`); | ||
|
Check failure on line 5296 in src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
|
||
| } else { | ||
| this.logger.warn(`Failed to decrypt vote. Last error: Could not decrypt with any combination`); | ||
| continue; | ||
| } | ||
| } else { | ||
| this.logger.warn('Vote has no encPayload and no selectedOptions, skipping'); | ||
| continue; | ||
| } | ||
|
|
||
| if (selectedOptionNames.length > 0 && successfulVoterJid) { | ||
| const normalizedVoterJid = jidNormalizedUser(successfulVoterJid); | ||
| const existingVote = votesByUser.get(normalizedVoterJid); | ||
|
|
||
| // Manter apenas o voto mais recente de cada usuário | ||
| if (!existingVote || pollUpdateMsg.messageTimestamp > existingVote.timestamp) { | ||
| votesByUser.set(normalizedVoterJid, { | ||
| timestamp: pollUpdateMsg.messageTimestamp, | ||
| selectedOptions: selectedOptionNames, | ||
| voterJid: successfulVoterJid, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Agrupar votos por opção | ||
| const results: Record<string, { votes: number; voters: string[] }> = {}; | ||
|
|
||
| // Inicializar todas as opções com zero votos | ||
| pollOptions.forEach((option: any) => { | ||
| results[option.optionName] = { | ||
| votes: 0, | ||
| voters: [], | ||
| }; | ||
| }); | ||
|
|
||
| // Agregar votos | ||
| votesByUser.forEach((voteData) => { | ||
| voteData.selectedOptions.forEach((optionName) => { | ||
| if (results[optionName]) { | ||
| results[optionName].votes++; | ||
| if (!results[optionName].voters.includes(voteData.voterJid)) { | ||
| results[optionName].voters.push(voteData.voterJid); | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| // Obter nome da enquete | ||
| const pollName = | ||
| (pollCreationMessage.message as any)?.pollCreationMessage?.name || | ||
| (pollCreationMessage.message as any)?.pollCreationMessageV3?.name || | ||
| 'Enquete sem nome'; | ||
|
|
||
| // Calcular total de votos únicos | ||
| const totalVotes = votesByUser.size; | ||
|
|
||
| return { | ||
| poll: { | ||
| name: pollName, | ||
| totalVotes, | ||
| results, | ||
| }, | ||
| }; | ||
| } catch (error) { | ||
| this.logger.error(`Error decrypting poll votes: ${error}`); | ||
| throw new InternalServerErrorException('Error decrypting poll votes', error.toString()); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (performance): Filter poll update messages at the DB level to avoid loading all messages into memory.
This query only filters by
instanceIdandmessageTypeand then narrows to the specific poll in memory. On instances with many polls, this can load a largepollUpdateMessageset on everybaileysDecryptPollVotecall. Where possible, add more selective criteria to the DB query (e.g., store and filter bypollCreationMessageKey.id/remoteJidor a derived index) so only updates for the target poll are loaded.Suggested implementation:
If your Prisma model does not currently support JSON path filtering on
message, you will need to:messagefield is of typeJson(Json/Json?) in your Prisma schema and that your DB column isjsonb(Postgres) or equivalent.messagefilter with whatever is supported in your setup (e.g., a custom index or a different column storingpollCreationMessageKey.id), while keeping the newkey.remoteJidfilter.(instanceId, messageType, key->>'remoteJid', message->'pollUpdateMessage'->'pollCreationMessageKey'->>'id')or equivalent in your DB to make this query efficient at scale.