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
420 changes: 214 additions & 206 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"dev:server": "tsx watch ./src/main.ts",
"test": "tsx watch ./test/all.test.ts",
"test:xmacna-compat": "node --test test/xmacna-baileys-compat.test.cjs",
"test:xmacna-inbox": "tsx --test test/xmacna-s3-storage.test.ts test/xmacna-inbound-inbox.test.ts test/xmacna-inbound-sink-dispatch.test.ts test/xmacna-inbound-service-contracts.test.ts test/xmacna-baileys-message-processor.test.ts test/xmacna-chatbot-debounce.test.ts test/xmacna-outgoing-lid.test.ts",
"test:xmacna-inbox:postgres": "tsx --test test/xmacna-inbound-inbox.postgres.test.ts",
"test:xmacna-inbox": "tsx --test test/xmacna-s3-storage.test.ts test/xmacna-inbound-inbox.test.ts test/xmacna-inbound-sink-dispatch.test.ts test/xmacna-inbound-service-contracts.test.ts test/xmacna-baileys-message-processor.test.ts test/xmacna-chatbot-debounce.test.ts test/xmacna-outgoing-lid.test.ts test/xmacna-inbound-identity.test.ts test/xmacna-inbound-media-upload.test.ts test/xmacna-inbound-media-guard.test.ts test/xmacna-get-conversation-message.test.ts",
"test:xmacna-inbox:postgres": "tsx --test test/xmacna-inbound-inbox.postgres.test.ts test/xmacna-inbound-identity.postgres.test.ts",
"inbound-inbox:maintenance": "tsx src/cli/inboundInboxMaintenance.ts",
"lint": "eslint --fix --ext .ts src",
"lint:check": "eslint --ext .ts src",
Expand Down Expand Up @@ -84,10 +84,10 @@
"@sentry/node": "^10.70.0",
"amqplib": "^0.10.5",
"audio-decode": "^2.2.3",
"axios": "^1.19.0",
"axios": "^1.20.0",
"baileys": "7.0.0-rc14",
"class-validator": "^0.14.1",
"compression": "^1.7.5",
"compression": "^1.8.2",
"cors": "^2.8.5",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
Expand All @@ -111,7 +111,7 @@
"mediainfo.js": "^0.3.4",
"mime": "^4.0.0",
"mime-types": "^2.1.35",
"multer": "^2.3.0",
"multer": "^2.4.0",
"nats": "^2.29.1",
"node-cache": "^5.1.2",
"node-cron": "^4.6.0",
Expand All @@ -124,12 +124,12 @@
"qrcode-terminal": "^0.12.0",
"redis": "^4.7.0",
"rxjs": "^7.8.2",
"sharp": "^0.35.4",
"sharp": "^0.35.5",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"socks-proxy-agent": "^8.0.5",
"swagger-ui-express": "^5.0.1",
"undici": "^7.29.0",
"undici": "^7.30.0",
"uuid": "^13.0.2"
},
"overrides": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE `LidPhoneAlias` (
`instanceScope` VARCHAR(67) NOT NULL,
`lidJid` VARCHAR(100) NOT NULL,
`phoneJid` VARCHAR(100) NULL,
`ambiguous` BOOLEAN NOT NULL DEFAULT false,
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`instanceScope`,`lidJid`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
10 changes: 10 additions & 0 deletions prisma/mysql-schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,16 @@ model InboundReceipt {
@@index([instanceScope, contactScope, createdAt])
}

model LidPhoneAlias {
instanceScope String @db.VarChar(67)
lidJid String @db.VarChar(100)
phoneJid String? @db.VarChar(100)
ambiguous Boolean @default(false)
updatedAt DateTime @updatedAt

@@id([instanceScope, lidJid])
}

model MessageUpdate {
id String @id @default(cuid())
keyId String @db.VarChar(100)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE "LidPhoneAlias" (
"instanceScope" VARCHAR(67) NOT NULL,
"lidJid" VARCHAR(100) NOT NULL,
"phoneJid" VARCHAR(100),
"ambiguous" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LidPhoneAlias_pkey" PRIMARY KEY ("instanceScope","lidJid")
);
11 changes: 11 additions & 0 deletions prisma/postgresql-schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,17 @@ model InboundReceipt {
@@index([instanceScope, contactScope, createdAt])
}

// Confirmed PN for a LID. The name-derived scope survives instance row recreation.
model LidPhoneAlias {
instanceScope String @db.VarChar(67)
lidJid String @db.VarChar(100)
phoneJid String? @db.VarChar(100)
ambiguous Boolean @default(false)
updatedAt DateTime @updatedAt @db.Timestamp

@@id([instanceScope, lidJid])
}

model MessageUpdate {
id String @id @default(cuid())
keyId String @db.VarChar(100)
Expand Down
10 changes: 10 additions & 0 deletions prisma/psql_bouncer-schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,16 @@ model InboundReceipt {
@@index([instanceScope, contactScope, createdAt])
}

model LidPhoneAlias {
instanceScope String @db.VarChar(67)
lidJid String @db.VarChar(100)
phoneJid String? @db.VarChar(100)
ambiguous Boolean @default(false)
updatedAt DateTime @updatedAt @db.Timestamp

@@id([instanceScope, lidJid])
}

model MessageUpdate {
id String @id @default(cuid())
keyId String @db.VarChar(100)
Expand Down
119 changes: 119 additions & 0 deletions src/api/integrations/channel/whatsapp/inboundIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { PrismaRepository } from '@api/repository/repository.service';
import { Prisma } from '@prisma/client';

import { normalizeInstanceScope } from './inboundInbox';

const PHONE_JID = /^[1-9][0-9]{5,19}@s\.whatsapp\.net$/;
const LID_JID = /^[1-9][0-9]{1,24}@lid$/;

export class UnresolvedInboundLidError extends Error {
constructor(reason: 'unknown' | 'ambiguous') {
super(`Inbound LID alias ${reason}; phone identity unavailable`);
this.name = 'UnresolvedInboundLidError';
}
}

export type InboundAddress = {
remoteJid: string;
remoteJidAlt?: string | null;
};

export type ResolvedInboundAddress = {
remoteJid: string;
senderLid?: string;
};

export interface LidPhoneAliasStore {
confirm(instanceScope: string, lidJid: string, phoneJid: string): Promise<string>;
find(instanceScope: string, lidJid: string): Promise<string | null>;
}

/**
* A conflicting confirmed pair poisons the alias instead of replacing it.
* The serializable transaction also prevents two workers from silently choosing
* different phones for the same LID while recording a concurrent observation.
*/
export class PrismaLidPhoneAliasStore implements LidPhoneAliasStore {
constructor(private readonly repository: PrismaRepository) {}

async confirm(instanceScope: string, lidJid: string, phoneJid: string): Promise<string> {
for (let attempt = 0; attempt < 8; attempt += 1) {
try {
const alias = await this.repository.$transaction(
async (tx) => {
const where = { instanceScope_lidJid: { instanceScope, lidJid } };
const existing = await tx.lidPhoneAlias.findUnique({ where });
if (!existing) {
return tx.lidPhoneAlias.create({ data: { instanceScope, lidJid, phoneJid } });
}
if (existing.ambiguous || existing.phoneJid !== phoneJid) {
if (!existing.ambiguous && existing.phoneJid !== phoneJid) {
console.warn(
`Inbound LID alias conflict scope=${instanceScope} lid=${lidJid} stored=${existing.phoneJid} observed=${phoneJid}`,
);
}
return tx.lidPhoneAlias.update({
where,
data: { ambiguous: true, phoneJid: null },
});
}
return existing;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
if (alias.ambiguous || !alias.phoneJid) throw new UnresolvedInboundLidError('ambiguous');
return alias.phoneJid;
} catch (error) {
if (error instanceof UnresolvedInboundLidError) throw error;
if (
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
!['P2002', 'P2034'].includes(error.code) ||
attempt === 7
) {
throw error;
}
}
}
throw new Error('LID alias transaction retry exhausted');
}

async find(instanceScope: string, lidJid: string): Promise<string | null> {
const alias = await this.repository.lidPhoneAlias.findUnique({
where: { instanceScope_lidJid: { instanceScope, lidJid } },
});
if (alias?.ambiguous) throw new UnresolvedInboundLidError('ambiguous');
return alias?.phoneJid ?? null;
}
}

/** Never derive a phone number from the LID digits. */
export async function resolveInboundAddress(
instanceName: string,
address: InboundAddress,
aliases: LidPhoneAliasStore,
getPhoneForLid?: (lidJid: string) => Promise<string | null | undefined>,
): Promise<ResolvedInboundAddress> {
const primary = address.remoteJid;
const alternate = address.remoteJidAlt ?? undefined;
const lid = LID_JID.test(primary) ? primary : LID_JID.test(alternate ?? '') ? alternate : undefined;
const primaryPhone = PHONE_JID.test(primary);
if (!lid || (!primaryPhone && !LID_JID.test(primary))) return { remoteJid: primary };

const scope = normalizeInstanceScope(instanceName);
const alternatePhone = PHONE_JID.test(alternate ?? '') ? alternate : undefined;
const confirmedPhone = primaryPhone ? primary : alternatePhone;
if (confirmedPhone) {
const remoteJid = await aliases.confirm(scope, lid, confirmedPhone);
return { remoteJid, senderLid: LID_JID.test(primary) ? lid : undefined };
}

// Baileys' signal repository is tied to this instance's authenticated socket.
const baileysPhone = await getPhoneForLid?.(lid);
if (baileysPhone && PHONE_JID.test(baileysPhone)) {
const remoteJid = await aliases.confirm(scope, lid, baileysPhone);
return { remoteJid, senderLid: lid };
}
const storedPhone = await aliases.find(scope, lid);
if (!storedPhone || !PHONE_JID.test(storedPhone)) throw new UnresolvedInboundLidError('unknown');
return { remoteJid: storedPhone, senderLid: lid };
}
88 changes: 88 additions & 0 deletions src/api/integrations/channel/whatsapp/inboundMediaUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { MessageSubtype } from '@api/types/wa.types';

type MediaUploadContext = { instance: string; messageId: string };

const MEDIA_WRAPPERS = [
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'ephemeralMessage',
] as const;

/**
* Unwraps the inbound media envelope (view-once/ephemeral) for guard checks.
* Baileys delivers such media as `{ viewOnceMessageV2: { message: { videoMessage } } }`,
* so the top-level `isMedia`/`isVideo` guards miss it and skip the S3 block entirely
* (MAR-414). The persisted shape is left untouched; only detection uses the inner content.
*/
export function unwrapInboundMediaContent(message: any): any {
let content = message;
for (let i = 0; i < 5 && content; i++) {
const inner = MEDIA_WRAPPERS.map((wrapper) => content?.[wrapper]?.message).find(Boolean);
if (!inner) break;
content = inner;
}
return content;
}

/**
* Own-key copy of a received message for `getBase64FromMediaMessage`. Its
* `'messageContextInfo' in msg.message` guard is true through the prototype of a
* raw Baileys protobuf, so media without its own `messageContextInfo` (whatsmeow
* clients, or the inner message of a wrapper such as `documentWithCaptionMessage`)
* was dropped before the S3 upload (MAR-304). The send path already passes a plain
* object built by `prepareMessage`, so the shared guard stays unchanged. `received`
* itself is not mutated; media fields are shared by reference, as in the Chatwoot path.
*/
export function ownKeyMediaMessage<T extends { message?: any }>(received: T): T {
const copy = (message: any) => {
if (!message) return message;
const own = { ...message };
for (const subtype of MessageSubtype) {
if (own[subtype]?.message) own[subtype] = { ...own[subtype], message: copy(own[subtype].message) };
}
return own;
};
return { ...received, message: copy(received.message) };
}
type MediaUploadLogger = { warn: (message: string) => void };

/** Keep media failures visible without logging message bodies, URLs, or media keys. */
export async function runInboundMediaUpload<TMedia, TUpload>(
context: MediaUploadContext,
logger: MediaUploadLogger,
operations: {
download: () => Promise<TMedia | null | undefined>;
upload: (media: TMedia) => Promise<TUpload | Error>;
createMedia: (media: TMedia, uploaded: TUpload) => Promise<unknown>;
updateMessage: (media: TMedia, uploaded: TUpload) => Promise<void>;
},
): Promise<boolean> {
let stage = 'download';
try {
const media = await operations.download();
if (!media) {
logger.warn(JSON.stringify({ event: 'inbound_media_upload_skipped', ...context, stage, reason: 'empty_media' }));
return false;
}
stage = 's3_upload';
const uploaded = await operations.upload(media);
// The S3 storage wrapper returns Error on failure instead of throwing it.
if (uploaded instanceof Error) throw uploaded;
stage = 'media_create';
await operations.createMedia(media, uploaded);
stage = 'message_update';
await operations.updateMessage(media, uploaded);
return true;
} catch (error) {
logger.warn(
JSON.stringify({
event: 'inbound_media_upload_failed',
...context,
stage,
errorType: error instanceof Error ? error.name : typeof error,
}),
);
return false;
}
}
16 changes: 16 additions & 0 deletions src/api/integrations/channel/whatsapp/inboundSinkDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,19 @@ export async function attemptDurableInboundSink(options: {
await options.mark(options.sink, state);
return undefined;
}

/** Keep legacy LID delivery in off/shadow; only an enforce receipt can retry it. */
export async function dispatchInboundChatbot(options: {
unresolvedLid?: Error;
receiptMode?: 'off' | 'shadow' | 'enforce';
attempt: (operation: () => Promise<'sent'>) => Promise<boolean>;
durable: () => Promise<void>;
bestEffort: () => Promise<void>;
}): Promise<void> {
const delivered = await options.attempt(async () => {
if (options.unresolvedLid && options.receiptMode === 'enforce') throw options.unresolvedLid;
await options.durable();
return 'sent';
});
if (delivered) await options.bestEffort();
}
Loading
Loading