Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
39.8 kB
// API Service Layer for OpenWA Dashboard
// Centralized API client with TypeScript types
import { warnIfInsecureHttpUrl } from '../utils/urlSecurity';
// Resolve the API base URL. By default this is the same-origin relative path '/api',
// correct when the dashboard and API are served from the same origin (the default
// single-container setup). For a split-origin deployment (dashboard hosted separately
// from the API), set VITE_API_URL at build time to the API ORIGIN — e.g.
// `VITE_API_URL=https://gateway.example.com` — and the '/api' prefix is appended here.
// Previously VITE_API_URL was documented but never read, so the dashboard always called
// same-origin '/api' and a split deployment failed with "Invalid API Key" (#91).
// Exported so direct fetches (e.g. auth/validate in Login.tsx / App.tsx) honor VITE_API_URL
// too — otherwise split-origin deployments break. Empty VITE_API_URL → '/api'.
const API_ORIGIN = (import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, '');
export const API_BASE_URL = `${API_ORIGIN}/api`;
// Warn (not refuse — would break dev + TLS-terminating-proxy) when the API origin is an
// insecure http:// URL pointing at a non-localhost host (API keys sent in cleartext).
if (API_ORIGIN) warnIfInsecureHttpUrl(API_ORIGIN, 'VITE_API_URL');
// =============================================================================
// Types
// =============================================================================
export interface Session {
id: string;
name: string;
status:
| 'created'
| 'idle'
| 'initializing'
| 'connecting'
| 'authenticating'
| 'qr_ready'
| 'ready'
| 'disconnected'
| 'failed';
phone?: string;
pushName?: string;
lastActive?: string;
createdAt: string;
updatedAt: string;
/** Human-readable reason for the most recent terminal engine failure (set only when status is 'failed'). */
lastError?: string | null;
}
export interface SessionStats {
total: number;
active: number;
ready: number;
disconnected: number;
byStatus: Record<string, number>;
memoryUsage: { heapUsed: number; heapTotal: number; rss: number };
}
export type WebhookFilterOperator = 'is' | 'isNot' | 'contains' | 'equals';
export interface WebhookFilterCondition {
field: string;
operator: WebhookFilterOperator;
value: string | string[] | boolean;
caseSensitive?: boolean;
}
export interface WebhookFilters {
conditions: WebhookFilterCondition[];
}
export interface Webhook {
id: string;
sessionId: string;
url: string;
events: string[];
filters?: WebhookFilters | null;
active: boolean;
secret?: string;
createdAt: string;
updatedAt: string;
}
export interface MessageTemplate {
id: string;
sessionId: string;
name: string;
body: string;
header?: string | null;
footer?: string | null;
createdAt: string;
updatedAt: string;
}
export interface TemplatePayload {
name: string;
body: string;
header?: string | null;
footer?: string | null;
}
export interface ApiKey {
id: string;
name: string;
keyPrefix: string;
role: 'admin' | 'operator' | 'viewer';
allowedIps?: string[];
allowedSessions?: string[];
isActive: boolean;
expiresAt?: string;
lastUsedAt?: string;
usageCount: number;
createdAt: string;
apiKey?: string; // Only returned on creation
}
export interface AuditLog {
id: string;
action: string;
severity: 'info' | 'warn' | 'error';
apiKeyId?: string;
apiKeyName?: string;
sessionId?: string;
sessionName?: string;
ipAddress?: string;
method?: string;
path?: string;
statusCode?: number;
errorMessage?: string;
createdAt: string;
}
export interface MessageResponse {
messageId: string;
timestamp: number;
}
// Chat summary returned by GET /sessions/:id/chats (mirrors the backend ChatSummary).
export interface Chat {
id: string;
name: string;
isGroup: boolean;
unreadCount: number;
timestamp: number;
lastMessage?: string;
}
// Engine-neutral message types (mirrors the backend's IWhatsAppEngine MessageType). The backend
// normalizes raw engine tokens at the adapter boundary (#265/#270), so persisted rows, the
// message.received/sent payloads, and the websocket all use these values.
export const MESSAGE_TYPES = [
'text',
'image',
'video',
'audio',
'voice',
'document',
'sticker',
'location',
'contact',
'poll',
'call',
'revoked',
'masked',
'unknown',
] as const;
export type MessageType = (typeof MESSAGE_TYPES)[number];
/** Coerce an arbitrary string (e.g. a raw websocket payload field) to a known MessageType. */
export function asMessageType(value: string | undefined): MessageType {
return (MESSAGE_TYPES as readonly string[]).includes(value ?? '') ? (value as MessageType) : 'unknown';
}
export interface ChatMessage {
id: string;
waMessageId?: string;
chatId: string;
from: string;
to: string;
body: string;
type: MessageType;
direction: 'incoming' | 'outgoing';
status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed';
timestamp?: number;
createdAt: string;
metadata?: {
media?: { mimetype: string; filename?: string; data?: string; omitted?: boolean; sizeBytes?: number };
quotedMessage?: { id: string; body: string };
reactions?: Record<string, string>;
call?: { video: boolean; missed: boolean };
};
}
// Live WhatsApp message from the engine history endpoint (not a persisted DB row): it carries `fromMe`
// instead of `direction`/`status`. Used to backfill a chat thread the gateway never captured live.
export interface EngineHistoryMessage {
id: string;
chatId: string;
from: string;
to: string;
body: string;
type: string;
timestamp: number;
fromMe?: boolean;
media?: { mimetype: string; filename?: string; data?: string };
}
export interface SendMediaPayload {
base64?: string;
url?: string;
mimetype?: string;
filename?: string;
caption?: string;
}
// Payloads below mirror the backend DTOs in src/modules/message/dto (raw bodies, no envelope).
export interface SendLocationPayload {
chatId: string;
latitude: number;
longitude: number;
description?: string;
address?: string;
}
export interface SendContactPayload {
chatId: string;
contactName: string;
contactNumber: string;
}
export interface SendPollPayload {
chatId: string;
name: string;
options: string[];
allowMultipleAnswers?: boolean;
}
export interface ForwardMessagePayload {
fromChatId: string;
toChatId: string;
messageId: string;
}
// Media block of a single bulk message (BulkMediaDto — no caption; caption sits next to it).
export interface BulkMediaPayload {
url?: string;
base64?: string;
mimetype?: string;
filename?: string;
ptt?: boolean;
}
export interface BulkMessageItem {
chatId: string;
type: 'text' | 'image' | 'video' | 'audio' | 'document';
content: {
text?: string;
image?: BulkMediaPayload;
video?: BulkMediaPayload;
audio?: BulkMediaPayload;
document?: BulkMediaPayload;
caption?: string;
};
variables?: Record<string, string>;
}
export interface SendBulkPayload {
batchId?: string;
messages: BulkMessageItem[];
options?: {
delayBetweenMessages?: number;
randomizeDelay?: boolean;
stopOnError?: boolean;
};
}
/** 202 response of POST send-bulk — the batch is processing asynchronously; poll getBatchStatus. */
export interface BulkBatchResponse {
batchId: string;
status: string;
totalMessages: number;
estimatedCompletionTime?: string;
statusUrl: string;
}
export type BatchStatus = 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed';
export interface BatchProgress {
total: number;
sent: number;
failed: number;
pending: number;
cancelled: number;
}
export interface BatchMessageResult {
chatId: string;
status: 'pending' | 'sent' | 'failed' | 'cancelled';
messageId?: string;
error?: { code: string; message: string };
sentAt?: string;
}
/** GET batch/:batchId shape; the cancel endpoint returns the same minus results/timestamps. */
export interface BatchStatusResponse {
batchId: string;
status: BatchStatus;
progress: BatchProgress;
results?: BatchMessageResult[];
startedAt?: string;
completedAt?: string;
}
export interface HealthStatus {
status: 'ok' | 'error';
timestamp?: string;
/** Running backend version (from package.json) — read live so the sidebar never shows a stale build. */
version?: string;
details?: {
database?: { status: string };
redis?: { status: string };
queue?: { status: string };
};
}
export interface InfraStatus {
// `builtIn` = OpenWA's own bundled container is actually running and backing this service (live),
// not just the saved intent — falls back to the saved flag when Docker is unavailable. (#488)
database: { connected: boolean; type: string; host: string; builtIn: boolean };
redis: { enabled: boolean; connected: boolean; host: string; port: number; builtIn: boolean };
queue: {
enabled: boolean;
webhooks: { pending: number; completed: number; failed: number };
};
storage: { type: 'local' | 's3'; path?: string; bucket?: string; builtIn: boolean; s3Available?: boolean };
engine: {
type: string;
headless: boolean;
// whatsapp-web.js only: the actual WhatsApp Web build in use (distinct from the library version)
// and how it was chosen. (#488)
webVersion?: string | null;
webVersionSource?: 'pinned' | 'auto' | 'native';
};
}
// Saved infrastructure config (from data/.env.generated) used to hydrate the form.
// Secrets are never returned — `*Set` flags indicate whether a value is stored.
export interface SavedConfig {
database: {
type: 'sqlite' | 'postgres';
builtIn: boolean;
host: string;
port: string;
username: string;
database: string;
schema: string;
poolSize: number;
sslEnabled: boolean;
sslRejectUnauthorized: boolean;
passwordSet: boolean;
};
redis: { enabled: boolean; builtIn: boolean; host: string; port: string; passwordSet: boolean };
queue: { enabled: boolean };
storage: {
type: 'local' | 's3';
builtIn: boolean;
localPath: string;
s3Bucket: string;
s3Region: string;
s3Endpoint: string;
s3CredentialsSet: boolean;
};
engine: { type: string; headless: boolean; sessionDataPath: string; browserArgs: string };
}
export interface SaveConfigPayload {
database?: {
type: 'sqlite' | 'postgres';
builtIn?: boolean;
host?: string;
port?: string;
username?: string;
password?: string;
database?: string;
schema?: string;
poolSize?: number;
sslEnabled?: boolean;
sslRejectUnauthorized?: boolean;
};
redis?: {
enabled?: boolean;
builtIn?: boolean;
host?: string;
port?: string;
password?: string;
};
queue?: {
enabled?: boolean;
};
storage?: {
type: 'local' | 's3';
builtIn?: boolean;
localPath?: string;
s3Bucket?: string;
s3Region?: string;
s3AccessKey?: string;
s3SecretKey?: string;
s3Endpoint?: string;
};
engine?: {
type?: string;
headless?: boolean;
sessionDataPath?: string;
browserArgs?: string;
};
}
export interface Settings {
general: { apiBaseUrl: string; autoReconnect: boolean; debugMode: boolean };
api: { rateLimit: number; rateLimitWindow: number; enableDocs: boolean };
notifications: { emailEnabled: boolean; notificationEmail: string; webhookAlerts: boolean };
}
// Global message search (mirrors the backend GET /search contract from #664).
// `timestamp` is epoch-seconds (the messages column is seconds, not ms); `dateFrom`/`dateTo`
// are epoch-ms on the wire — see `dateFrom`/`dateTo` JSDoc below.
export interface SearchParams {
q: string;
sessionId?: string;
chatId?: string;
direction?: string;
type?: string;
from?: string;
/** Epoch-ms lower bound (inclusive) — the backend binds against messages.timestamp (/1000). */
dateFrom?: number;
/** Epoch-ms upper bound (inclusive). */
dateTo?: number;
limit?: number;
offset?: number;
}
export interface SearchHit {
messageId: string;
waMessageId: string;
sessionId: string;
chatId: string;
body: string;
/** Provider-generated excerpt with `<mark>` highlight markers — render as text, never as HTML. */
snippet: string;
/** Epoch-seconds (mirrors the persisted messages.timestamp column). */
timestamp: number;
type: string;
direction: string;
from: string;
score?: number;
}
export interface SearchResults {
hits: SearchHit[];
total: number;
tookMs: number;
provider: string;
}
// =============================================================================
// API Client
// =============================================================================
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
// Get API key from sessionStorage for authentication
const apiKey = sessionStorage.getItem('openwa_api_key');
// For FormData (file uploads) let the browser set multipart/form-data + boundary itself.
const isFormData = options.body instanceof FormData;
const headers: HeadersInit = {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(apiKey ? { 'X-API-Key': apiKey } : {}),
...options.headers,
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
// The stored API key is invalid/expired/revoked — clear it and return to login
// so the user isn't stuck on a dashboard that 401s every request.
sessionStorage.removeItem('openwa_api_key');
if (typeof window !== 'undefined') {
window.location.assign('/');
// The page is navigating away — halt this request's promise chain so callers neither
// throw the generic error below (flashing a toast) nor receive an undefined payload.
return new Promise<T>(() => {});
}
}
if (!response.ok) {
// On a non-JSON body (e.g. a reverse-proxy 502/503 HTML page) fall through to `HTTP <status>`
// rather than statusText: the status code is what the toast connection-lost de-dup matches on,
// and statusText is empty over HTTP/2 anyway.
const error = await response.json().catch(() => ({}));
// Carry the HTTP status on the Error (message unchanged, so the toast de-dup still matches) so
// callers can tell apart a permission 403 from a real server 5xx instead of guessing from text.
const err = new Error(error.message || `HTTP ${response.status}`) as Error & { status?: number };
err.status = response.status;
throw err;
}
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
/** Like {@link request} but returns the raw response text — e.g. a plugin's HTML config-UI bundle. */
async function requestText(endpoint: string): Promise<string> {
const apiKey = sessionStorage.getItem('openwa_api_key');
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: { ...(apiKey ? { 'X-API-Key': apiKey } : {}) },
});
if (response.status === 401) {
sessionStorage.removeItem('openwa_api_key');
if (typeof window !== 'undefined') {
window.location.assign('/');
return new Promise<string>(() => {});
}
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.text();
}
// =============================================================================
// Session API
// =============================================================================
export const sessionApi = {
list: () => request<Session[]>('/sessions'),
get: (id: string) => request<Session>(`/sessions/${id}`),
create: (name: string) =>
request<Session>('/sessions', {
method: 'POST',
body: JSON.stringify({ name }),
}),
delete: (id: string) => request<void>(`/sessions/${id}`, { method: 'DELETE' }),
start: (id: string) => request<Session>(`/sessions/${id}/start`, { method: 'POST' }),
stop: (id: string) => request<Session>(`/sessions/${id}/stop`, { method: 'POST' }),
forceKill: (id: string) => request<Session>(`/sessions/${id}/force-kill`, { method: 'POST' }),
getQR: (id: string) => request<{ qrCode: string; status: string }>(`/sessions/${id}/qr`),
requestPairingCode: (id: string, phoneNumber: string) =>
request<{ pairingCode: string; status: string }>(`/sessions/${id}/pairing-code`, {
method: 'POST',
body: JSON.stringify({ phoneNumber }),
}),
getStats: () => request<SessionStats>('/sessions/stats/overview'),
getGroups: (id: string) =>
request<{ id: string; name: string; linkedParentJID?: string | null }[]>(`/sessions/${id}/groups`),
getChats: (id: string) => request<Chat[]>(`/sessions/${id}/chats`),
markChatRead: (id: string, chatId: string) =>
request<{ success: boolean }>(`/sessions/${id}/chats/read`, {
method: 'POST',
body: JSON.stringify({ chatId }),
}),
markChatUnread: (id: string, chatId: string) =>
request<{ success: boolean }>(`/sessions/${id}/chats/unread`, {
method: 'POST',
body: JSON.stringify({ chatId }),
}),
getChatMessages: (id: string, chatId: string, limit = 100) =>
request<{ messages: ChatMessage[]; total: number }>(
`/sessions/${id}/messages?chatId=${encodeURIComponent(chatId)}&limit=${limit}`,
),
// Live history straight from WhatsApp (bypasses the DB) — backfills a thread the gateway never
// captured, e.g. a freshly paired session whose persisted store is still empty.
// includeMedia downloads the media payload (base64) for history messages so stickers/images/
// video/voice render instead of collapsing to an empty timestamp-only bubble.
getChatHistory: (id: string, chatId: string, limit = 100, includeMedia = false) =>
request<EngineHistoryMessage[]>(
`/sessions/${id}/messages/${encodeURIComponent(chatId)}/history?limit=${limit}${
includeMedia ? '&includeMedia=true' : ''
}`,
),
};
// =============================================================================
// Webhook API
// =============================================================================
export const webhookApi = {
listBySession: (sessionId: string) => request<Webhook[]>(`/sessions/${sessionId}/webhooks`),
listAll: () => request<Webhook[]>('/webhooks'),
get: (sessionId: string, id: string) => request<Webhook>(`/sessions/${sessionId}/webhooks/${id}`),
create: (sessionId: string, data: { url: string; events: string[]; filters?: WebhookFilters | null }) =>
request<Webhook>(`/sessions/${sessionId}/webhooks`, {
method: 'POST',
body: JSON.stringify(data),
}),
update: (sessionId: string, id: string, data: Partial<Webhook>) =>
request<Webhook>(`/sessions/${sessionId}/webhooks/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (sessionId: string, id: string) =>
request<void>(`/sessions/${sessionId}/webhooks/${id}`, { method: 'DELETE' }),
test: (sessionId: string, id: string) =>
request<{ success: boolean; statusCode?: number; error?: string }>(`/sessions/${sessionId}/webhooks/${id}/test`, {
method: 'POST',
}),
};
// =============================================================================
// Template API
// =============================================================================
export const templateApi = {
list: (sessionId: string) => request<MessageTemplate[]>(`/sessions/${sessionId}/templates`),
get: (sessionId: string, id: string) => request<MessageTemplate>(`/sessions/${sessionId}/templates/${id}`),
create: (sessionId: string, data: TemplatePayload) =>
request<MessageTemplate>(`/sessions/${sessionId}/templates`, {
method: 'POST',
body: JSON.stringify(data),
}),
update: (sessionId: string, id: string, data: Partial<TemplatePayload>) =>
request<MessageTemplate>(`/sessions/${sessionId}/templates/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (sessionId: string, id: string) =>
request<void>(`/sessions/${sessionId}/templates/${id}`, { method: 'DELETE' }),
};
// =============================================================================
// Contact API
// =============================================================================
export interface CheckNumberResponse {
number: string;
exists: boolean;
/** Engine-canonical WhatsApp id for the number (e.g. `…@c.us` or `…@lid`), or null if unregistered. */
whatsappId: string | null;
}
export interface ProfilePictureResponse {
/** Signed CDN URL for the contact/group picture, or null when hidden / unavailable. */
url: string | null;
}
export const contactApi = {
checkNumber: (sessionId: string, number: string) =>
request<CheckNumberResponse>(`/sessions/${sessionId}/contacts/check/${encodeURIComponent(number)}`),
// Returns the contact/group profile picture URL. Both engines return null when the user hid their
// picture or has none. The URL is a signed WhatsApp CDN link that expires in a few hours, so the
// dashboard caches it for an hour (see useProfilePicture) and re-fetches on expiry.
profilePicture: (sessionId: string, contactId: string) =>
request<ProfilePictureResponse>(`/sessions/${sessionId}/contacts/${encodeURIComponent(contactId)}/profile-picture`),
// Best-effort resolution of a contact id (e.g. an @lid privacy id) to its phone number (MSISDN
// digits), or null when the engine can't map it. Cached a day by useResolvedPhone.
resolvePhone: (sessionId: string, contactId: string) =>
request<{ contactId: string; phone: string | null }>(
`/sessions/${sessionId}/contacts/${encodeURIComponent(contactId)}/phone`,
),
// Batch-resolve profile picture URLs for a whole sidebar in ONE request — the per-chat burst of
// parallel single fetches exhausts the per-IP throttle (429s). Engine lookups run 3 at a time
// server-side; ids beyond the backend's 50-id cap are dropped client-side too.
profilePictures: (sessionId: string, contactIds: string[]) =>
request<{ pictures: Record<string, string | null> }>(
`/sessions/${sessionId}/contacts/profile-pictures?ids=${contactIds
.slice(0, 50)
.map(encodeURIComponent)
.join(',')}`,
),
};
// =============================================================================
// API Key API
// =============================================================================
export const apiKeyApi = {
list: () => request<ApiKey[]>('/auth/api-keys'),
get: (id: string) => request<ApiKey>(`/auth/api-keys/${id}`),
create: (data: {
name: string;
role: string;
allowedIps?: string[];
allowedSessions?: string[];
expiresAt?: string;
}) =>
request<ApiKey>('/auth/api-keys', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: Partial<ApiKey>) =>
request<ApiKey>(`/auth/api-keys/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: string) => request<void>(`/auth/api-keys/${id}`, { method: 'DELETE' }),
revoke: (id: string) => request<ApiKey>(`/auth/api-keys/${id}/revoke`, { method: 'POST' }),
};
// =============================================================================
// Audit/Logs API
// =============================================================================
export const auditApi = {
list: (params?: { action?: string; severity?: string; limit?: number; offset?: number }) => {
const query = new URLSearchParams();
if (params?.action) query.set('action', params.action);
if (params?.severity) query.set('severity', params.severity);
if (params?.limit) query.set('limit', String(params.limit));
if (params?.offset) query.set('offset', String(params.offset));
const queryStr = query.toString();
return request<{ data: AuditLog[]; total: number }>(`/audit${queryStr ? `?${queryStr}` : ''}`);
},
};
// =============================================================================
// Message API
// =============================================================================
export const messageApi = {
sendText: (sessionId: string, chatId: string, text: string) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-text`, {
method: 'POST',
body: JSON.stringify({ chatId, text }),
}),
sendImage: (sessionId: string, chatId: string, url: string, caption?: string) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-image`, {
method: 'POST',
body: JSON.stringify({ chatId, url, caption }),
}),
sendVideo: (sessionId: string, chatId: string, url: string, caption?: string) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-video`, {
method: 'POST',
body: JSON.stringify({ chatId, url, caption }),
}),
sendAudio: (sessionId: string, chatId: string, url: string) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-audio`, {
method: 'POST',
body: JSON.stringify({ chatId, url }),
}),
sendDocument: (sessionId: string, chatId: string, url: string, filename?: string) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-document`, {
method: 'POST',
body: JSON.stringify({ chatId, url, filename }),
}),
sendMedia: (
sessionId: string,
chatId: string,
mediaType: 'image' | 'video' | 'audio' | 'document',
payload: SendMediaPayload,
) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-${mediaType}`, {
method: 'POST',
body: JSON.stringify({ chatId, ...payload }),
}),
sendLocation: (sessionId: string, data: SendLocationPayload) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-location`, {
method: 'POST',
body: JSON.stringify(data),
}),
sendContact: (sessionId: string, data: SendContactPayload) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-contact`, {
method: 'POST',
body: JSON.stringify(data),
}),
// Stickers take the same media body as the other send-* endpoints (base64 XOR url + mimetype).
sendSticker: (sessionId: string, chatId: string, payload: SendMediaPayload) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-sticker`, {
method: 'POST',
body: JSON.stringify({ chatId, ...payload }),
}),
sendPoll: (sessionId: string, data: SendPollPayload) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-poll`, {
method: 'POST',
body: JSON.stringify(data),
}),
forward: (sessionId: string, data: ForwardMessagePayload) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/forward`, {
method: 'POST',
body: JSON.stringify(data),
}),
// Async batch: returns 202 immediately; poll getBatchStatus until a terminal status.
sendBulk: (sessionId: string, data: SendBulkPayload) =>
request<BulkBatchResponse>(`/sessions/${sessionId}/messages/send-bulk`, {
method: 'POST',
body: JSON.stringify(data),
}),
getBatchStatus: (sessionId: string, batchId: string) =>
request<BatchStatusResponse>(`/sessions/${sessionId}/messages/batch/${encodeURIComponent(batchId)}`),
cancelBatch: (sessionId: string, batchId: string) =>
request<BatchStatusResponse>(`/sessions/${sessionId}/messages/batch/${encodeURIComponent(batchId)}/cancel`, {
method: 'POST',
}),
reply: (sessionId: string, data: { chatId: string; quotedMessageId: string; text: string }) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/reply`, {
method: 'POST',
body: JSON.stringify(data),
}),
react: (sessionId: string, data: { chatId: string; messageId: string; emoji: string }) =>
request<void>(`/sessions/${sessionId}/messages/react`, {
method: 'POST',
body: JSON.stringify(data),
}),
sendTemplate: (
sessionId: string,
data: { chatId: string; templateId?: string; templateName?: string; variables?: Record<string, string> },
) =>
request<MessageResponse>(`/sessions/${sessionId}/messages/send-template`, {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (sessionId: string, data: { chatId: string; messageId: string; forEveryone?: boolean }) =>
request<void>(`/sessions/${sessionId}/messages/delete`, {
method: 'POST',
body: JSON.stringify(data),
}),
};
// =============================================================================
// Search API
// =============================================================================
export const searchApi = {
search: (params: SearchParams) => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.set(key, String(value));
});
return request<SearchResults>(`/search?${query.toString()}`);
},
};
// =============================================================================
// Health & Infrastructure API
// =============================================================================
export const healthApi = {
check: () => request<HealthStatus>('/health'),
ready: () => request<HealthStatus>('/health/ready'),
};
export const infraApi = {
getStatus: () => request<InfraStatus>('/infra/status'),
getConfig: () => request<SavedConfig>('/infra/config'),
updateConfig: (config: Partial<InfraStatus>) =>
request<InfraStatus>('/infra/config', {
method: 'PUT',
body: JSON.stringify(config),
}),
saveConfig: (config: SaveConfigPayload) =>
request<{ message: string; saved: boolean; envPath: string; profiles: string[] }>('/infra/config', {
method: 'PUT',
body: JSON.stringify(config),
}),
restart: (profiles?: string[], profilesToRemove?: string[]) =>
request<{
message: string;
restarting: boolean;
profiles: string[];
profilesToRemove: string[];
estimatedTime: number;
}>('/infra/restart', {
method: 'POST',
body: JSON.stringify({ profiles: profiles || [], profilesToRemove: profilesToRemove || [] }),
}),
healthCheck: () => request<{ status: string; timestamp: string }>('/infra/health'),
// Data migration: export all Data-DB tables (call while still on the OLD database, before switching),
// then import after the switch + restart. Used by the DB-switch migration guard so data isn't lost.
exportData: () =>
request<{
exportedAt: string;
dataDbType: string;
tables: Record<string, unknown[]>;
counts: Record<string, number>;
}>('/infra/export-data'),
importData: (tables: Record<string, unknown[]>) =>
request<{ imported: boolean; counts?: Record<string, number>; message?: string; warnings?: string[] }>(
'/infra/import-data',
{
method: 'POST',
body: JSON.stringify({ tables }),
},
),
};
// =============================================================================
// Plugin Types
// =============================================================================
/** Field definition within a plugin's config schema (mirrors the backend PluginConfigField). */
export interface PluginConfigField {
// 'textarea' is a multi-line string; a field with `enum` renders as a <select>.
type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'textarea';
title?: string;
description?: string;
default?: unknown;
enum?: unknown[];
required?: boolean;
secret?: boolean;
min?: number;
max?: number;
pattern?: string;
items?: PluginConfigField; // array element schema; array-of-rows when items.type === 'object'
properties?: Record<string, PluginConfigField>; // nested-object fields
}
export interface PluginConfigSchema {
type: 'object';
properties: Record<string, PluginConfigField>;
}
export interface PluginI18nText {
title?: string;
description?: string;
}
export interface PluginI18nLocale {
name?: string;
description?: string;
config?: Record<string, PluginI18nText>;
}
export type PluginI18n = Record<string, PluginI18nLocale>;
export interface Plugin {
id: string;
name: string;
version: string;
type: 'engine' | 'storage' | 'queue' | 'auth' | 'extension';
description?: string;
author?: string;
status: 'installed' | 'enabled' | 'disabled' | 'error';
config: Record<string, unknown>;
builtIn: boolean;
provides: string[];
/** Whether this plugin can host provisioned ingress instances (drives the Instances tab). */
ingressCapable: boolean;
/** Declared config fields, when the plugin exposes a schema (drives the dashboard config form). */
configSchema?: PluginConfigSchema;
/** When set, the plugin ships a sandboxed-iframe config editor (preferred over configSchema). */
configUi?: { entry: string; height?: number };
/** Whether the plugin is scoped to specific sessions (false = global, always runs). */
sessionScoped: boolean;
/** Sessions the plugin is activated for; ['*'] = all numbers. */
activeSessions: string[];
/** Per-session config overrides, keyed by sessionId (secrets redacted per slice). */
sessionConfig?: Record<string, Record<string, unknown>>;
loadedAt?: string;
enabledAt?: string;
error?: string;
i18n?: PluginI18n;
}
export interface Engine {
id: string;
name: string;
enabled: boolean;
features: string[];
/** Underlying engine library (e.g. whatsapp-web.js 1.34.7), distinct from the adapter version. */
library?: { name: string; version: string };
}
/** A remote catalog entry annotated with this instance's install state. */
export interface CatalogPlugin {
id: string;
name: string;
version: string;
type?: string;
status?: string;
description?: string;
author?: string;
license?: string;
keywords?: string[];
minOpenWAVersion?: string;
testedOpenWAVersion?: string;
homepage?: string;
download?: string;
installed: boolean;
installedVersion: string | null;
updateAvailable: boolean;
i18n?: PluginI18n;
}
// =============================================================================
// Plugins API
// =============================================================================
export const pluginsApi = {
list: () => request<Plugin[]>('/plugins'),
get: (id: string) => request<Plugin>(`/plugins/${id}`),
enable: (id: string) =>
request<{ success: boolean; message: string }>(`/plugins/${id}/enable`, {
method: 'POST',
}),
disable: (id: string) =>
request<{ success: boolean; message: string }>(`/plugins/${id}/disable`, {
method: 'POST',
}),
updateConfig: (id: string, config: Record<string, unknown>) =>
request<{ success: boolean; message: string }>(`/plugins/${id}/config`, {
method: 'PUT',
body: JSON.stringify({ config }),
}),
/** Set which sessions a session-scoped plugin is activated for (['*'] = all). */
setSessions: (id: string, sessions: string[]) =>
request<Plugin>(`/plugins/${id}/sessions`, { method: 'PUT', body: JSON.stringify({ sessions }) }),
/** Set (or clear, with an empty object) a plugin's config override for one session. */
updateSessionConfig: (id: string, sessionId: string, config: Record<string, unknown>) =>
request<{ success: boolean; message: string }>(`/plugins/${id}/config/${encodeURIComponent(sessionId)}`, {
method: 'PUT',
body: JSON.stringify({ config }),
}),
healthCheck: (id: string) => request<{ healthy: boolean; message?: string }>(`/plugins/${id}/health`),
install: (file: File) => {
const form = new FormData();
form.append('file', file);
return request<Plugin>('/plugins/install', { method: 'POST', body: form });
},
installFromUrl: (url: string) =>
request<Plugin>('/plugins/install-url', { method: 'POST', body: JSON.stringify({ url }) }),
updateFromUrl: (id: string, url: string) =>
request<Plugin>(`/plugins/${id}/update`, { method: 'POST', body: JSON.stringify({ url }) }),
catalog: () => request<CatalogPlugin[]>('/plugins/catalog'),
/** Fetch a plugin's sandboxed config-UI entry HTML (the API key stays here, in the parent). */
getConfigUi: (id: string) => requestText(`/plugins/${id}/config-ui`),
uninstall: (id: string) => request<{ success: boolean; message: string }>(`/plugins/${id}`, { method: 'DELETE' }),
getEngines: () => request<Engine[]>('/infra/engines'),
getCurrentEngine: () => request<{ engineType: string }>('/infra/engines/current'),
};
// =============================================================================
// Plugin instances API (Integration Fabric provisioning; mirrors src/modules/integration)
// =============================================================================
export interface IngressUrl {
route: string;
url: string;
}
export interface InstanceView {
id: string;
pluginId: string;
instanceId: string;
sessionScope: string | null;
secret: string; // '***' on reads; plaintext once on create/regenerate
verifyToken: string | null;
config: Record<string, unknown> | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
ingressUrls: IngressUrl[];
}
export type MintedInstance = InstanceView; // same shape; `secret` carries the plaintext once
export interface CreateInstanceInput {
instanceId: string;
sessionScope?: string;
verifyToken?: string;
/** Provider-fixed webhook secret (e.g. Chatwoot's). Omit to auto-generate one (shown once). */
secret?: string;
config?: Record<string, unknown>;
}
export interface UpdateInstanceInput {
enabled?: boolean;
sessionScope?: string;
config?: Record<string, unknown>;
}
export const pluginInstancesApi = {
list: (pluginId: string) => request<InstanceView[]>(`/integration/plugins/${pluginId}/instances`),
create: (pluginId: string, body: CreateInstanceInput) =>
request<MintedInstance>(`/integration/plugins/${pluginId}/instances`, {
method: 'POST',
body: JSON.stringify(body),
}),
regenerateSecret: (pluginId: string, instanceId: string) =>
request<MintedInstance>(`/integration/plugins/${pluginId}/instances/${instanceId}/regenerate-secret`, {
method: 'POST',
}),
update: (pluginId: string, instanceId: string, body: UpdateInstanceInput) =>
request<InstanceView>(`/integration/plugins/${pluginId}/instances/${instanceId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (pluginId: string, instanceId: string) =>
request<void>(`/integration/plugins/${pluginId}/instances/${instanceId}`, { method: 'DELETE' }),
};
// =============================================================================
// Statistics API (mirrors src/modules/stats)
// =============================================================================
export type StatsPeriod = '24h' | '7d' | '30d';
export interface OverviewStats {
sessions: { active: number; total: number; byStatus: Record<string, number> };
messages: { sent: number; received: number; failed: number; today: { sent: number; received: number } };
}
export interface MessageTimeSeriesPoint {
timestamp: string;
sent: number;
received: number;
}
export interface MessageStats {
timeSeries: MessageTimeSeriesPoint[];
byType: Record<string, number>;
bySession: Array<{ sessionId: string; name: string; sent: number; received: number }>;
topChats: Array<{ chatId: string; chatName?: string | null; messageCount: number }>;
}
export const statsApi = {
getOverview: () => request<OverviewStats>('/stats/overview'),
getMessages: (period: StatsPeriod) => request<MessageStats>(`/stats/messages?period=${period}`),
};