4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
610 lines
27 KiB
Markdown
610 lines
27 KiB
Markdown
# 19 - Plugin Architecture
|
|
|
|
## Implementation Status
|
|
|
|
> **Current Status: ✅ Implemented**
|
|
>
|
|
> The plugin runtime — loader, hook bus, capability facade, permission enforcement, per-session
|
|
> activation, and a `worker_thread` sandbox for untrusted plugins — is shipped and wired.
|
|
|
|
| Component | Status | Location |
|
|
|-----------|--------|----------|
|
|
| **HookManager** | ✅ Implemented | `src/core/hooks/hook-manager.service.ts` |
|
|
| **PluginLoaderService** | ✅ Implemented | `src/core/plugins/plugin-loader.service.ts` |
|
|
| **PluginStorageService** | ✅ Implemented | `src/core/plugins/plugin-storage.service.ts` |
|
|
| **Manifest loading** | ✅ Implemented | Loads from `plugins/` directory |
|
|
| **Plugin lifecycle** | ✅ Implemented | onLoad, onEnable, onDisable, onUnload, onConfigChange, healthCheck |
|
|
| **Dashboard UI** | ✅ Implemented | `dashboard/src/pages/Plugins.tsx` |
|
|
| **REST API** | ✅ Implemented | `src/modules/plugins/plugins.controller.ts` |
|
|
|
|
| Component | Status | Notes |
|
|
|-----------|--------|-------|
|
|
| **Sandboxed execution** | ✅ Implemented | Untrusted (disk-loaded) plugins run in a `worker_thread`; see [23 — Plugin Sandboxing](./23-plugin-sandboxing.md). No `vm2`. |
|
|
| **Permission enforcement** | ✅ Implemented | Capability permissions enforced at the call boundary via `assertPermission` |
|
|
| **Per-session activation** | ✅ Implemented | A session-scoped plugin runs only for the sessions an operator activated it for |
|
|
| **Per-session config** | ✅ Implemented | Per-session config overrides shallow-merged over the base config at hook time |
|
|
| **Built-in plugins** | ✅ Implemented | The two engine adapters (`whatsapp-web.js`, `baileys`) register as in-process built-ins |
|
|
| **Plugin install / catalog** | ✅ Implemented | Install a `.zip` by upload or URL, or from the remote catalog |
|
|
| **@openwa/plugin-sdk** | 🔜 Planned | NPM package not yet published; plugins implement `IPlugin` directly today |
|
|
|
|
---
|
|
|
|
## 19.1 Overview
|
|
|
|
The plugin architecture enables OpenWA extensibility without modifying the core codebase. Plugins can add new features, integrate with external services, or customize behavior.
|
|
|
|
### Design Goals
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Goals
|
|
ISO[Isolation]
|
|
EXT[Extensibility]
|
|
SAFE[Safety]
|
|
PERF[Performance]
|
|
end
|
|
|
|
subgraph Implementation
|
|
ISO --> SANDBOX[Sandboxed Execution]
|
|
EXT --> HOOKS[Hook System]
|
|
SAFE --> PERM[Permission Model]
|
|
PERF --> LAZY[Lazy Loading]
|
|
end
|
|
```
|
|
|
|
1. **Isolation** - Untrusted plugins (anything loaded from the `plugins/` directory) run in a `worker_thread`, separate from in-process built-ins; capability calls round-trip to the host. First-party built-ins (the engine adapters) run in-process. A `worker_thread` is V8-context isolation in the same OS process, not an OS-level sandbox — see [23 — Plugin Sandboxing](./23-plugin-sandboxing.md) for what it does and does not guarantee, and the OS-containment guidance.
|
|
2. **Extensibility** - Easy to add new features
|
|
3. **Safety** - Capability permissions are enforced at the call boundary (`assertPermission` throws `PluginCapabilityError`), session scope is enforced per call, and outbound HTTP is SSRF-guarded.
|
|
4. **Performance** - Lazy loading, minimal overhead
|
|
|
|
## 19.2 Plugin Types
|
|
|
|
### Type Categories
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph PluginTypes[Plugin Types]
|
|
MSG[Message Handlers]
|
|
WH[Webhook Transformers]
|
|
AUTH[Auth Providers]
|
|
STORE[Storage Providers]
|
|
INT[Integrations]
|
|
end
|
|
|
|
MSG --> |Process| INCOMING[Incoming Messages]
|
|
MSG --> |Transform| OUTGOING[Outgoing Messages]
|
|
WH --> |Filter/Transform| WEBHOOK[Webhook Payloads]
|
|
AUTH --> |Custom| AUTHFLOW[Auth Flow]
|
|
STORE --> |Alternative| STORAGE[Storage Backends]
|
|
INT --> |External| SERVICES[External Services]
|
|
```
|
|
|
|
| Type | Description | Examples |
|
|
|------|-------------|----------|
|
|
| Message Handler | Process incoming/outgoing messages | Auto-reply, Translation |
|
|
| Webhook Transformer | Transform webhook payloads | Add metadata, Filter events |
|
|
| Auth Provider | Custom authentication | OAuth, LDAP |
|
|
| Storage Provider | Alternative storage backends | Google Drive, Dropbox |
|
|
| Integration | External service integration | CRM, Analytics, n8n |
|
|
|
|
## 19.3 Plugin Structure
|
|
|
|
### Directory Structure
|
|
|
|
```
|
|
plugins/
|
|
├── my-plugin/
|
|
│ ├── package.json # Plugin metadata
|
|
│ ├── index.ts # Entry point
|
|
│ ├── manifest.json # Permissions & config
|
|
│ ├── src/
|
|
│ │ ├── handlers/ # Message handlers
|
|
│ │ ├── hooks/ # Hook implementations
|
|
│ │ └── utils/ # Utilities
|
|
│ ├── config/
|
|
│ │ └── default.json # Default config
|
|
│ └── README.md # Documentation
|
|
```
|
|
|
|
### Manifest File
|
|
|
|
`id`, `name`, `version`, `type`, and `main` are required; the rest are optional. There is **no**
|
|
`types` field and **no** version-compatibility (`min`/`maxVersion`) check — the loader does not gate on
|
|
a host version. The config schema is the top-level `configSchema` (note: not nested under `config`).
|
|
|
|
```json
|
|
{
|
|
"id": "my-awesome-plugin",
|
|
"name": "My Awesome Plugin",
|
|
"version": "1.0.0",
|
|
"type": "extension",
|
|
"description": "An awesome plugin for OpenWA",
|
|
"author": "Your Name",
|
|
"license": "MIT",
|
|
|
|
"main": "dist/index.js",
|
|
|
|
"permissions": ["messages:send", "engine:read", "net:fetch"],
|
|
|
|
"sessionScoped": true,
|
|
"sessions": ["*"],
|
|
|
|
"net": { "allow": ["api.example.com"] },
|
|
|
|
"hooks": ["message:received", "message:sending"],
|
|
|
|
"provides": ["greeter"],
|
|
"requires": [],
|
|
|
|
"configSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"greeting": { "type": "string", "title": "Greeting", "default": "Hello" },
|
|
"apiKey": { "type": "string", "title": "API key", "secret": true }
|
|
}
|
|
},
|
|
|
|
"configUi": { "entry": "config-ui.html", "height": 480 },
|
|
|
|
"i18n": {
|
|
"es": { "name": "Mi complemento", "config": { "greeting": { "title": "Saludo" } } }
|
|
}
|
|
}
|
|
```
|
|
|
|
| Field | Required | Meaning |
|
|
|-------|----------|---------|
|
|
| `id` | ✅ | Unique identifier (also the plugin's on-disk directory name) |
|
|
| `name` | ✅ | Display name |
|
|
| `version` | ✅ | Semver |
|
|
| `type` | ✅ | One of `engine`, `storage`, `queue`, `auth`, `extension` |
|
|
| `main` | ✅ | Entry file, resolved **inside** the plugin directory (a path that escapes it is rejected) |
|
|
| `permissions` | — | Capability permissions this plugin declares; absent/empty = no capability access |
|
|
| `sessions` | — | Session ids this plugin may act on, or `['*']`. Absent = `['*']`. Static — editing config can't widen it |
|
|
| `sessionScoped` | — | Default `true`. A scoped plugin only sees events for the sessions it's activated for; `false` = always runs |
|
|
| `net.allow` | — | Outbound-HTTP host allowlist for `ctx.net.fetch` (`host`, `host:port`, or `'*'`). Absent = deny all |
|
|
| `configSchema` | — | Declarative config schema the dashboard renders into a form |
|
|
| `configUi` | — | Optional self-contained HTML config editor served into a sandboxed iframe (preferred over `configSchema` when present) |
|
|
| `hooks` | — | Hook events this plugin listens to (informational) |
|
|
| `provides` / `requires` | — | Features this plugin provides / depends on |
|
|
| `i18n` | — | Localized dashboard text per locale (dashboard-only) |
|
|
|
|
A `configSchema` field may set `secret: true` (e.g. an API key): the value is masked on read and
|
|
preserved on an unchanged write.
|
|
|
|
### Plugin Entry Point
|
|
|
|
A plugin's `main` module **default-exports a class** that implements `IPlugin`. All lifecycle methods
|
|
are optional and each receives only the `PluginContext` — there is no second `config` argument; config
|
|
is read from `ctx.config`.
|
|
|
|
```typescript
|
|
// plugins/my-plugin/index.ts
|
|
|
|
import type { IPlugin, PluginContext } from '@openwa/plugin-sdk'; // shape only; implement IPlugin
|
|
|
|
interface MyPluginConfig {
|
|
greeting: string;
|
|
apiKey?: string;
|
|
}
|
|
|
|
export default class MyAwesomePlugin implements IPlugin {
|
|
async onLoad(ctx: PluginContext): Promise<void> {
|
|
ctx.logger.log('Plugin loaded', { version: ctx.manifest.version });
|
|
|
|
// Register a hook handler. Handlers receive a HookContext and return a HookResult.
|
|
ctx.registerHook('message:received', async hookCtx => {
|
|
const cfg = ctx.config as MyPluginConfig; // per-session-merged config for this event
|
|
const { sessionId, data } = hookCtx;
|
|
const message = data as { from: string; body?: string };
|
|
|
|
if (message.body?.toLowerCase() === 'help' && sessionId) {
|
|
// Capability call — requires the 'messages:send' permission + an in-scope, live session.
|
|
await ctx.messages.sendText(sessionId, message.from, cfg.greeting ?? 'How can I help you?');
|
|
return { continue: false }; // stop the chain
|
|
}
|
|
return { continue: true };
|
|
});
|
|
}
|
|
|
|
async onEnable(_ctx: PluginContext): Promise<void> {}
|
|
async onDisable(_ctx: PluginContext): Promise<void> {}
|
|
async onUnload(ctx: PluginContext): Promise<void> {
|
|
ctx.logger.log('Plugin unloading');
|
|
}
|
|
|
|
async onConfigChange(ctx: PluginContext, newConfig: Record<string, unknown>): Promise<void> {
|
|
ctx.logger.log('Config changed', { keys: Object.keys(newConfig) });
|
|
}
|
|
|
|
async healthCheck(): Promise<{ healthy: boolean; message?: string }> {
|
|
return { healthy: true };
|
|
}
|
|
}
|
|
```
|
|
|
|
## 19.4 Plugin SDK
|
|
|
|
> Plugins implement the `IPlugin` interface directly. An `@openwa/plugin-sdk` npm package is planned
|
|
> but not yet published; the interfaces below are the live runtime contract from
|
|
> `src/core/plugins/plugin.interfaces.ts`.
|
|
|
|
### Core Interfaces
|
|
|
|
```typescript
|
|
// src/core/plugins/plugin.interfaces.ts
|
|
|
|
export interface IPlugin {
|
|
// All optional; each receives only the PluginContext.
|
|
onLoad?: (context: PluginContext) => Promise<void>;
|
|
onEnable?: (context: PluginContext) => Promise<void>;
|
|
onDisable?: (context: PluginContext) => Promise<void>;
|
|
onUnload?: (context: PluginContext) => Promise<void>;
|
|
onConfigChange?: (context: PluginContext, newConfig: Record<string, unknown>) => Promise<void>;
|
|
healthCheck?: () => Promise<{ healthy: boolean; message?: string }>;
|
|
}
|
|
|
|
export interface PluginContext {
|
|
pluginId: string;
|
|
manifest: PluginManifest;
|
|
|
|
// Effective config for the firing session (per-session override merged over the base). A getter,
|
|
// so it reflects live config edits; outside a hook it returns the base config.
|
|
config: Record<string, unknown>;
|
|
|
|
hookManager: HookManager;
|
|
logger: PluginLogger; // log / debug / warn / error
|
|
storage: PluginStorage; // get / set / delete / list — scoped to this plugin
|
|
|
|
// Register a hook handler (optionally with a priority; lower runs first).
|
|
registerHook: (event: HookEvent, handler: HookHandler, priority?: number) => void;
|
|
|
|
// Capability facade (permission- + scope-checked on each call):
|
|
messages: PluginMessagingCapability; // requires 'messages:send'
|
|
engine: PluginEngineReadCapability; // requires 'engine:read' (read-only)
|
|
net: PluginNetCapability; // requires 'net:fetch' (SSRF-guarded, net.allow-scoped)
|
|
}
|
|
|
|
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;
|
|
}
|
|
```
|
|
|
|
There is **no** `ctx.api`, `ctx.router`, or `ctx.events` — plugins do not mount HTTP routes or get an
|
|
event emitter. Hook handlers use the host `HookContext` / `HookResult` shape (see §19.5), not a
|
|
`{ continue, modified }` shape.
|
|
|
|
### Capability facade
|
|
|
|
A plugin reaches WhatsApp, the engine, and the network **only** through these three namespaces. Each
|
|
call is gated by the matching declared permission (and, for `messages`/`engine`, the session scope) —
|
|
a missing grant throws a `PluginCapabilityError`.
|
|
|
|
```typescript
|
|
// ctx.messages — requires 'messages:send'. Routes through MessageService (persistence preserved).
|
|
export interface PluginMessagingCapability {
|
|
sendText(sessionId: string, chatId: string, text: string): Promise<MessageResponseDto>;
|
|
reply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise<MessageResponseDto>;
|
|
}
|
|
|
|
// ctx.engine — requires 'engine:read'. Read-only, scoped engine queries.
|
|
export interface PluginEngineReadCapability {
|
|
getGroupInfo(sessionId: string, groupId: string): Promise<...>;
|
|
getContacts(sessionId: string): Promise<...>;
|
|
getContactById(sessionId: string, contactId: string): Promise<...>;
|
|
checkNumberExists(sessionId: string, phone: string): Promise<...>;
|
|
getChats(sessionId: string): Promise<...>;
|
|
}
|
|
|
|
// ctx.net — requires 'net:fetch'. Always through the host SSRF guard, scoped to manifest net.allow.
|
|
export interface PluginNetCapability {
|
|
fetch(url: string, init?: PluginNetRequestInit): Promise<PluginNetResponse>;
|
|
}
|
|
```
|
|
|
|
`ctx.net.fetch` returns a serializable response (`{ ok, status, statusText, headers, body }`, body as
|
|
a string) — no streaming and no live `Response` object, because it must cross the worker boundary via
|
|
structured clone. The request has a default 15 s / hard-cap 30 s timeout and a 10 MB response cap.
|
|
|
|
### Plugin Storage
|
|
|
|
```typescript
|
|
// Key-value storage, scoped to the plugin (values cross structuredClone, so keep them serializable).
|
|
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[]>;
|
|
}
|
|
```
|
|
|
|
## 19.5 Hook System
|
|
|
|
### Hook Lifecycle
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Core as OpenWA Core
|
|
participant HM as Hook Manager
|
|
participant P1 as Plugin 1
|
|
participant P2 as Plugin 2
|
|
participant WH as Webhook
|
|
|
|
Core->>HM: Message Received
|
|
HM->>P1: message:received
|
|
P1->>HM: {continue: true, data: msg}
|
|
HM->>P2: message:received (with P1's data)
|
|
P2->>HM: {continue: true}
|
|
HM->>Core: Continue processing
|
|
Core->>WH: Deliver webhook
|
|
```
|
|
|
|
A handler returns `{ continue: false }` to stop the chain. To transform the event it returns
|
|
`{ continue: true, data: <new value> }`; the next handler (and the host) sees that `data`. (There is
|
|
no `modified` field — the result type uses `data`.)
|
|
|
|
### Hook events
|
|
|
|
```typescript
|
|
// src/core/hooks/hook.interfaces.ts
|
|
|
|
export type HookEvent =
|
|
// Session lifecycle
|
|
| 'session:created'
|
|
| 'session:starting'
|
|
| 'session:ready'
|
|
| 'session:qr'
|
|
| 'session:disconnected'
|
|
| 'session:error'
|
|
| 'session:deleted'
|
|
// Message lifecycle
|
|
| 'message:received'
|
|
| 'message:sending'
|
|
| 'message:sent'
|
|
| 'message:failed'
|
|
| 'message:ack'
|
|
// Webhook lifecycle
|
|
| 'webhook:before'
|
|
| 'webhook:queued'
|
|
| 'webhook:delivered'
|
|
| 'webhook:after'
|
|
| 'webhook:error';
|
|
```
|
|
|
|
### Hook context and result
|
|
|
|
```typescript
|
|
export interface HookContext<T = unknown> {
|
|
event: HookEvent;
|
|
data: T; // the event payload (mutate via the returned HookResult.data)
|
|
sessionId?: string; // the session the event belongs to (used for activation + capability scope)
|
|
timestamp: Date;
|
|
source: string; // which service emitted this
|
|
}
|
|
|
|
export interface HookResult<T = unknown> {
|
|
continue: boolean; // false = stop the chain
|
|
data?: T; // replacement payload for the next handler (only applied when error is absent)
|
|
error?: Error; // an error to propagate; the handler's data mutation is discarded
|
|
}
|
|
|
|
export type HookHandler<T = unknown> = (ctx: HookContext<T>) => Promise<HookResult<T>>;
|
|
```
|
|
|
|
### Hook Manager behavior
|
|
|
|
`HookManager` (`src/core/hooks/hook-manager.service.ts`) is a NestJS provider. Handlers are stored per
|
|
event and run in **priority order** (lower `priority` first; default `100`). On `execute(event, data,
|
|
{ sessionId, source })` it walks the chain, threading each handler's returned `data` into the next; a
|
|
handler that returns `{ continue: false }` stops the chain. A handler that **throws** is logged and the
|
|
chain continues with the previous data (one bad plugin can't break the chain). Same-event re-entrancy
|
|
is blocked: a handler that re-fires the event it is handling is short-circuited (guards synchronous
|
|
re-entry only). Plugins never call `HookManager` directly — they use `ctx.registerHook(...)`, which
|
|
also applies the per-session activation gate.
|
|
|
|
## 19.6 Plugin Loader
|
|
|
|
`PluginLoaderService` (`src/core/plugins/plugin-loader.service.ts`) is the NestJS provider that
|
|
discovers, loads, and runs plugins.
|
|
|
|
**Discovery & load.** On `onModuleInit` it registers built-in plugins programmatically (the engine
|
|
adapters; see §19.7), then scans the plugins directory (`plugins.dir`, default `./plugins`). For each
|
|
sub-directory with a `manifest.json` it reads the manifest, validates the required fields
|
|
(`id`/`name`/`version`/`type`/`main`), and records an `INSTALLED` plugin plus a persisted registry
|
|
entry — **without running any plugin code**. Persisted config and per-session activation/config are
|
|
read back so an operator's choices survive a restart. There is **no** version-compatibility check.
|
|
|
|
> Loading a plugin from disk never auto-enables it. A previously-enabled plugin returns as `INSTALLED`
|
|
> after a restart and must be re-enabled — enabling is always an explicit ADMIN action.
|
|
|
|
**Enable.** `enablePlugin(id)` runs the lifecycle by trust tier (a synchronous lock prevents a racing
|
|
double-enable, and engines must match the configured active engine):
|
|
|
|
- **Built-in (trusted)** → `enableInProcess`: `require()` the `main` module (path-contained to the
|
|
plugin dir), instantiate the default-exported class, and run `onLoad` then `onEnable` in-process with
|
|
the live capability context.
|
|
- **Untrusted (disk-loaded)** → `enableSandboxed`: spawn a `worker_thread`, load the module there, and
|
|
drive `onLoad`/`onEnable` over the channel. Capability calls and hook dispatches round-trip to the
|
|
host, which runs the **same** permission + session-scope checks. Lifecycle calls are bounded by a
|
|
30 s timeout and hooks by a 5 s timeout; a failure tears the worker back down. See
|
|
[23 — Plugin Sandboxing](./23-plugin-sandboxing.md).
|
|
|
|
**Disable / unload / uninstall.** `disablePlugin` runs `onDisable` (force-terminating the worker for a
|
|
sandboxed plugin, even if `onDisable` hangs or throws) and unregisters the plugin's hooks. `onModuleDestroy`
|
|
disables every enabled plugin on graceful shutdown so stateful plugins can flush. `uninstallPlugin`
|
|
disables + unloads, drops the registry entry, and deletes the plugin's directory (built-ins are
|
|
protected and cannot be uninstalled).
|
|
|
|
**Context.** `createPluginContext` builds the `PluginContext` (§19.4): a per-plugin logger, plugin-scoped
|
|
storage, `registerHook` (wrapped with the per-session activation gate), the live `config` getter, and
|
|
the permission-checked `messages` / `engine` / `net` capabilities. The capability methods call
|
|
`assertPermission` (and, for messages/engine, `resolveEngine` → `assertSessionAllowed`) before doing any
|
|
work, so a missing grant or out-of-scope session fails fast with a `PluginCapabilityError`.
|
|
|
|
> ⚠️ **`ctx.storage` is plugin-scoped, not per-session — unlike `ctx.config`.** `ctx.config` is merged
|
|
> automatically for the firing session, but `ctx.storage` is a single namespace shared across **all** of a
|
|
> plugin's sessions. A multi-session plugin that keys state by a fixed string (mirroring the per-session
|
|
> config model) will have one session overwrite another's. Prefix storage keys with the session id (e.g.
|
|
> `ctx.storage.set(\`${sessionId}:lastSeen\`, …)`) whenever state must be isolated per session.
|
|
|
|
## 19.7 Built-in Plugins
|
|
|
|
The only plugins **shipped** as built-ins are the two WhatsApp **engine adapters**, registered
|
|
programmatically (not loaded from disk) by `EngineFactory` via `registerBuiltInPlugin`:
|
|
|
|
| Plugin id | Type | Notes |
|
|
|-----------|------|-------|
|
|
| `whatsapp-web.js` | `engine` | Default engine adapter (browser-based) |
|
|
| `baileys` | `engine` | WebSocket engine adapter (no browser) |
|
|
|
|
Engines are mutually exclusive: the active one is pinned by `engine.type` config, and `enablePlugin`
|
|
rejects any engine that is not the configured active one. Built-ins run **in-process** (trusted) — they
|
|
are not sandboxed. There is no `auto-reply` / `translation` plugin bundled in the source tree today.
|
|
|
|
### Example: a hook-based message plugin
|
|
|
|
A disk-installed extension implements `IPlugin`, registers hooks in `onLoad`, and uses the capability
|
|
facade. This auto-reply sketch needs only `messages:send` in its manifest `permissions`:
|
|
|
|
```typescript
|
|
// plugins/auto-reply/index.ts
|
|
import type { IPlugin, PluginContext } from '@openwa/plugin-sdk'; // shape only; implement IPlugin
|
|
|
|
interface AutoReplyConfig {
|
|
enabled?: boolean;
|
|
rules?: { match: string; reply: string }[];
|
|
}
|
|
|
|
export default class AutoReplyPlugin implements IPlugin {
|
|
async onLoad(ctx: PluginContext): Promise<void> {
|
|
ctx.registerHook('message:received', async hookCtx => {
|
|
const cfg = ctx.config as AutoReplyConfig; // per-session-merged config for this event
|
|
const { sessionId, data } = hookCtx;
|
|
const message = data as { from: string; body?: string };
|
|
if (cfg.enabled === false || !sessionId || !message.body) return { continue: true };
|
|
|
|
const text = message.body.toLowerCase();
|
|
const rule = (cfg.rules ?? []).find(r => text.includes(r.match.toLowerCase()));
|
|
if (rule) {
|
|
await ctx.messages.sendText(sessionId, message.from, rule.reply);
|
|
await ctx.storage.set(`stats:replies:${Date.now()}`, rule.match);
|
|
}
|
|
return { continue: true }; // keep the chain going (still delivers the webhook)
|
|
});
|
|
|
|
ctx.logger.log('Auto-reply plugin loaded');
|
|
}
|
|
}
|
|
```
|
|
|
|
A plugin that calls an external API instead would declare `net:fetch` plus the host in `net.allow`, and
|
|
use `await ctx.net.fetch('https://api.example.com/...', { method: 'POST', body, headers })` — the
|
|
SSRF-guarded outbound HTTP capability. A read-only plugin (e.g. enriching events with group/contact
|
|
data) would declare `engine:read` and call `ctx.engine.getGroupInfo(...)` / `ctx.engine.getContacts(...)`.
|
|
|
|
## 19.8 Plugin Management API
|
|
|
|
`PluginsController` (`src/modules/plugins/plugins.controller.ts`) is mounted at `plugins` and **every
|
|
route requires the `ADMIN` role** (`@RequireRole(ApiKeyRole.ADMIN)` — not a bare API-key guard). There
|
|
is **no** `POST :id/reload` and **no** `GET :id/config`. Install is a multipart `.zip` upload (or by
|
|
URL / catalog), not an npm/github source descriptor.
|
|
|
|
| Method & path | Purpose |
|
|
|---------------|---------|
|
|
| `GET /plugins` | List all plugins |
|
|
| `GET /plugins/catalog` | List the remote plugin catalog, annotated with install state |
|
|
| `GET /plugins/:id` | Get a single plugin |
|
|
| `POST /plugins/install` | Install from an uploaded `.zip` (`multipart/form-data`, field `file`, ≤ 5 MB) |
|
|
| `POST /plugins/install-url` | Install by downloading a `.zip` from a URL (SSRF-guarded) |
|
|
| `POST /plugins/:id/update` | Update an installed plugin in place from a URL (preserves config + enabled state) |
|
|
| `POST /plugins/:id/enable` | Enable a plugin |
|
|
| `POST /plugins/:id/disable` | Disable a plugin |
|
|
| `PUT /plugins/:id/config` | Update the plugin's base config |
|
|
| `PUT /plugins/:id/config/:sessionId` | Set (or clear, when empty) a per-session config override |
|
|
| `PUT /plugins/:id/sessions` | Set which sessions a session-scoped plugin is activated for (`['*']` = all) |
|
|
| `GET /plugins/:id/config-ui` | Serve the plugin's sandboxed config-UI HTML (for an iframe `srcdoc`) |
|
|
| `GET /plugins/:id/health` | Run the plugin's `healthCheck` |
|
|
| `DELETE /plugins/:id` | Uninstall a plugin (removes its files; built-ins are protected) |
|
|
|
|
> `GET /plugins/:id/config-ui` returns untrusted HTML served with `Content-Security-Policy: sandbox`
|
|
> and `X-Content-Type-Options: nosniff`. The dashboard fetches it **with** the API key and injects the
|
|
> body as an iframe `srcdoc` (opaque origin); the editor exchanges config over a `postMessage` bridge,
|
|
> so the API key never reaches the iframe.
|
|
|
|
## 19.9 Plugin Security
|
|
|
|
### Permission model
|
|
|
|
There are exactly **three** capability permissions, declared in the manifest `permissions` array and
|
|
enforced at the capability boundary:
|
|
|
|
```typescript
|
|
// src/core/plugins/plugin.interfaces.ts
|
|
|
|
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',
|
|
} as const;
|
|
```
|
|
|
|
There is no `PermissionChecker` class and no `PermissionDeniedError`. Enforcement lives in the loader:
|
|
each capability method calls `assertPermission(manifest, permission)` before doing any work, and a
|
|
plugin whose manifest does not declare the matching permission (or declares none) is rejected with a
|
|
`PluginCapabilityError`:
|
|
|
|
```typescript
|
|
// src/core/plugins/plugin-loader.service.ts (paraphrased)
|
|
|
|
private assertPermission(manifest: PluginManifest, permission: PluginCapabilityPermission): void {
|
|
if (!(manifest.permissions ?? []).includes(permission)) {
|
|
throw new PluginCapabilityError(
|
|
`Plugin ${manifest.id} is missing the '${permission}' permission required for this capability`,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
Two further checks apply on top of the permission:
|
|
|
|
- **Session scope.** `messages` and `engine` calls run `assertSessionAllowed(manifest, sessionId)` —
|
|
the plugin may only act on sessions in its manifest `sessions` list (`['*']` = all). The `sessionId`
|
|
comes from the plugin, so this is the security boundary; it is static (editing config can't widen it).
|
|
- **Network allowlist.** `ctx.net.fetch` additionally requires the target host to be in `manifest.net.allow`,
|
|
and the request always passes through the SSRF guard (which blocks internal IPs even for an allowlisted host).
|
|
|
|
### Sandboxed execution
|
|
|
|
Untrusted plugins (anything loaded from the plugins directory) run in a Node `worker_thread`; there is
|
|
**no `vm2`**. First-party built-ins (the engine adapters) run in-process. The loader routes by trust
|
|
tier automatically. Key properties:
|
|
|
|
- Each worker has a heap cap (`maxOldGenerationSizeMb`, default **256 MB**) — an OOM terminates the
|
|
worker, not the host.
|
|
- A sandboxed hook handler has a **5 s** time budget (`SANDBOX_HOOK_TIMEOUT_MS`); on timeout the host
|
|
resolves `{ continue: true }` (fail-open) so a slow/wedged handler never stalls the hook chain. The
|
|
same fail-open value drains in-flight hooks if the worker crashes.
|
|
- Lifecycle methods (`load`/`onLoad`/`onEnable`/`onDisable`) and `healthCheck` are bounded by a **30 s**
|
|
/ **5 s** timeout respectively, so a wedged plugin can't hang an ADMIN enable/disable or the health endpoint.
|
|
- The worker gets a **minimal allowlisted env** (`NODE_ENV`, `NODE_EXTRA_CA_CERTS`, `TZ`) — host secrets
|
|
(master key, DB/Redis vars, …) are withheld.
|
|
|
|
This is V8-context isolation in the same OS process, not an OS-level sandbox: a worker can still reach
|
|
Node built-ins (`fs`, `process`, sockets). For genuinely untrusted plugins, combine it with OS
|
|
containment (the shipped Docker image runs read-only rootfs, non-root, `cap_drop: ALL`). See
|
|
[23 — Plugin Sandboxing](./23-plugin-sandboxing.md) for the full security model and author rules.
|
|
|
|
---
|
|
|
|
<div align="center">
|
|
|
|
[← 18 - SDK Design](./18-sdk-design.md) · [Documentation Index](./README.md) · [Next: 20 - Community Guidelines →](./20-community-guidelines.md)
|
|
|
|
</div>
|