elizaos--eliza
426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
514 行
12 KiB
TypeScript
514 行
12 KiB
TypeScript
/**
|
|
* HTTP client for the WhatsApp Cloud API (Meta Graph). Sends text, media,
|
|
* reactions, location, and interactive (button/list) messages, marks messages
|
|
* read, resolves media URLs, and verifies the webhook token — one Graph request
|
|
* per call against the configured phone number ID. Implements IWhatsAppClient;
|
|
* media links pass through the SSRF guard before dispatch.
|
|
*/
|
|
import { EventEmitter } from "node:events";
|
|
import type { IWhatsAppClient } from "./clients/interface";
|
|
import { assertValidWhatsAppMediaLink } from "./media";
|
|
import type {
|
|
CloudAPIConfig,
|
|
ConnectionStatus,
|
|
SendReactionParams,
|
|
SendReactionResult,
|
|
WhatsAppInteractiveMessage,
|
|
WhatsAppLocationMessage,
|
|
WhatsAppMediaMessage,
|
|
WhatsAppMessage,
|
|
WhatsAppMessageResponse,
|
|
WhatsAppReactionMessage,
|
|
} from "./types";
|
|
|
|
const DEFAULT_API_VERSION = "v24.0";
|
|
|
|
interface WhatsAppApiResponse<T> {
|
|
data: T;
|
|
status: number;
|
|
statusText: string;
|
|
headers: Headers;
|
|
}
|
|
|
|
export class WhatsAppClient extends EventEmitter implements IWhatsAppClient {
|
|
private baseUrl: string;
|
|
private headers: HeadersInit;
|
|
private config: CloudAPIConfig;
|
|
private connectionStatus: ConnectionStatus = "close";
|
|
|
|
constructor(config: CloudAPIConfig) {
|
|
super();
|
|
this.config = config;
|
|
const apiVersion = config.apiVersion || DEFAULT_API_VERSION;
|
|
this.baseUrl = `https://graph.facebook.com/${apiVersion}`;
|
|
this.headers = {
|
|
Authorization: `Bearer ${config.accessToken}`,
|
|
"Content-Type": "application/json",
|
|
};
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.connectionStatus = "open";
|
|
this.emit("connection", "open");
|
|
this.emit("ready");
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.connectionStatus = "close";
|
|
this.emit("connection", "close");
|
|
}
|
|
|
|
getConnectionStatus(): ConnectionStatus {
|
|
return this.connectionStatus;
|
|
}
|
|
|
|
/**
|
|
* Get the configured phone number ID.
|
|
*/
|
|
getPhoneNumberId(): string {
|
|
return this.config.phoneNumberId;
|
|
}
|
|
|
|
/**
|
|
* Send a message of any supported type.
|
|
*/
|
|
async sendMessage(
|
|
message: WhatsAppMessage
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
const endpoint = `/${this.config.phoneNumberId}/messages`;
|
|
const payload = this.buildMessagePayload(message);
|
|
return this.post<WhatsAppMessageResponse>(endpoint, payload);
|
|
}
|
|
|
|
/**
|
|
* Send a text message.
|
|
*/
|
|
async sendTextMessage(
|
|
to: string,
|
|
text: string,
|
|
_previewUrl = false
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
return this.sendMessage({
|
|
type: "text",
|
|
to,
|
|
content: text,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a reaction to a message.
|
|
*/
|
|
async sendReaction(params: SendReactionParams): Promise<SendReactionResult> {
|
|
const endpoint = `/${this.config.phoneNumberId}/messages`;
|
|
|
|
const payload = {
|
|
messaging_product: "whatsapp",
|
|
recipient_type: "individual",
|
|
to: params.to,
|
|
type: "reaction",
|
|
reaction: {
|
|
message_id: params.messageId,
|
|
emoji: params.emoji,
|
|
},
|
|
};
|
|
|
|
try {
|
|
const response = await this.post<WhatsAppMessageResponse>(endpoint, payload);
|
|
return {
|
|
success: true,
|
|
messageId: response.data.messages[0]?.id,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a reaction from a message (send empty emoji).
|
|
*/
|
|
async removeReaction(to: string, messageId: string): Promise<SendReactionResult> {
|
|
return this.sendReaction({
|
|
to,
|
|
messageId,
|
|
emoji: "",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send an image message.
|
|
*/
|
|
async sendImage(
|
|
to: string,
|
|
imageUrl: string,
|
|
caption?: string
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
return this.sendMessage({
|
|
type: "image",
|
|
to,
|
|
content: {
|
|
link: imageUrl,
|
|
caption,
|
|
} as WhatsAppMediaMessage,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a video message.
|
|
*/
|
|
async sendVideo(
|
|
to: string,
|
|
videoUrl: string,
|
|
caption?: string
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
return this.sendMessage({
|
|
type: "video",
|
|
to,
|
|
content: {
|
|
link: videoUrl,
|
|
caption,
|
|
} as WhatsAppMediaMessage,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send an audio message.
|
|
*/
|
|
async sendAudio(
|
|
to: string,
|
|
audioUrl: string
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
return this.sendMessage({
|
|
type: "audio",
|
|
to,
|
|
content: {
|
|
link: audioUrl,
|
|
} as WhatsAppMediaMessage,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a document message.
|
|
*/
|
|
async sendDocument(
|
|
to: string,
|
|
documentUrl: string,
|
|
filename?: string,
|
|
caption?: string
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
return this.sendMessage({
|
|
type: "document",
|
|
to,
|
|
content: {
|
|
link: documentUrl,
|
|
filename,
|
|
caption,
|
|
} as WhatsAppMediaMessage,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a location message.
|
|
*/
|
|
async sendLocation(
|
|
to: string,
|
|
latitude: number,
|
|
longitude: number,
|
|
name?: string,
|
|
address?: string
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
return this.sendMessage({
|
|
type: "location",
|
|
to,
|
|
content: {
|
|
latitude,
|
|
longitude,
|
|
name,
|
|
address,
|
|
} as WhatsAppLocationMessage,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send an interactive button message.
|
|
*/
|
|
async sendButtonMessage(
|
|
to: string,
|
|
bodyText: string,
|
|
buttons: Array<{ id: string; title: string }>,
|
|
headerText?: string,
|
|
footerText?: string
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
const interactive: WhatsAppInteractiveMessage = {
|
|
type: "button",
|
|
body: { text: bodyText },
|
|
action: {
|
|
buttons: buttons.map((btn) => ({
|
|
type: "reply" as const,
|
|
reply: { id: btn.id, title: btn.title },
|
|
})),
|
|
},
|
|
};
|
|
|
|
if (headerText) {
|
|
interactive.header = { type: "text", text: headerText };
|
|
}
|
|
if (footerText) {
|
|
interactive.footer = { text: footerText };
|
|
}
|
|
|
|
return this.sendMessage({
|
|
type: "interactive",
|
|
to,
|
|
content: interactive,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send an interactive list message.
|
|
*/
|
|
async sendListMessage(
|
|
to: string,
|
|
bodyText: string,
|
|
buttonText: string,
|
|
sections: Array<{
|
|
title?: string;
|
|
rows: Array<{ id: string; title: string; description?: string }>;
|
|
}>,
|
|
headerText?: string,
|
|
footerText?: string
|
|
): Promise<WhatsAppApiResponse<WhatsAppMessageResponse>> {
|
|
const interactive: WhatsAppInteractiveMessage = {
|
|
type: "list",
|
|
body: { text: bodyText },
|
|
action: {
|
|
button: buttonText,
|
|
sections,
|
|
},
|
|
};
|
|
|
|
if (headerText) {
|
|
interactive.header = { type: "text", text: headerText };
|
|
}
|
|
if (footerText) {
|
|
interactive.footer = { text: footerText };
|
|
}
|
|
|
|
return this.sendMessage({
|
|
type: "interactive",
|
|
to,
|
|
content: interactive,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mark a message as read.
|
|
*/
|
|
async markMessageAsRead(messageId: string): Promise<boolean> {
|
|
const endpoint = `/${this.config.phoneNumberId}/messages`;
|
|
|
|
const payload = {
|
|
messaging_product: "whatsapp",
|
|
status: "read",
|
|
message_id: messageId,
|
|
};
|
|
|
|
try {
|
|
await this.post<WhatsAppMessageResponse>(endpoint, payload);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download media by ID.
|
|
*/
|
|
async getMediaUrl(mediaId: string): Promise<string | null> {
|
|
try {
|
|
const response = await this.get<{ url?: string }>(`/${mediaId}`);
|
|
return response.data.url || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify webhook token.
|
|
*/
|
|
async verifyWebhook(token: string): Promise<boolean> {
|
|
return token === this.config.webhookVerifyToken;
|
|
}
|
|
|
|
private get<T>(endpoint: string): Promise<WhatsAppApiResponse<T>> {
|
|
return this.request<T>(endpoint, { method: "GET" });
|
|
}
|
|
|
|
private post<T>(endpoint: string, payload: unknown): Promise<WhatsAppApiResponse<T>> {
|
|
return this.request<T>(endpoint, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
private async request<T>(endpoint: string, init: RequestInit): Promise<WhatsAppApiResponse<T>> {
|
|
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint;
|
|
const response = await fetch(`${this.baseUrl}/${normalizedEndpoint}`, {
|
|
...init,
|
|
headers: {
|
|
...this.headers,
|
|
...init.headers,
|
|
},
|
|
});
|
|
|
|
const text = await response.text();
|
|
const data = text ? this.parseResponseBody(text) : undefined;
|
|
|
|
if (!response.ok) {
|
|
const detail =
|
|
typeof data === "string" ? data : data ? JSON.stringify(data) : response.statusText;
|
|
throw new Error(
|
|
`WhatsApp Cloud API request failed (${response.status} ${response.statusText}): ${detail}`
|
|
);
|
|
}
|
|
|
|
return {
|
|
data: data as T,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
};
|
|
}
|
|
|
|
private parseResponseBody(text: string): unknown {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the message payload based on message type.
|
|
*/
|
|
private buildMessagePayload(message: WhatsAppMessage): Record<string, unknown> {
|
|
const basePayload = {
|
|
messaging_product: "whatsapp",
|
|
recipient_type: "individual",
|
|
to: message.to,
|
|
type: message.type,
|
|
};
|
|
|
|
// Add context for replies
|
|
const contextPayload = message.replyToMessageId
|
|
? { context: { message_id: message.replyToMessageId } }
|
|
: {};
|
|
|
|
switch (message.type) {
|
|
case "text":
|
|
return {
|
|
...basePayload,
|
|
...contextPayload,
|
|
text: {
|
|
body: message.content as string,
|
|
},
|
|
};
|
|
|
|
case "template":
|
|
return {
|
|
...basePayload,
|
|
...contextPayload,
|
|
template: message.content,
|
|
};
|
|
|
|
case "image": {
|
|
const imageContent = message.content as WhatsAppMediaMessage;
|
|
return {
|
|
...basePayload,
|
|
...contextPayload,
|
|
image: {
|
|
link: assertValidWhatsAppMediaLink(imageContent.link, "image"),
|
|
caption: imageContent.caption,
|
|
},
|
|
};
|
|
}
|
|
|
|
case "video": {
|
|
const videoContent = message.content as WhatsAppMediaMessage;
|
|
return {
|
|
...basePayload,
|
|
...contextPayload,
|
|
video: {
|
|
link: assertValidWhatsAppMediaLink(videoContent.link, "video"),
|
|
caption: videoContent.caption,
|
|
},
|
|
};
|
|
}
|
|
|
|
case "audio": {
|
|
const audioContent = message.content as WhatsAppMediaMessage;
|
|
return {
|
|
...basePayload,
|
|
...contextPayload,
|
|
audio: {
|
|
link: assertValidWhatsAppMediaLink(audioContent.link, "audio"),
|
|
},
|
|
};
|
|
}
|
|
|
|
case "document": {
|
|
const docContent = message.content as WhatsAppMediaMessage;
|
|
return {
|
|
...basePayload,
|
|
...contextPayload,
|
|
document: {
|
|
link: assertValidWhatsAppMediaLink(docContent.link, "document"),
|
|
filename: docContent.filename,
|
|
caption: docContent.caption,
|
|
},
|
|
};
|
|
}
|
|
|
|
case "location": {
|
|
const locContent = message.content as WhatsAppLocationMessage;
|
|
return {
|
|
...basePayload,
|
|
...contextPayload,
|
|
location: {
|
|
latitude: locContent.latitude,
|
|
longitude: locContent.longitude,
|
|
name: locContent.name,
|
|
address: locContent.address,
|
|
},
|
|
};
|
|
}
|
|
|
|
case "reaction": {
|
|
const reactionContent = message.content as WhatsAppReactionMessage;
|
|
return {
|
|
...basePayload,
|
|
reaction: {
|
|
message_id: reactionContent.messageId,
|
|
emoji: reactionContent.emoji,
|
|
},
|
|
};
|
|
}
|
|
|
|
case "interactive": {
|
|
const interactiveContent = message.content as WhatsAppInteractiveMessage;
|
|
return {
|
|
...basePayload,
|
|
...contextPayload,
|
|
interactive: interactiveContent,
|
|
};
|
|
}
|
|
|
|
default:
|
|
return basePayload;
|
|
}
|
|
}
|
|
}
|