/** * 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; peerDependencies?: Record; // 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; } /** Keyed by a dashboard locale code (e.g. "es", "zh-CN"). Untranslated entries fall back to English. */ export type PluginI18n = Record; /** * 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 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; // nested-object fields (type: 'object') } export interface PluginConfigSchema { type: 'object'; properties: Record; } // ============================================================================ // 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_`) 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; // 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(); 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) => 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; reply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise; } export interface PluginEngineReadCapability { getGroupInfo(sessionId: string, groupId: string): ReturnType; getContacts(sessionId: string): ReturnType; getContactById(sessionId: string, contactId: string): ReturnType; checkNumberExists(sessionId: string, phone: string): ReturnType; getChats(sessionId: string): ReturnType; /** 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; /** * Canonical (neutral) form of a chat id: resolves a `@lid` privacy id to its stable `@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; } /** 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; } /** Normalized outbound send for a plugin — translated host-side to MessageService.sendText/reply. */ export interface PluginConversationsCapability { send(env: ConversationSendEnvelope): Promise; } /** * 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; } /** * 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; 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; // 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) => void; debug: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; error: (message: string, error?: unknown, meta?: Record) => void; } export interface PluginStorage { get: (key: string) => Promise; set: (key: string, value: T) => Promise; delete: (key: string) => Promise; list: (prefix?: string) => Promise; } // ============================================================================ // Plugin Interface (what plugins must implement) // ============================================================================ export interface IPlugin { // Lifecycle hooks onLoad?: (context: PluginContext) => Promise; onEnable?: (context: PluginContext) => Promise; onDisable?: (context: PluginContext) => Promise; onUnload?: (context: PluginContext) => Promise; // Configuration change handler onConfigChange?: (context: PluginContext, newConfig: Record) => Promise; // 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) => 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; 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>; // 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; 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>; // 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; }