Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/api/controllers/chat.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ArchiveChatDto,
BlockUserDto,
DecryptPollVoteDto,
DeleteMessage,
getBase64FromMediaMessageDto,
MarkChatUnreadDto,
Expand Down Expand Up @@ -113,4 +114,12 @@ export class ChatController {
public async blockUser({ instanceName }: InstanceDto, data: BlockUserDto) {
return await this.waMonitor.waInstances[instanceName].blockUser(data);
}

public async decryptPollVote({ instanceName }: InstanceDto, data: DecryptPollVoteDto) {
const pollCreationMessageKey = {
id: data.message.key.id,
remoteJid: data.remoteJid,
};
return await this.waMonitor.waInstances[instanceName].baileysDecryptPollVote(pollCreationMessageKey);
}
}
9 changes: 9 additions & 0 deletions src/api/dto/chat.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,12 @@
number: string;
status: 'block' | 'unblock';
}

export class DecryptPollVoteDto {
message: {
key: {
id: string;
};
};
remoteJid: string;
}

Check failure on line 138 in src/api/dto/chat.dto.ts

View workflow job for this annotation

GitHub Actions / check-lint-and-build

Insert `⏎`
243 changes: 243 additions & 0 deletions src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +5181 to +5190
Copy link
Copy Markdown
Contributor

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 instanceId and messageType and then narrows to the specific poll in memory. On instances with many polls, this can load a large pollUpdateMessage set on every baileysDecryptPollVote call. Where possible, add more selective criteria to the DB query (e.g., store and filter by pollCreationMessageKey.id / remoteJid or a derived index) so only updates for the target poll are loaded.

Suggested implementation:

      // Buscar apenas mensagens de atualização de votos relacionadas a esta enquete específica
      const allPollUpdateMessages = await this.prismaRepository.message.findMany({
        where: {
          instanceId: this.instanceId,
          messageType: 'pollUpdateMessage',
          // Filtrar por remoteJid da enquete de criação, para reduzir o conjunto de mensagens
          key: {
            remoteJid: pollCreationMessageKey.remoteJid,
          },
          // Se o campo `message` for JSON/JSONB, usamos filtro por caminho para restringir ao ID da mensagem de criação
          // Isso evita carregar atualizações de outras enquetes no mesmo grupo/contato
          message: {
            path: ['pollUpdateMessage', 'pollCreationMessageKey', 'id'],
            equals: pollCreationMessageKey.id,
          } as any,
        },
        select: {
          id: true,
          key: true,
          message: true,
          messageTimestamp: true,
        },
      });

      this.logger.verbose(
        `Found ${allPollUpdateMessages.length} pollUpdateMessage messages in database for poll ${pollCreationMessageKey.id} / ${pollCreationMessageKey.remoteJid}`,
      );

      // Filtrar apenas mensagens relacionadas a esta enquete específica (filtro em memória, como camada extra de segurança)
      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 || '')

If your Prisma model does not currently support JSON path filtering on message, you will need to:

  1. Ensure the message field is of type Json (Json / Json?) in your Prisma schema and that your DB column is jsonb (Postgres) or equivalent.
  2. If JSON path filters are not available in your Prisma version, replace the message filter with whatever is supported in your setup (e.g., a custom index or a different column storing pollCreationMessageKey.id), while keeping the new key.remoteJid filter.
  3. Optionally, add a DB index on (instanceId, messageType, key->>'remoteJid', message->'pollUpdateMessage'->'pollCreationMessageKey'->>'id') or equivalent in your DB to make this query efficient at scale.

},
});

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 = [
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 this.instance.wuid (your number) comes first, every vote gets attributed to you.
For the encrypted path (lines 5708-5726), it brute-forces creator/voter JID combinations — the successfulVoterJid is set to whichever voter JID successfully decrypts. But it tries your own JID first, and if your JID happens to decrypt the vote (since the crypto may succeed with the wrong voter in some edge cases), the vote gets misattributed.

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.

const voterCandidates = [
  this.instance.wuid,      // YOUR phone number
  this.client.user?.lid,   // YOUR LID  
  key.participant,          // actual voter
  key.participantAlt,
  key.remoteJidAlt,
  key.remoteJid,            // group JID (!)
]

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];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): When using pre-decrypted selectedOptions, the voter JID is assumed rather than derived, which may be fragile.

In the branch where selectedOptions is already decrypted, successfulVoterJid is taken as uniqueVoters[0] without verifying it matches the actual sender/participant. If voterCandidates can contain multiple JIDs, this may attribute a vote to the wrong user. Consider preferring a definitive participant JID from the message metadata if available, or explicitly ordering voterCandidates by reliability and documenting that assumption.

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 {
  1. Ensure that pollVote.participant and/or pollVote.sender are the correct fields exposed by the Baileys poll vote event in your codebase. If your structure differs, adjust the definitiveVoterJid derivation to use the appropriate metadata fields (e.g. pollVote.key.participant or similar).
  2. Confirm that voterCandidates is in scope at this point and that its elements are ordered by reliability as assumed; if not, you may want to sort or filter it earlier in the code to enforce the intended reliability ordering.
  3. If you use strict TypeScript settings, you may wish to refine the types for pollVote, voterCandidates, and uniqueVoters instead of relying on as string | undefined casts here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bom demais veiiiiiiiiiiii!!
Bora botar isso pra produção!!!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 selectedOptions já descriptografado, hoje o voto é associado ao uniqueVoters[0]. Se em algum cenário vierem múltiplos candidatos de JID, pode ter um risco pequeno de atribuir o voto pro usuário errado.

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 participant/sender) e só cair para a lista agregada como fallback.

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

View workflow job for this annotation

GitHub Actions / check-lint-and-build

Replace ``Using·already·decrypted·vote:·voter=${successfulVoterJid},·options=${selectedOptionNames.join(',')}`` with `⏎··············`Using·already·decrypted·vote:·voter=${successfulVoterJid},·options=${selectedOptionNames.join(',')}`,⏎············`
} 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) {

Check failure on line 5275 in src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts

View workflow job for this annotation

GitHub Actions / check-lint-and-build

'error' is defined but never used
// 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

View workflow job for this annotation

GitHub Actions / check-lint-and-build

Replace ``Successfully·decrypted·vote·for·voter:·${successfulVoterJid},·creator:·${uniqueCreators[0]}`` with `⏎··············`Successfully·decrypted·vote·for·voter:·${successfulVoterJid},·creator:·${uniqueCreators[0]}`,⏎············`
} 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());
}
}
}
12 changes: 12 additions & 0 deletions src/api/routes/chat.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { RouterBroker } from '@api/abstract/abstract.router';
import {
ArchiveChatDto,
BlockUserDto,
DecryptPollVoteDto,
DeleteMessage,
getBase64FromMediaMessageDto,
MarkChatUnreadDto,
Expand All @@ -23,6 +24,7 @@ import {
archiveChatSchema,
blockUserSchema,
contactValidateSchema,
decryptPollVoteSchema,
deleteMessageSchema,
markChatUnreadSchema,
messageUpSchema,
Expand Down Expand Up @@ -281,6 +283,16 @@ export class ChatRouter extends RouterBroker {
});

return res.status(HttpStatus.CREATED).json(response);
})
.post(this.routerPath('getPollVote'), ...guards, async (req, res) => {
const response = await this.dataValidate<DecryptPollVoteDto>({
request: req,
schema: decryptPollVoteSchema,
ClassRef: DecryptPollVoteDto,
execute: (instance, data) => chatController.decryptPollVote(instance, data),
});

return res.status(HttpStatus.OK).json(response);
});
}

Expand Down
22 changes: 22 additions & 0 deletions src/validate/message.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,25 @@
},
required: ['number'],
};

export const decryptPollVoteSchema: JSONSchema7 = {
$id: v4(),
type: 'object',
properties: {
message: {
type: 'object',
properties: {
key: {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
},
},
required: ['key'],
},
remoteJid: { type: 'string' },
},
required: ['message', 'remoteJid'],
};

Check failure on line 471 in src/validate/message.schema.ts

View workflow job for this annotation

GitHub Actions / check-lint-and-build

Insert `⏎`
Loading