mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
fix(ci): stabilize whatsapp extension checks
This commit is contained in:
committed by
Peter Steinberger
parent
6f5df14308
commit
19295994f3
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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})` : ""}`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}`,
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user