fix(ci): stabilize whatsapp extension checks

This commit is contained in:
Vincent Koc
2026-03-23 15:34:40 -07:00
committed by Peter Steinberger
parent 6f5df14308
commit 19295994f3
13 changed files with 25679 additions and 35 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,14 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
mockExtractMessageContent,
mockGetContentType,
mockIsJidGroup,
mockNormalizeMessageContent,
} from "../../../test/mocks/baileys.js";
type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
@@ -71,7 +79,14 @@ vi.mock("@whiskeysockets/baileys", async (importOriginal) => {
]);
return {
...actual,
DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
downloadMediaMessage: vi.fn().mockResolvedValue(jpegBuffer),
extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
normalizeMessageContent: vi.fn((message: MockMessageInput) =>
mockNormalizeMessageContent(message),
),
};
});

View File

@@ -9,8 +9,106 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { jidToE164 } from "openclaw/plugin-sdk/text-runtime";
import { parseVcard } from "../vcard.js";
const MESSAGE_WRAPPER_KEYS = [
"ephemeralMessage",
"viewOnceMessage",
"viewOnceMessageV2",
"viewOnceMessageV2Extension",
"documentWithCaptionMessage",
] as const;
const MESSAGE_CONTENT_KEYS = [
"conversation",
"extendedTextMessage",
"imageMessage",
"videoMessage",
"audioMessage",
"documentMessage",
"stickerMessage",
"locationMessage",
"liveLocationMessage",
"contactMessage",
"contactsArrayMessage",
"buttonsResponseMessage",
"listResponseMessage",
"templateButtonReplyMessage",
"interactiveResponseMessage",
"buttonsMessage",
"listMessage",
] as const;
function fallbackNormalizeMessageContent(
message: proto.IMessage | undefined,
): proto.IMessage | undefined {
let current = message as unknown;
while (current && typeof current === "object") {
let unwrapped = false;
for (const key of MESSAGE_WRAPPER_KEYS) {
const candidate = (current as Record<string, unknown>)[key];
if (
candidate &&
typeof candidate === "object" &&
"message" in (candidate as Record<string, unknown>) &&
(candidate as { message?: unknown }).message
) {
current = (candidate as { message: unknown }).message;
unwrapped = true;
break;
}
}
if (!unwrapped) {
break;
}
}
return current as proto.IMessage | undefined;
}
function normalizeMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
if (typeof normalizeMessageContent === "function") {
return normalizeMessageContent(message);
}
return fallbackNormalizeMessageContent(message);
}
function fallbackGetContentType(
message: proto.IMessage | undefined,
): keyof proto.IMessage | undefined {
const normalized = fallbackNormalizeMessageContent(message);
if (!normalized || typeof normalized !== "object") {
return undefined;
}
for (const key of MESSAGE_CONTENT_KEYS) {
if ((normalized as Record<string, unknown>)[key] != null) {
return key as keyof proto.IMessage;
}
}
return undefined;
}
function getMessageContentType(
message: proto.IMessage | undefined,
): keyof proto.IMessage | undefined {
if (typeof getContentType === "function") {
return getContentType(message);
}
return fallbackGetContentType(message);
}
function extractMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
if (typeof extractMessageContent === "function") {
return extractMessageContent(message) as proto.IMessage | undefined;
}
const normalized = fallbackNormalizeMessageContent(message);
const contentType = fallbackGetContentType(normalized);
if (!normalized || !contentType || contentType === "conversation") {
return normalized;
}
const candidate = (normalized as Record<string, unknown>)[contentType];
return candidate && typeof candidate === "object" ? (candidate as proto.IMessage) : normalized;
}
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
const normalized = normalizeMessageContent(message);
const normalized = normalizeMessage(message);
return normalized;
}
@@ -18,7 +116,7 @@ function extractContextInfo(message: proto.IMessage | undefined): proto.IContext
if (!message) {
return undefined;
}
const contentType = getContentType(message);
const contentType = getMessageContentType(message);
const candidate = contentType ? (message as Record<string, unknown>)[contentType] : undefined;
const contextInfo =
candidate && typeof candidate === "object" && "contextInfo" in candidate
@@ -89,7 +187,7 @@ export function extractText(rawMessage: proto.IMessage | undefined): string | un
if (!message) {
return undefined;
}
const extracted = extractMessageContent(message);
const extracted = extractMessage(message);
const candidates = [message, extracted && extracted !== message ? extracted : undefined];
for (const candidate of candidates) {
if (!candidate) {
@@ -300,7 +398,7 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
return null;
}
const contextInfo = extractContextInfo(message);
const quoted = normalizeMessageContent(contextInfo?.quotedMessage as proto.IMessage | undefined);
const quoted = normalizeMessage(contextInfo?.quotedMessage as proto.IMessage | undefined);
if (!quoted) {
return null;
}
@@ -312,7 +410,7 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
body = extractMediaPlaceholder(quoted);
}
if (!body) {
const quotedType = quoted ? getContentType(quoted) : undefined;
const quotedType = quoted ? getMessageContentType(quoted) : undefined;
logVerbose(
`Quoted message missing extractable body${quotedType ? ` (type ${quotedType})` : ""}`,
);

View File

@@ -1,14 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
mockExtractMessageContent,
mockGetContentType,
mockIsJidGroup,
mockNormalizeMessageContent,
} from "../../../../test/mocks/baileys.js";
type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
const { normalizeMessageContent, downloadMediaMessage } = vi.hoisted(() => ({
normalizeMessageContent: vi.fn((msg: unknown) => msg),
normalizeMessageContent: vi.fn((msg: MockMessageInput) => mockNormalizeMessageContent(msg)),
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("fake-media-data")),
}));
vi.mock("@whiskeysockets/baileys", () => ({
normalizeMessageContent,
downloadMediaMessage,
}));
vi.mock("@whiskeysockets/baileys", async (importOriginal) => {
const actual = await importOriginal<typeof import("@whiskeysockets/baileys")>();
return {
...actual,
DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
normalizeMessageContent,
downloadMediaMessage,
};
});
let downloadInboundMedia: typeof import("./media.js").downloadInboundMedia;

View File

@@ -22,6 +22,12 @@ import { downloadInboundMedia } from "./media.js";
import { createWebSendApi } from "./send-api.js";
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
function isGroupJid(jid: string): boolean {
return (typeof isJidGroup === "function" ? isJidGroup(jid) : jid.endsWith("@g.us")) === true;
}
export async function monitorWebInbox(options: {
verbose: boolean;
accountId: string;
@@ -176,7 +182,7 @@ export async function monitorWebInbox(options: {
return null;
}
const group = isJidGroup(remoteJid) === true;
const group = isGroupJid(remoteJid);
if (id) {
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
if (isRecentInboundMessage(dedupeKey)) {
@@ -438,7 +444,7 @@ export async function monitorWebInbox(options: {
const status = getStatusCode(update.lastDisconnect?.error);
resolveClose({
status,
isLoggedOut: status === DisconnectReason.loggedOut,
isLoggedOut: status === LOGGED_OUT_STATUS,
error: update.lastDisconnect?.error,
});
}

View File

@@ -7,7 +7,8 @@ import {
waitForWaConnection,
} from "./session.js";
vi.mock("./session.js", () => {
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
const createWaSocket = vi.fn(
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
const sock = { ws: { close: vi.fn() } };
@@ -30,6 +31,7 @@ vi.mock("./session.js", () => {
const logoutWeb = vi.fn(async () => true);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
...actual,
createWaSocket,
waitForWaConnection,
formatError,

View File

@@ -17,6 +17,8 @@ import {
webAuthExists,
} from "./session.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
type WaSocket = Awaited<ReturnType<typeof createWaSocket>>;
type ActiveLogin = {
@@ -261,7 +263,7 @@ export async function waitForWebLogin(
}
if (login.error) {
if (login.errorStatus === DisconnectReason.loggedOut) {
if (login.errorStatus === LOGGED_OUT_STATUS) {
await logoutWeb({
authDir: login.authDir,
isLegacyAuthDir: login.isLegacyAuthDir,

View File

@@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
import { renderQrPngBase64 } from "./qr-image.js";
vi.mock("./session.js", () => {
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
const ev = new EventEmitter();
const sock = {
ev,
@@ -14,6 +15,7 @@ vi.mock("./session.js", () => {
sendMessage: vi.fn(),
};
return {
...actual,
createWaSocket: vi.fn().mockResolvedValue(sock),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
};

View File

@@ -14,6 +14,8 @@ import {
waitForWaConnection,
} from "./session.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
export async function loginWeb(
verbose: boolean,
waitForConnection?: typeof waitForWaConnection,
@@ -53,7 +55,7 @@ export async function loginWeb(
setTimeout(() => retry.ws?.close(), 500);
}
}
if (code === DisconnectReason.loggedOut) {
if (code === LOGGED_OUT_STATUS) {
await logoutWeb({
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,

View File

@@ -33,6 +33,8 @@ export {
webAuthExists,
} from "./auth-store.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
// Per-authDir queues so multi-account creds saves don't block each other.
const credsSaveQueues = new Map<string, Promise<void>>();
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
@@ -142,7 +144,7 @@ export async function createWaSocket(
}
if (connection === "close") {
const status = getStatusCode(lastDisconnect?.error);
if (status === DisconnectReason.loggedOut) {
if (status === LOGGED_OUT_STATUS) {
console.error(
danger(
`WhatsApp session logged out. Run: ${formatCliCommand("openclaw channels login")}`,

View File

@@ -168,11 +168,15 @@ vi.mock("openclaw/plugin-sdk/state-paths", async (importOriginal) => {
};
});
vi.mock("@whiskeysockets/baileys", () => {
vi.mock("@whiskeysockets/baileys", async (importOriginal) => {
const actual = await importOriginal<typeof import("@whiskeysockets/baileys")>();
const created = createMockBaileys();
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw:lastSocket")] =
created.lastSocket;
return created.mod;
return {
...actual,
...created.mod,
};
});
vi.mock("qrcode-terminal", () => ({

View File

@@ -7,6 +7,13 @@ type MakeCacheableSignalKeyStoreFn = BaileysExports["makeCacheableSignalKeyStore
type MakeWASocketFn = BaileysExports["makeWASocket"];
type UseMultiFileAuthStateFn = BaileysExports["useMultiFileAuthState"];
type DownloadMediaMessageFn = BaileysExports["downloadMediaMessage"];
type ExtractMessageContentFn = BaileysExports["extractMessageContent"];
type GetContentTypeFn = BaileysExports["getContentType"];
type NormalizeMessageContentFn = BaileysExports["normalizeMessageContent"];
type IsJidGroupFn = BaileysExports["isJidGroup"];
type MessageContentInput = Parameters<NormalizeMessageContentFn>[0];
type MessageContentOutput = ReturnType<NormalizeMessageContentFn>;
type MessageContentType = ReturnType<GetContentTypeFn>;
export type MockBaileysSocket = {
ev: EventEmitter;
@@ -19,15 +26,105 @@ export type MockBaileysSocket = {
export type MockBaileysModule = {
DisconnectReason: { loggedOut: number };
extractMessageContent: ReturnType<typeof vi.fn<ExtractMessageContentFn>>;
fetchLatestBaileysVersion: ReturnType<typeof vi.fn<FetchLatestBaileysVersionFn>>;
getContentType: ReturnType<typeof vi.fn<GetContentTypeFn>>;
isJidGroup: ReturnType<typeof vi.fn<IsJidGroupFn>>;
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn<MakeCacheableSignalKeyStoreFn>>;
makeWASocket: ReturnType<typeof vi.fn<MakeWASocketFn>>;
normalizeMessageContent: ReturnType<typeof vi.fn<NormalizeMessageContentFn>>;
useMultiFileAuthState: ReturnType<typeof vi.fn<UseMultiFileAuthStateFn>>;
jidToE164?: (jid: string) => string | null;
proto?: unknown;
downloadMediaMessage?: ReturnType<typeof vi.fn<DownloadMediaMessageFn>>;
};
const MESSAGE_WRAPPER_KEYS = [
"ephemeralMessage",
"viewOnceMessage",
"viewOnceMessageV2",
"viewOnceMessageV2Extension",
"documentWithCaptionMessage",
] as const;
const MESSAGE_CONTENT_KEYS = [
"conversation",
"extendedTextMessage",
"imageMessage",
"videoMessage",
"audioMessage",
"documentMessage",
"stickerMessage",
"locationMessage",
"liveLocationMessage",
"contactMessage",
"contactsArrayMessage",
"buttonsResponseMessage",
"listResponseMessage",
"templateButtonReplyMessage",
"interactiveResponseMessage",
"buttonsMessage",
"listMessage",
] as const;
type MessageLike = Record<string, unknown>;
export function mockNormalizeMessageContent(message: MessageContentInput): MessageContentOutput {
let current = message as unknown;
while (current && typeof current === "object") {
let unwrapped = false;
for (const key of MESSAGE_WRAPPER_KEYS) {
const candidate = (current as MessageLike)[key];
if (
candidate &&
typeof candidate === "object" &&
"message" in (candidate as MessageLike) &&
(candidate as { message?: unknown }).message
) {
current = (candidate as { message: unknown }).message;
unwrapped = true;
break;
}
}
if (!unwrapped) {
break;
}
}
return current as MessageContentOutput;
}
export function mockGetContentType(message: MessageContentInput): MessageContentType {
const normalized = mockNormalizeMessageContent(message);
if (!normalized || typeof normalized !== "object") {
return undefined;
}
for (const key of MESSAGE_CONTENT_KEYS) {
if ((normalized as MessageLike)[key] != null) {
return key as MessageContentType;
}
}
return undefined;
}
export function mockExtractMessageContent(message: MessageContentInput): MessageContentOutput {
const normalized = mockNormalizeMessageContent(message);
if (!normalized || typeof normalized !== "object") {
return normalized;
}
const contentType = mockGetContentType(normalized);
if (!contentType || contentType === "conversation") {
return normalized;
}
const candidate = (normalized as MessageLike)[contentType];
return (
candidate && typeof candidate === "object" ? candidate : normalized
) as MessageContentOutput;
}
export function mockIsJidGroup(jid: string | undefined | null): boolean {
return typeof jid === "string" && jid.endsWith("@g.us");
}
export function createMockBaileys(): {
mod: MockBaileysModule;
lastSocket: () => MockBaileysSocket;
@@ -50,11 +147,19 @@ export function createMockBaileys(): {
const mod: MockBaileysModule = {
DisconnectReason: { loggedOut: 401 },
extractMessageContent: vi.fn<ExtractMessageContentFn>((message) =>
mockExtractMessageContent(message),
),
fetchLatestBaileysVersion: vi
.fn<FetchLatestBaileysVersionFn>()
.mockResolvedValue({ version: [1, 2, 3], isLatest: true }),
getContentType: vi.fn<GetContentTypeFn>((message) => mockGetContentType(message)),
isJidGroup: vi.fn<IsJidGroupFn>((jid) => mockIsJidGroup(jid)),
makeCacheableSignalKeyStore: vi.fn<MakeCacheableSignalKeyStoreFn>((keys) => keys),
makeWASocket,
normalizeMessageContent: vi.fn<NormalizeMessageContentFn>((message) =>
mockNormalizeMessageContent(message),
),
useMultiFileAuthState: vi.fn<UseMultiFileAuthStateFn>(async () => ({
state: { creds: {}, keys: {} } as Awaited<ReturnType<UseMultiFileAuthStateFn>>["state"],
saveCreds: vi.fn(),