qwen_2.5_model / src /core /plugins /plugin.interfaces.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
27.1 kB
/**
* Plugin System Interfaces
* Defines the contract for OpenWA plugins
*/
import { HookManager, HookEvent, HookHandler } from '../hooks';
import type { MessageResponseDto } from '../../modules/message/dto';
import type { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface';
import type { PluginNetRequestInit, PluginNetResponse } from './plugin-net';
import type { HandoverState } from '../../modules/integration/entities/conversation-mapping.entity';
import type { WebhookRequest, WebhookResponse, WebhookHandler } from './sandbox/worker-webhooks';
// Re-export the ingress webhook types on the public SDK surface so plugin authors can type their
// handler without importing from sandbox internals.
export type { WebhookRequest, WebhookResponse, WebhookHandler };
// ============================================================================
// Plugin Types
// ============================================================================
export enum PluginType {
ENGINE = 'engine', // WhatsApp engine (whatsapp-web.js, baileys, etc.)
STORAGE = 'storage', // Storage backends (local, S3, GCS, etc.)
QUEUE = 'queue', // Queue systems (Redis, RabbitMQ, etc.)
AUTH = 'auth', // Authentication providers
EXTENSION = 'extension', // General extensions (auto-reply, scheduler, etc.)
}
export enum PluginStatus {
INSTALLED = 'installed',
ENABLED = 'enabled',
DISABLED = 'disabled',
ERROR = 'error',
}
// ============================================================================
// Plugin Manifest
// ============================================================================
export interface PluginManifest {
id: string; // Unique identifier (e.g., 'whatsapp-web.js', 'auto-reply')
name: string; // Display name
version: string; // Semver
type: PluginType;
description?: string;
author?: string;
homepage?: string;
repository?: string;
license?: string;
// Entry point
main: string; // Relative path to main file
// Dependencies
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
// Configuration schema (optional, for UI generation)
configSchema?: PluginConfigSchema;
// Optional sandboxed-iframe config editor. `entry` is a plugin-relative path to a self-contained
// HTML file (inline JS/CSS — a sandboxed opaque-origin iframe can't load subresources). Served by
// the host via the authenticated GET /plugins/:id/config-ui and injected as an iframe `srcdoc`; the
// editor exchanges config over a postMessage bridge (the API key never reaches the iframe). When
// present, the dashboard prefers it over the declarative `configSchema` form.
configUi?: { entry: string; height?: number };
// Hooks this plugin listens to
hooks?: HookEvent[];
// Features provided by this plugin
provides?: string[];
// Required features from other plugins
requires?: string[];
// Capability permissions this plugin declares; the loader enforces them at the capability
// boundary (see PluginCapabilityPermission). A capability call whose permission is not declared
// here is denied with a PluginCapabilityError. Absent / empty = no capability access.
permissions?: string[];
// Session ids this plugin may act on, or ['*']. Absent = ['*'] (all). Enforced by the
// capability facade. Static (manifest) by design: editing plugin config cannot widen scope.
sessions?: string[];
// Whether the plugin is scoped to specific sessions (default true). A session-scoped plugin only
// receives hook events for the sessions an operator has activated it for (see activeSessions); a
// global plugin (false) always runs, with no per-number notion (e.g. a metrics logger).
sessionScoped?: boolean;
// Outbound-HTTP host allowlist for `ctx.net.fetch` (requires the `net:fetch` permission). Each
// entry is `host:port` (exact) or a bare `host` (any port); `'*'` allows any public host. Absent /
// empty = deny all. `allowConfigHosts` additionally admits the host of each named config key (e.g. an
// operator-set base URL), resolved at fetch time. The SSRF guard still blocks internal IPs regardless.
net?: { allow?: string[]; allowConfigHosts?: string[] };
// Localized dashboard text (name/description/config field titles) per locale code. English is the
// base manifest + fallback. Dashboard-only; does not affect runtime behavior.
i18n?: PluginI18n;
// Integration SDK major.minor the plugin was authored against (e.g. '1' or '1.2'). Only the major
// is enforced — see SUPPORTED_SDK_MAJOR / validateIngressManifest. Absent = treated as '1'.
sdkVersion?: string;
// Inbound webhook routes this plugin claims (requires the `webhook:ingress` permission). Validated
// by validateIngressManifest, which the loader calls on every external plugin load (loadPlugin).
// (Built-in registration declares no ingress and bypasses that validation.)
ingress?: PluginIngressRoute[];
}
/** Localized overrides for a plugin's dashboard-facing text, per locale (dashboard i18n). */
export interface PluginI18nText {
title?: string;
description?: string;
}
export interface PluginI18nLocale {
name?: string;
description?: string;
/** Keyed by a TOP-LEVEL configSchema.properties key; only title/description are localized. */
config?: Record<string, PluginI18nText>;
}
/** Keyed by a dashboard locale code (e.g. "es", "zh-CN"). Untranslated entries fall back to English. */
export type PluginI18n = Record<string, PluginI18nLocale>;
/**
* One field in a plugin's config schema. Recursive: an `object` field nests `properties`, an `array`
* field describes its element with `items` (array-of-rows when `items.type === 'object'`). The host
* renders this into an authenticated form; the plugin still reads `ctx.config` defensively.
*/
export interface PluginConfigField {
// 'textarea' is a string rendered multi-line; a field with `enum` renders as a <select>.
type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'textarea';
title?: string;
description?: string;
default?: unknown;
enum?: unknown[]; // when present (any scalar type), the field renders as a <select>
required?: boolean;
secret?: boolean; // sensitive value (e.g. API key): masked on read, preserved on an unchanged write
// Validation hints, surfaced as HTML input attributes (advisory — not hard-enforced server-side):
min?: number; // number: value bound; string/textarea: minLength; array: min rows
max?: number; // number: value bound; string/textarea: maxLength; array: max rows
pattern?: string; // string/textarea: HTML validation regex
// Composite kinds:
items?: PluginConfigField; // array element schema; array-of-rows when items.type === 'object'
properties?: Record<string, PluginConfigField>; // nested-object fields (type: 'object')
}
export interface PluginConfigSchema {
type: 'object';
properties: Record<string, PluginConfigField>;
}
// ============================================================================
// Plugin Capability
// ============================================================================
/**
* Capability permissions a plugin declares in its manifest `permissions` and that the loader
* enforces at the capability boundary. A plugin may only use a capability whose permission it
* declares; an undeclared (or missing-permission) plugin is denied with a PluginCapabilityError.
*/
export const PluginCapabilityPermission = {
/** `ctx.messages.*` — send / reply on a session. */
MESSAGES_SEND: 'messages:send',
/** `ctx.engine.*` — read-only engine queries (group info, contacts, chats, number check). */
ENGINE_READ: 'engine:read',
/** `ctx.net.fetch` — SSRF-guarded outbound HTTP, scoped to the manifest `net.allow` host list. */
NET_FETCH: 'net:fetch',
/** `ctx.registerWebhook` — claim an inbound ingress route. Loader-enforced; cannot be widened by config. */
WEBHOOK_INGRESS: 'webhook:ingress',
/** `ctx.conversations.send` — normalized outbound send translated to MessageService. */
CONVERSATION_SEND: 'conversation:send',
} as const;
export type PluginCapabilityPermission = (typeof PluginCapabilityPermission)[keyof typeof PluginCapabilityPermission];
// ============================================================================
// Integration SDK v1 — inbound webhook ingress + normalized outbound send
// ============================================================================
/** How an inbound webhook's authenticity is established before the plugin sees it. */
export interface IngressSignatureSpec {
/**
* - `hmac-sha256`: HMAC over a `contentTemplate` (tokens `{rawBody}`/`{timestamp}`/`{id}`).
* - `shared-secret`: constant-time compare of a header value against `instance.secret`.
* - `standard-webhooks`: host-side [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks)
* verify. The wire format is fixed by the spec (headers `webhook-id`/`webhook-timestamp`/
* `webhook-signature`, signed content `${webhook-id}.${webhook-timestamp}.${rawBody}`, base64
* HMAC-SHA256 with the base64-decoded Svix key, `v1,` prefix, space-separated candidate list), so
* `header`/`contentTemplate`/`encoding`/`prefix`/`timestampHeader` are IGNORED — only
* `toleranceSec` (default 300) and `dedupHeader` apply. The operator pastes the Svix secret
* (`v1,whsec_<base64>`) as `instance.secret`.
*/
scheme: 'hmac-sha256' | 'shared-secret' | 'standard-webhooks' | 'none';
header?: string;
// Template over which the HMAC is computed. `{rawBody}` `{timestamp}` `{id}` placeholders.
contentTemplate?: string;
encoding?: 'hex' | 'base64';
prefix?: string;
timestampHeader?: string;
toleranceSec?: number; // when present, must be > 0 (see validateIngressManifest)
dedupHeader?: string;
}
/** Provider webhook-verification challenge (e.g. a GET handshake on route registration). */
export interface IngressChallengeSpec {
method: 'GET';
tokenParam: string;
echoParam: string;
}
/** A host-side preflight check on an inbound route, evaluated AFTER signature verify and BEFORE the
* dedup persist. First failure short-circuits to its mapped HTTP status. O(1), never initializes the
* engine, never mutates state. */
export type IngressPreflightCheck = {
// Reject (503) when the route's concrete-scoped WhatsApp session is not alive (no live engine, or
// EngineStatus.FAILED). Recoverable statuses (INITIALIZING/QR_READY/AUTHENTICATING/DISCONNECTED) and
// READY pass through to a normal 202+enqueue so the worker can fail fast and the dedup row holds the
// delivery. Skipped for wildcard (sessionScope null/'*') scopes — there is no single session to probe.
type: 'session-alive';
};
/** Declares the synchronous HTTP response an inbound route returns to the provider, computed entirely
* host-side. The plugin ALWAYS runs async (enqueued, full DLQ/retry) regardless of this contract. */
export interface IngressResponseContract {
preflight?: IngressPreflightCheck[];
ack?: {
status?: number; // default 202
body?: string; // literal, or a '{rawBody}'/'{timestamp}'/'{id}' template rendered host-side
headers?: Record<string, string>; // static; validated at load (HTTP-token name, no CR/LF value)
};
deadlineMs?: number; // documented provider ack budget (advisory; not enforced)
}
/** One inbound webhook route a plugin claims. Requires the `webhook:ingress` permission. */
export interface PluginIngressRoute {
route: string; // host prefixes it; the plugin never binds a port
/**
* @deprecated 'sync-reply' is inert dead code since the P0 substrate (#568) and is NOT wired to the
* HTTP response — the pipeline is always async + fast-ack. Declare synchronous response behavior via
* `response` instead. Kept in the union only to preserve SDK v1 additive-only compatibility; do not
* remove within major 1, and do not rely on either value at runtime.
*/
mode: 'async' | 'sync-reply';
signature: IngressSignatureSpec;
challenge?: IngressChallengeSpec;
/** Reserved/advisory compatibility field. Authenticity is currently verified by the host according
* to `signature`; the worker does not perform an additional `self` verification pass. */
verify: 'core' | 'self';
maxBodyBytes: number;
// Optional: where the provider's conversation id lives, so the host can compute a per-conversation
// ordering key (P1). Absent => the P1 lock falls back to per-instance serialization. The host never
// needs to understand the provider's schema beyond this one pointer.
conversationId?: { header?: string; jsonPointer?: string };
/** Optional synchronous-response contract (host-side preflight + ack). Additive; absent = today's
* default 202 fast-ack, byte-identical. Validated by validateIngressManifest. */
response?: IngressResponseContract;
}
// Normalized outbound envelope for ctx.conversations.send (POJO across the wire).
export interface ConversationSendEnvelope {
sessionId?: string;
instanceId?: string;
chatId?: string;
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'voice' | 'location';
text?: string;
mediaUrl?: string;
replyTo?: string;
source?: { provider: string; externalConversationId: string };
}
/** Integration SDK major version this host supports. A plugin whose `sdkVersion` major differs is refused. */
export const SUPPORTED_SDK_MAJOR = 1;
// ack header guards: name must be an RFC 7230 token (no spaces/separators), value must contain no
// CR/LF (header-injection guard). The header source is the static manifest, validated once at load.
const HTTP_HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const HTTP_HEADER_VALUE_NO_CRLF = /^[^\r\n]*$/;
/**
* Validates a manifest's `ingress` declarations: SDK major compatibility, the `webhook:ingress`
* permission, route uniqueness, and that a declared `toleranceSec` is usable (> 0 — a replay window
* of zero or less would make the tolerance check a no-op). A manifest with no `ingress` entries is a
* no-op. Called from PluginLoaderService.loadPlugin, so a malformed declaration is rejected at load time.
*/
export function validateIngressManifest(manifest: PluginManifest): void {
if (!manifest.ingress?.length) return; // no ingress declared → nothing to validate
const declaredMajor = Number.parseInt((manifest.sdkVersion ?? '1').split('.')[0], 10);
if (!Number.isFinite(declaredMajor) || declaredMajor !== SUPPORTED_SDK_MAJOR) {
throw new Error(
`Plugin ${manifest.id}: SDK major ${manifest.sdkVersion} is not supported by this host (supports ${SUPPORTED_SDK_MAJOR})`,
);
}
const perms = manifest.permissions ?? [];
if (!perms.includes(PluginCapabilityPermission.WEBHOOK_INGRESS)) {
throw new Error(`Plugin ${manifest.id}: declares ingress routes but is missing the 'webhook:ingress' permission`);
}
const seen = new Set<string>();
for (const r of manifest.ingress) {
if (!r.route || seen.has(r.route)) {
throw new Error(`Plugin ${manifest.id}: duplicate or empty ingress route '${r.route}'`);
}
seen.add(r.route);
if (r.signature.toleranceSec !== undefined && r.signature.toleranceSec <= 0) {
throw new Error(
`Plugin ${manifest.id}: route '${r.route}' toleranceSec must be > 0 (a replay guard would be a no-op)`,
);
}
if (r.response) {
const ackStatus = r.response.ack?.status;
if (ackStatus !== undefined && (!Number.isInteger(ackStatus) || ackStatus < 100 || ackStatus > 599)) {
throw new Error(
`Plugin ${manifest.id}: route '${r.route}' response.ack.status must be a valid HTTP status (100-599)`,
);
}
if (r.response.ack?.headers) {
for (const [name, value] of Object.entries(r.response.ack.headers)) {
if (!HTTP_HEADER_NAME.test(name)) {
throw new Error(
`Plugin ${manifest.id}: route '${r.route}' response.ack header name '${name}' is not a valid HTTP token`,
);
}
if (!HTTP_HEADER_VALUE_NO_CRLF.test(value)) {
throw new Error(
`Plugin ${manifest.id}: route '${r.route}' response.ack header '${name}' has invalid characters (CR/LF forbidden)`,
);
}
}
}
}
}
}
/**
* Warns about each ingress route declared with `scheme: 'none'` — a fully-unauthenticated public endpoint
* that anyone who can reach the host can use to trigger WhatsApp sends. Purely additive (a warning): a
* deployment that legitimately relies on scheme:'none' (a provider that offers no HMAC) still boots; the
* loud log surfaces the exposure so an operator can front the URL with a network/reverse-proxy guard.
* Called from PluginLoaderService.loadPlugin at boot and on dynamic install.
*/
export function warnUnauthenticatedIngressRoutes(
manifest: PluginManifest,
logger: { warn: (message: string, context?: Record<string, unknown>) => void },
): void {
for (const r of manifest.ingress ?? []) {
if (r.signature.scheme === 'none') {
logger.warn(
`Ingress route '${r.route}' of plugin '${manifest.id}' uses signature scheme 'none' — it is an ` +
`UNAUTHENTICATED public endpoint that can trigger WhatsApp sends. Only keep this if the provider ` +
`offers no HMAC and the URL is guarded by a network/reverse-proxy ACL.`,
{ pluginId: manifest.id, route: r.route, action: 'ingress_unauthenticated_route' },
);
}
}
}
/**
* Thrown by a plugin capability when a call is rejected (missing permission, out-of-scope session,
* unstarted session, etc.). Gives plugins a predictable failure instead of a raw TypeError.
*/
export class PluginCapabilityError extends Error {
constructor(message: string) {
super(message);
this.name = 'PluginCapabilityError';
}
}
export interface PluginMessagingCapability {
sendText(sessionId: string, chatId: string, text: string): Promise<MessageResponseDto>;
reply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise<MessageResponseDto>;
}
export interface PluginEngineReadCapability {
getGroupInfo(sessionId: string, groupId: string): ReturnType<IWhatsAppEngine['getGroupInfo']>;
getContacts(sessionId: string): ReturnType<IWhatsAppEngine['getContacts']>;
getContactById(sessionId: string, contactId: string): ReturnType<IWhatsAppEngine['getContactById']>;
checkNumberExists(sessionId: string, phone: string): ReturnType<IWhatsAppEngine['checkNumberExists']>;
getChats(sessionId: string): ReturnType<IWhatsAppEngine['getChats']>;
/** Recent messages for a chat (both directions), for history backfill. `limit` is clamped host-side. */
getChatHistory(
sessionId: string,
chatId: string,
limit?: number,
includeMedia?: boolean,
): ReturnType<IWhatsAppEngine['getChatHistory']>;
/**
* Canonical (neutral) form of a chat id: resolves a `@lid` privacy id to its stable `<phone>@c.us`
* when the lid->phone mapping is known, and otherwise returns the id unchanged. Lets a plugin key a
* chat by one identity across WhatsApp's `@lid` migration (best-effort; an unresolved lid stays `@lid`).
*/
canonicalChatId(sessionId: string, chatId: string): Promise<string>;
}
/** Outbound HTTP for a plugin — always through the host SSRF guard, scoped to `manifest.net.allow`. */
export interface PluginNetCapability {
fetch(url: string, init?: PluginNetRequestInit): Promise<PluginNetResponse>;
}
/** Normalized outbound send for a plugin — translated host-side to MessageService.sendText/reply. */
export interface PluginConversationsCapability {
send(env: ConversationSendEnvelope): Promise<unknown>;
}
/**
* Flip a mapped conversation's handover state. Reuses the `conversation:send` permission — flipping
* handover is part of owning the conversation, not a distinct capability grant.
*/
export interface PluginHandoverCapability {
set(key: { sessionId: string; chatId: string; instanceId: string }, state: HandoverState): Promise<unknown>;
}
/**
* Plugin-facing conversation mapping: create/read the WA-chat <-> provider-conversation link an adapter
* needs so handover.set and conversation.send({source}) can resolve. Reuses the `conversation:send`
* permission — owning the mapping is part of owning the conversation.
*/
export interface PluginMappingsCapability {
upsert(key: { sessionId: string; chatId: string; instanceId: string }, providerConversationId: string): Promise<void>;
get(key: {
sessionId: string;
chatId: string;
instanceId: string;
}): Promise<{ providerConversationId: string; handoverState: HandoverState } | null>;
getByProvider(
instanceId: string,
providerConversationId: string,
): Promise<{ sessionId: string; chatId: string; handoverState: HandoverState } | null>;
}
// ============================================================================
// Plugin Context (passed to plugin on initialization)
// ============================================================================
export interface PluginContext {
// Plugin info
pluginId: string;
manifest: PluginManifest;
// Configuration
config: Record<string, unknown>;
// Hook system
hookManager: HookManager;
// Logger instance for this plugin
logger: PluginLogger;
// Storage for plugin data
storage: PluginStorage;
// Register a hook handler
registerHook: (event: HookEvent, handler: HookHandler, priority?: number) => void;
// Claim an inbound ingress webhook route (requires the `webhook:ingress` permission). Delivered only
// to sandboxed plugins via the ingress pipeline; in-process built-ins cannot receive ingress.
registerWebhook: (route: string, handler: WebhookHandler) => void;
// Curated write surface — routes through MessageService (persistence preserved).
messages: PluginMessagingCapability;
// Read-only, scoped engine queries.
engine: PluginEngineReadCapability;
// SSRF-guarded outbound HTTP, scoped to the manifest `net.allow` host list.
net: PluginNetCapability;
// Normalized outbound send, translated to MessageService. Requires `conversation:send`.
conversations: PluginConversationsCapability;
// Flip a mapped conversation's bot/human/closed handover state. Requires `conversation:send`.
handover: PluginHandoverCapability;
// Create/read the WA-chat <-> provider-conversation mapping. Requires `conversation:send`.
mappings: PluginMappingsCapability;
}
export interface PluginLogger {
log: (message: string, meta?: Record<string, unknown>) => void;
debug: (message: string, meta?: Record<string, unknown>) => void;
warn: (message: string, meta?: Record<string, unknown>) => void;
error: (message: string, error?: unknown, meta?: Record<string, unknown>) => void;
}
export interface PluginStorage {
get: <T = unknown>(key: string) => Promise<T | null>;
set: <T = unknown>(key: string, value: T) => Promise<void>;
delete: (key: string) => Promise<void>;
list: (prefix?: string) => Promise<string[]>;
}
// ============================================================================
// Plugin Interface (what plugins must implement)
// ============================================================================
export interface IPlugin {
// Lifecycle hooks
onLoad?: (context: PluginContext) => Promise<void>;
onEnable?: (context: PluginContext) => Promise<void>;
onDisable?: (context: PluginContext) => Promise<void>;
onUnload?: (context: PluginContext) => Promise<void>;
// Configuration change handler
onConfigChange?: (context: PluginContext, newConfig: Record<string, unknown>) => Promise<void>;
// Health check (for dashboard monitoring)
healthCheck?: () => Promise<{ healthy: boolean; message?: string }>;
}
// ============================================================================
// Engine Plugin Interface (extends IPlugin for engine-specific methods)
// ============================================================================
export interface IEnginePlugin extends IPlugin {
type: PluginType.ENGINE;
// Engine factory method
createEngine: (config: Record<string, unknown>) => unknown;
// Get supported features
getFeatures: () => string[];
// Underlying engine library name + version (e.g. { name: 'whatsapp-web.js', version: '1.34.7' }) —
// distinct from the adapter/plugin's own manifest version. Optional: an engine may not report it.
getEngineLibrary?: () => { name: string; version: string };
}
// ============================================================================
// Plugin Instance (runtime representation)
// ============================================================================
export interface PluginInstance {
manifest: PluginManifest;
status: PluginStatus;
config: Record<string, unknown>;
instance: IPlugin | null;
error?: string;
loadedAt?: Date;
enabledAt?: Date;
// Sessions a session-scoped plugin is activated for; ['*'] = all. Defaulted to ['*'] on enable.
// Ignored for a global (sessionScoped:false) plugin. Persisted on the registry entry.
activeSessions?: string[];
// Per-session config overrides, keyed by sessionId. The config a hook sees for session S is the
// override shallow-merged over `config` (the '*' base) — see resolvePluginConfig. Absent = no
// overrides (every session gets the base). Persisted on the registry entry.
sessionConfig?: Record<string, Record<string, unknown>>;
// First-party built-ins (engines, bundled extensions) run in-process; plugins loaded from the
// plugins directory are untrusted and run sandboxed in a worker. `false` => sandboxed.
builtIn?: boolean;
}
// ============================================================================
// Plugin Registry Entry (for storage)
// ============================================================================
export interface PluginRegistryEntry {
id: string;
type: PluginType;
name: string;
version: string;
status: PluginStatus;
config: Record<string, unknown>;
builtIn: boolean; // True for bundled plugins
installedAt: Date;
updatedAt: Date;
// Sessions a session-scoped plugin is activated for; ['*'] = all. Absent = not yet set (treated
// as ['*'] on enable).
activeSessions?: string[];
// Per-session config overrides (keyed by sessionId), merged over `config` per session at hook time.
sessionConfig?: Record<string, Record<string, unknown>>;
// The operator's standing decision, as opposed to `status`, which is where the runtime currently is.
// `status` is reset to INSTALLED on every load (enabling runs the lifecycle and is never inherited
// from a previous process), so it cannot carry intent across a restart — a restart used to silently
// turn every extension plugin off (#856). This field is what bootstrap restores from. Written only by
// the operator-facing enable/disable, never by the loader's own teardown. Absent on pre-#856 rows,
// which are adopted from a lingering ENABLED status on first load.
enabledByOperator?: boolean;
}