Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Native WhatsApp polls** via `POST /api/sessions/:sessionId/messages/send-poll`: question, 2–12 options and an optional `allowMultipleAnswers` flag (default single choice), implemented on both engines (whatsapp-web.js `Poll`, Baileys `poll` content with `selectableCount` 1/0). The message history stores the poll question as the body so the log stays readable. Polls are a first-class `poll` message type end to end — both engines map incoming poll messages to it, so the websocket/webhook events, persisted rows, and dashboard all report `poll` consistently. Thanks @alejo117.

### Fixed

- **`GET /api/sessions/:sessionId/channels/:channelId/messages` always returned an empty array** on the whatsapp-web.js engine (#625). The adapter called `client.getChannelById()`, which does not exist in whatsapp-web.js 1.34.x, so every call threw and the error was swallowed into `[]`. Channel messages are now read from the subscribed `Channel` instance (via `getChannels()`), and an unknown/unsubscribed channel returns a `404` (`ChannelNotFoundError`) instead of a silent empty `200` — matching `GET /channels/:channelId`. Thanks @Header9968.

## [0.8.7] - 2026-07-03

### Added
Expand Down
16 changes: 16 additions & 0 deletions src/common/errors/channel-not-found.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NotFoundException } from '@nestjs/common';

/**
* Thrown by the engine layer when a channel referenced by id isn't among the session's subscribed
* channels (a wrong/typo'd id, or a channel not yet synced into the local collection).
*
* Extends NestJS `NotFoundException` so it maps to **HTTP 404** through the built-in exception
* handler — i.e. it does NOT depend on a custom global filter being registered, and it survives the
* `channel.service` passthrough — instead of surfacing as a generic 500 Internal Server Error.
* Mirrors {@link MessageNotFoundError}.
*/
export class ChannelNotFoundError extends NotFoundException {
constructor(channelId: string) {
super(`Channel ${channelId} not found`);
}
}
56 changes: 56 additions & 0 deletions src/engine/adapters/whatsapp-web-js.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as qrcode from 'qrcode';
import { UnprocessableEntityException } from '@nestjs/common';
import { EngineNotReadyError } from '../../common/errors/engine-not-ready.error';
import { EngineNotSupportedError } from '../../common/errors/engine-not-supported.error';
import { ChannelNotFoundError } from '../../common/errors/channel-not-found.error';
import { EngineStatus } from '../interfaces/whatsapp-engine.interface';
import { SsrfBlockedError } from '../../common/security/ssrf-guard';
import { fetch as undiciFetch } from 'undici';
Expand Down Expand Up @@ -365,6 +366,61 @@ describe('WhatsAppWebJsAdapter.forwardMessage (returns the real sent id, not a s
});
});

describe('WhatsAppWebJsAdapter channels (#625 — wwebjs Client has no getChannelById)', () => {
const CHANNEL = '120363401234567890@newsletter';

const readyAdapter = (client: unknown): WhatsAppWebJsAdapter => {
const adapter = new WhatsAppWebJsAdapter({ sessionId: 's', sessionDataPath: './data/sessions', puppeteer: {} });
(adapter as unknown as { status: EngineStatus }).status = EngineStatus.READY;
(adapter as unknown as { client: unknown }).client = client;
return adapter;
};

it('getChannelMessages fetches via the subscribed Channel (getChannels), not the non-existent getChannelById', async () => {
const fetchMessages = jest
.fn()
.mockResolvedValue([{ id: { _serialized: 'M1' }, body: 'hello', timestamp: 1700000000, hasMedia: false }]);
const getChannels = jest.fn().mockResolvedValue([{ id: { _serialized: CHANNEL }, name: 'News', fetchMessages }]);

const result = await readyAdapter({ getChannels }).getChannelMessages(CHANNEL, 10);

expect(getChannels).toHaveBeenCalled();
expect(fetchMessages).toHaveBeenCalledWith({ limit: 10 });
expect(result).toEqual([{ id: 'M1', body: 'hello', timestamp: 1700000000, hasMedia: false, mediaUrl: undefined }]);
});

it('getChannelMessages surfaces a not-found channel as ChannelNotFoundError (→ 404), not a silent []', async () => {
const getChannels = jest
.fn()
.mockResolvedValue([{ id: { _serialized: 'other@newsletter' }, fetchMessages: jest.fn() }]);
// Typed NotFoundException subclass so it maps to 404, not a plain Error → generic 500.
await expect(readyAdapter({ getChannels }).getChannelMessages(CHANNEL)).rejects.toBeInstanceOf(
ChannelNotFoundError,
);
});

it('getChannelMessages returns [] for a channel with no messages (empty is not an error)', async () => {
const fetchMessages = jest.fn().mockResolvedValue([]);
const getChannels = jest.fn().mockResolvedValue([{ id: { _serialized: CHANNEL }, name: 'News', fetchMessages }]);
await expect(readyAdapter({ getChannels }).getChannelMessages(CHANNEL)).resolves.toEqual([]);
});

it('getChannelById resolves from the subscribed-channel list (no getChannelById call)', async () => {
const getChannels = jest
.fn()
.mockResolvedValue([
{ id: { _serialized: CHANNEL }, name: 'News', description: 'desc', subscriberCount: 5, verified: true },
]);
const ch = await readyAdapter({ getChannels }).getChannelById(CHANNEL);
expect(ch).toMatchObject({ id: CHANNEL, name: 'News', description: 'desc', subscriberCount: 5, verified: true });
});

it('getChannelById returns null for a channel not in the subscribed list (service maps null → 404)', async () => {
const getChannels = jest.fn().mockResolvedValue([{ id: { _serialized: 'other@newsletter' }, name: 'Other' }]);
await expect(readyAdapter({ getChannels }).getChannelById(CHANNEL)).resolves.toBeNull();
});
});

describe('WhatsAppWebJsAdapter channel-JID guard (#554 — wwebjs Channel lacks Chat methods)', () => {
const NEWSLETTER = '120363401234567890@newsletter';
const USER = '628111@c.us';
Expand Down
59 changes: 22 additions & 37 deletions src/engine/adapters/whatsapp-web-js.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { createLogger } from '../../common/services/logger.service';
import { EngineNotReadyError } from '../../common/errors/engine-not-ready.error';
import { EngineNotSupportedError } from '../../common/errors/engine-not-supported.error';
import { MessageNotFoundError } from '../../common/errors/message-not-found.error';
import { ChannelNotFoundError } from '../../common/errors/channel-not-found.error';
import { loadRemoteMediaBuffer } from '../../common/media/load-remote-media';
import {
GroupChat,
Expand Down Expand Up @@ -1463,23 +1464,9 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin

async getChannelById(channelId: string): Promise<Channel | null> {
this.ensureReady();
try {
const ch = await (this.client as unknown as BusinessClient).getChannelById(channelId);
if (!ch) {
return null;
}
return {
id: String(typeof ch.id === 'object' ? ch.id._serialized : ch.id),
name: String(ch.name || ''),
description: ch.description ? String(ch.description) : undefined,
inviteCode: ch.inviteCode ? String(ch.inviteCode) : undefined,
subscriberCount: ch.subscriberCount ? Number(ch.subscriberCount) : undefined,
verified: ch.verified ? Boolean(ch.verified) : undefined,
};
} catch (error) {
this.logger.warn(`Failed to get channel: ${channelId}`, String(error));
return null;
}
// wwebjs 1.34.x exposes no client.getChannelById; resolve from the subscribed-channel list (#625).
const channels = await this.getSubscribedChannels();
return channels.find(c => c.id === channelId) ?? null;
}

async subscribeToChannel(inviteCode: string): Promise<Channel> {
Expand All @@ -1501,26 +1488,24 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin

async getChannelMessages(channelId: string, limit: number = 50): Promise<ChannelMessage[]> {
this.ensureReady();
try {
const ch = await (this.client as unknown as BusinessClient).getChannelById(channelId);
if (!ch) {
throw new Error(`Channel ${channelId} not found`);
}
const messages = await ch.fetchMessages({ limit });
if (!messages) {
return [];
}
return messages.map(msg => ({
id: String(typeof msg.id === 'object' ? msg.id._serialized : msg.id),
body: String(msg.body || ''),
timestamp: Number(msg.timestamp),
hasMedia: Boolean(msg.hasMedia),
mediaUrl: msg.mediaUrl ? String(msg.mediaUrl) : undefined,
}));
} catch (error) {
this.logger.error(`Failed to get channel messages: ${String(error)}`);
return [];
}
// wwebjs 1.34.x has no client.getChannelById (calling it threw and the error was swallowed into an
// empty list, #625). The subscribed Channel instances returned by getChannels() carry fetchMessages(),
// so resolve the channel from that list and read its messages. A missing channel surfaces as a
// ChannelNotFoundError (→ 404, like getChannelById) so callers can tell "no messages" apart from
// "wrong/unsubscribed channel" instead of getting a silent [].
const channels = await (this.client as unknown as BusinessClient).getChannels();
const channel = channels?.find(c => (typeof c.id === 'object' ? c.id._serialized : c.id) === channelId);
if (!channel) {
throw new ChannelNotFoundError(channelId);
}
const messages = await channel.fetchMessages({ limit });
return (messages ?? []).map(msg => ({
id: String(typeof msg.id === 'object' ? msg.id._serialized : msg.id),
body: String(msg.body || ''),
timestamp: Number(msg.timestamp),
hasMedia: Boolean(msg.hasMedia),
mediaUrl: msg.mediaUrl ? String(msg.mediaUrl) : undefined,
}));
}

// ========== Gap Quick Wins Implementation ==========
Expand Down
3 changes: 1 addition & 2 deletions src/engine/types/whatsapp-web-js.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,11 @@ export interface MessageWithReactions extends Omit<Message, 'hasReaction' | 'get
*/
export interface BusinessClient extends Omit<
Client,
'subscribeToChannel' | 'unsubscribeFromChannel' | 'getLabels' | 'getLabelById' | 'getChannels' | 'getChannelById'
'subscribeToChannel' | 'unsubscribeFromChannel' | 'getLabels' | 'getLabelById' | 'getChannels'
> {
getLabels(): Promise<Array<{ id: string; name: string; hexColor: string }>>;
getLabelById(id: string): Promise<{ id: string; name: string; hexColor: string } | null>;
getChannels(): Promise<WwjsChannelData[]>;
getChannelById(id: string): Promise<WwjsChannelData | null>;
subscribeToChannel(inviteCode: string): Promise<WwjsChannelData>;
unsubscribeFromChannel(id: string): Promise<void>;
}
Expand Down
Loading