Skip to content

Commit 5faf3d1

Browse files
Add poll vote decryption endpoint and logic
Introduces a new API endpoint and supporting logic to decrypt WhatsApp poll votes. Adds DecryptPollVoteDto, validation schema, controller method, and service logic to process and aggregate poll vote results based on poll creation message key.
1 parent 4f642e1 commit 5faf3d1

File tree

5 files changed

+287
-0
lines changed

5 files changed

+287
-0
lines changed

src/api/controllers/chat.controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
ArchiveChatDto,
33
BlockUserDto,
4+
DecryptPollVoteDto,
45
DeleteMessage,
56
getBase64FromMediaMessageDto,
67
MarkChatUnreadDto,
@@ -113,4 +114,8 @@ export class ChatController {
113114
public async blockUser({ instanceName }: InstanceDto, data: BlockUserDto) {
114115
return await this.waMonitor.waInstances[instanceName].blockUser(data);
115116
}
117+
118+
public async decryptPollVote({ instanceName }: InstanceDto, data: DecryptPollVoteDto) {
119+
return await this.waMonitor.waInstances[instanceName].baileysDecryptPollVote(data.pollCreationMessageKey);
120+
}
116121
}

src/api/dto/chat.dto.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,12 @@ export class BlockUserDto {
127127
number: string;
128128
status: 'block' | 'unblock';
129129
}
130+
131+
export class DecryptPollVoteDto {
132+
pollCreationMessageKey: {
133+
id: string;
134+
remoteJid: string;
135+
participant?: string;
136+
fromMe?: boolean;
137+
};
138+
}

src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5119,4 +5119,247 @@ export class BaileysStartupService extends ChannelStartupService {
51195119
},
51205120
};
51215121
}
5122+
5123+
public async baileysDecryptPollVote(pollCreationMessageKey: proto.IMessageKey) {
5124+
try {
5125+
this.logger.verbose('Starting poll vote decryption process');
5126+
5127+
// Buscar a mensagem de criação da enquete
5128+
const pollCreationMessage = (await this.getMessage(pollCreationMessageKey, true)) as proto.IWebMessageInfo;
5129+
5130+
if (!pollCreationMessage) {
5131+
throw new NotFoundException('Poll creation message not found');
5132+
}
5133+
5134+
// Extrair opções da enquete
5135+
const pollOptions =
5136+
(pollCreationMessage.message as any)?.pollCreationMessage?.options ||
5137+
(pollCreationMessage.message as any)?.pollCreationMessageV3?.options ||
5138+
[];
5139+
5140+
if (!pollOptions || pollOptions.length === 0) {
5141+
throw new NotFoundException('Poll options not found');
5142+
}
5143+
5144+
// Recuperar chave de criptografia
5145+
const pollMessageSecret = (await this.getMessage(pollCreationMessageKey)) as any;
5146+
let pollEncKey = pollMessageSecret?.messageContextInfo?.messageSecret;
5147+
5148+
if (!pollEncKey) {
5149+
throw new NotFoundException('Poll encryption key not found');
5150+
}
5151+
5152+
// Normalizar chave de criptografia
5153+
if (typeof pollEncKey === 'string') {
5154+
pollEncKey = Buffer.from(pollEncKey, 'base64');
5155+
} else if (pollEncKey?.type === 'Buffer' && Array.isArray(pollEncKey.data)) {
5156+
pollEncKey = Buffer.from(pollEncKey.data);
5157+
}
5158+
5159+
if (Buffer.isBuffer(pollEncKey) && pollEncKey.length === 44) {
5160+
pollEncKey = Buffer.from(pollEncKey.toString('utf8'), 'base64');
5161+
}
5162+
5163+
// Buscar todas as mensagens de atualização de votos
5164+
const allPollUpdateMessages = await this.prismaRepository.message.findMany({
5165+
where: {
5166+
instanceId: this.instanceId,
5167+
messageType: 'pollUpdateMessage',
5168+
},
5169+
select: {
5170+
id: true,
5171+
key: true,
5172+
message: true,
5173+
messageTimestamp: true,
5174+
},
5175+
});
5176+
5177+
this.logger.verbose(`Found ${allPollUpdateMessages.length} pollUpdateMessage messages in database`);
5178+
5179+
// Filtrar apenas mensagens relacionadas a esta enquete específica
5180+
const pollUpdateMessages = allPollUpdateMessages.filter((msg) => {
5181+
const pollUpdate = (msg.message as any)?.pollUpdateMessage;
5182+
if (!pollUpdate) return false;
5183+
5184+
const creationKey = pollUpdate.pollCreationMessageKey;
5185+
if (!creationKey) return false;
5186+
5187+
return (
5188+
creationKey.id === pollCreationMessageKey.id &&
5189+
jidNormalizedUser(creationKey.remoteJid || '') === jidNormalizedUser(pollCreationMessageKey.remoteJid || '')
5190+
);
5191+
});
5192+
5193+
this.logger.verbose(`Filtered to ${pollUpdateMessages.length} matching poll update messages`);
5194+
5195+
// Preparar candidatos de JID para descriptografia
5196+
const creatorCandidates = [
5197+
this.instance.wuid,
5198+
this.client.user?.lid,
5199+
pollCreationMessage.key.participant,
5200+
(pollCreationMessage.key as any).participantAlt,
5201+
pollCreationMessage.key.remoteJid,
5202+
(pollCreationMessage.key as any).remoteJidAlt,
5203+
].filter(Boolean);
5204+
5205+
const uniqueCreators = [...new Set(creatorCandidates.map((id) => jidNormalizedUser(id)))];
5206+
5207+
// Processar votos
5208+
const votesByUser = new Map<string, { timestamp: number; selectedOptions: string[]; voterJid: string }>();
5209+
5210+
this.logger.verbose(`Processing ${pollUpdateMessages.length} poll update messages for decryption`);
5211+
5212+
for (const pollUpdateMsg of pollUpdateMessages) {
5213+
const pollVote = (pollUpdateMsg.message as any)?.pollUpdateMessage?.vote;
5214+
if (!pollVote) continue;
5215+
5216+
const key = pollUpdateMsg.key as any;
5217+
const voterCandidates = [
5218+
this.instance.wuid,
5219+
this.client.user?.lid,
5220+
key.participant,
5221+
key.participantAlt,
5222+
key.remoteJidAlt,
5223+
key.remoteJid,
5224+
].filter(Boolean);
5225+
5226+
const uniqueVoters = [...new Set(voterCandidates.map((id) => jidNormalizedUser(id)))];
5227+
5228+
let selectedOptionNames: string[] = [];
5229+
let successfulVoterJid: string | undefined;
5230+
5231+
// Verificar se o voto já está descriptografado
5232+
if (pollVote.selectedOptions && Array.isArray(pollVote.selectedOptions)) {
5233+
const selectedOptions = pollVote.selectedOptions;
5234+
this.logger.verbose('Vote already has selectedOptions, checking format');
5235+
5236+
// Verificar se são strings (já descriptografado) ou buffers (precisa descriptografar)
5237+
if (selectedOptions.length > 0 && typeof selectedOptions[0] === 'string') {
5238+
// Já está descriptografado como nomes de opções
5239+
selectedOptionNames = selectedOptions;
5240+
successfulVoterJid = uniqueVoters[0];
5241+
this.logger.verbose(`Using already decrypted vote: voter=${successfulVoterJid}, options=${selectedOptionNames.join(',')}`);
5242+
} else {
5243+
// Está como hash, precisa converter para nomes
5244+
selectedOptionNames = pollOptions
5245+
.filter((option: any) => {
5246+
const hash = createHash('sha256').update(option.optionName).digest();
5247+
return selectedOptions.some((selected: any) => {
5248+
if (Buffer.isBuffer(selected)) {
5249+
return Buffer.compare(selected, hash) === 0;
5250+
}
5251+
return false;
5252+
});
5253+
})
5254+
.map((option: any) => option.optionName);
5255+
successfulVoterJid = uniqueVoters[0];
5256+
}
5257+
} else if (pollVote.encPayload && pollEncKey) {
5258+
// Tentar descriptografar
5259+
let decryptedVote: any = null;
5260+
5261+
for (const creator of uniqueCreators) {
5262+
for (const voter of uniqueVoters) {
5263+
try {
5264+
decryptedVote = decryptPollVote(pollVote, {
5265+
pollCreatorJid: creator,
5266+
pollMsgId: pollCreationMessage.key.id,
5267+
pollEncKey,
5268+
voterJid: voter,
5269+
} as any);
5270+
5271+
if (decryptedVote) {
5272+
successfulVoterJid = voter;
5273+
break;
5274+
}
5275+
} catch (error) {
5276+
// Continue tentando outras combinações
5277+
}
5278+
}
5279+
if (decryptedVote) break;
5280+
}
5281+
5282+
if (decryptedVote && decryptedVote.selectedOptions) {
5283+
// Converter hashes para nomes de opções
5284+
selectedOptionNames = pollOptions
5285+
.filter((option: any) => {
5286+
const hash = createHash('sha256').update(option.optionName).digest();
5287+
return decryptedVote.selectedOptions.some((selected: any) => {
5288+
if (Buffer.isBuffer(selected)) {
5289+
return Buffer.compare(selected, hash) === 0;
5290+
}
5291+
return false;
5292+
});
5293+
})
5294+
.map((option: any) => option.optionName);
5295+
5296+
this.logger.verbose(`Successfully decrypted vote for voter: ${successfulVoterJid}, creator: ${uniqueCreators[0]}`);
5297+
} else {
5298+
this.logger.warn(`Failed to decrypt vote. Last error: Could not decrypt with any combination`);
5299+
continue;
5300+
}
5301+
} else {
5302+
this.logger.warn('Vote has no encPayload and no selectedOptions, skipping');
5303+
continue;
5304+
}
5305+
5306+
if (selectedOptionNames.length > 0 && successfulVoterJid) {
5307+
const normalizedVoterJid = jidNormalizedUser(successfulVoterJid);
5308+
const existingVote = votesByUser.get(normalizedVoterJid);
5309+
5310+
// Manter apenas o voto mais recente de cada usuário
5311+
if (!existingVote || pollUpdateMsg.messageTimestamp > existingVote.timestamp) {
5312+
votesByUser.set(normalizedVoterJid, {
5313+
timestamp: pollUpdateMsg.messageTimestamp,
5314+
selectedOptions: selectedOptionNames,
5315+
voterJid: successfulVoterJid,
5316+
});
5317+
}
5318+
}
5319+
}
5320+
5321+
// Agrupar votos por opção
5322+
const results: Record<string, { votes: number; voters: string[] }> = {};
5323+
5324+
// Inicializar todas as opções com zero votos
5325+
pollOptions.forEach((option: any) => {
5326+
results[option.optionName] = {
5327+
votes: 0,
5328+
voters: [],
5329+
};
5330+
});
5331+
5332+
// Agregar votos
5333+
votesByUser.forEach((voteData) => {
5334+
voteData.selectedOptions.forEach((optionName) => {
5335+
if (results[optionName]) {
5336+
results[optionName].votes++;
5337+
if (!results[optionName].voters.includes(voteData.voterJid)) {
5338+
results[optionName].voters.push(voteData.voterJid);
5339+
}
5340+
}
5341+
});
5342+
});
5343+
5344+
// Obter nome da enquete
5345+
const pollName =
5346+
(pollCreationMessage.message as any)?.pollCreationMessage?.name ||
5347+
(pollCreationMessage.message as any)?.pollCreationMessageV3?.name ||
5348+
'Enquete sem nome';
5349+
5350+
// Calcular total de votos únicos
5351+
const totalVotes = votesByUser.size;
5352+
5353+
return {
5354+
poll: {
5355+
name: pollName,
5356+
totalVotes,
5357+
results,
5358+
},
5359+
};
5360+
} catch (error) {
5361+
this.logger.error(`Error decrypting poll votes: ${error}`);
5362+
throw new InternalServerErrorException('Error decrypting poll votes', error.toString());
5363+
}
5364+
}
51225365
}

src/api/routes/chat.router.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { RouterBroker } from '@api/abstract/abstract.router';
22
import {
33
ArchiveChatDto,
44
BlockUserDto,
5+
DecryptPollVoteDto,
56
DeleteMessage,
67
getBase64FromMediaMessageDto,
78
MarkChatUnreadDto,
@@ -23,6 +24,7 @@ import {
2324
archiveChatSchema,
2425
blockUserSchema,
2526
contactValidateSchema,
27+
decryptPollVoteSchema,
2628
deleteMessageSchema,
2729
markChatUnreadSchema,
2830
messageUpSchema,
@@ -281,6 +283,16 @@ export class ChatRouter extends RouterBroker {
281283
});
282284

283285
return res.status(HttpStatus.CREATED).json(response);
286+
})
287+
.post(this.routerPath('getPollVote'), ...guards, async (req, res) => {
288+
const response = await this.dataValidate<DecryptPollVoteDto>({
289+
request: req,
290+
schema: decryptPollVoteSchema,
291+
ClassRef: DecryptPollVoteDto,
292+
execute: (instance, data) => chatController.decryptPollVote(instance, data),
293+
});
294+
295+
return res.status(HttpStatus.OK).json(response);
284296
});
285297
}
286298

src/validate/message.schema.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,3 +447,21 @@ export const buttonsMessageSchema: JSONSchema7 = {
447447
},
448448
required: ['number'],
449449
};
450+
451+
export const decryptPollVoteSchema: JSONSchema7 = {
452+
$id: v4(),
453+
type: 'object',
454+
properties: {
455+
pollCreationMessageKey: {
456+
type: 'object',
457+
properties: {
458+
id: { type: 'string' },
459+
remoteJid: { type: 'string' },
460+
participant: { type: 'string' },
461+
fromMe: { type: 'boolean' },
462+
},
463+
required: ['id', 'remoteJid'],
464+
},
465+
},
466+
required: ['pollCreationMessageKey'],
467+
};

0 commit comments

Comments
 (0)