Current Status:
⚠️ Partially ImplementedThe core plugin infrastructure is functional. Advanced features are planned for future releases.
| 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 | load, enable, disable, unload |
| Dashboard UI | ✅ Implemented | dashboard/src/pages/Plugins.tsx |
| REST API | ✅ Implemented | src/modules/plugins/plugins.controller.ts |
| Component | Status | Notes |
|---|---|---|
| @openwa/plugin-sdk | 🔜 Planned | NPM package not yet published |
| Sandboxed execution | 🔜 Planned | vm2 isolation not implemented |
| Permission enforcement | Defined in manifest, not enforced | |
| Built-in plugins | 🔜 Planned | Auto-reply, Translation examples |
| Plugin marketplace | 🔜 Planned | Install from npm/github |
The plugin architecture enables OpenWA extensibility without modifying the core codebase. Plugins can add new features, integrate with external services, or customize behavior.
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
- Isolation - Plugins cannot compromise the core system
- Extensibility - Easy to add new features
- Safety - Permission-based access control
- Performance - Lazy loading, minimal overhead
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 |
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
{
"name": "my-awesome-plugin",
"version": "1.0.0",
"description": "An awesome plugin for OpenWA",
"author": "Your Name",
"license": "MIT",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"openwa": {
"minVersion": "0.2.0",
"maxVersion": "2.0.0"
},
"permissions": [
"messages:read",
"messages:write",
"contacts:read",
"storage:read",
"storage:write",
"http:outbound"
],
"hooks": [
"message:received",
"message:sending",
"session:connected",
"webhook:before"
],
"config": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"apiKey": {
"type": "string",
"secret": true
},
"options": {
"type": "object",
"properties": {
"autoReply": { "type": "boolean", "default": false },
"language": { "type": "string", "default": "id" }
}
}
}
}
}
}// plugins/my-plugin/index.ts
import { OpenWAPlugin, PluginContext, MessageEvent, HookResult } from '@openwa/plugin-sdk';
export interface MyPluginConfig {
enabled: boolean;
apiKey: string;
options: {
autoReply: boolean;
language: string;
};
}
export default class MyAwesomePlugin implements OpenWAPlugin<MyPluginConfig> {
name = 'my-awesome-plugin';
version = '1.0.0';
private ctx!: PluginContext;
private config!: MyPluginConfig;
/**
* Called when plugin is loaded
*/
async onLoad(ctx: PluginContext, config: MyPluginConfig): Promise<void> {
this.ctx = ctx;
this.config = config;
ctx.logger.info('Plugin loaded', { version: this.version });
// Register message handler
ctx.hooks.on('message:received', this.handleIncomingMessage.bind(this));
// Register API routes (optional)
ctx.router.get('/my-plugin/status', this.getStatus.bind(this));
ctx.router.post('/my-plugin/action', this.doAction.bind(this));
}
/**
* Called when plugin is unloaded
*/
async onUnload(): Promise<void> {
this.ctx.logger.info('Plugin unloading');
// Cleanup resources
}
/**
* Handle incoming message
*/
async handleIncomingMessage(event: MessageEvent): Promise<HookResult> {
if (!this.config.enabled) {
return { continue: true };
}
const { message, session } = event;
// Example: Auto-reply to specific keyword
if (this.config.options.autoReply && message.body?.toLowerCase() === 'help') {
await this.ctx.api.messages.send(session.id, {
phone: message.from,
type: 'text',
body: 'How can I help you?',
});
// Stop processing (don't trigger webhook)
return { continue: false };
}
// Continue to next handler/webhook
return { continue: true };
}
/**
* Custom API endpoint
*/
async getStatus(req: Request, res: Response): Promise<void> {
res.json({
plugin: this.name,
version: this.version,
enabled: this.config.enabled,
stats: await this.getStats(),
});
}
async doAction(req: Request, res: Response): Promise<void> {
// Perform action
res.json({ success: true });
}
private async getStats(): Promise<object> {
// Get plugin stats from storage
return this.ctx.storage.get('stats') || {};
}
}// @openwa/plugin-sdk/types.ts
export interface OpenWAPlugin<TConfig = any> {
name: string;
version: string;
onLoad(ctx: PluginContext, config: TConfig): Promise<void>;
onUnload(): Promise<void>;
}
export interface PluginContext {
// Logging
logger: Logger;
// Hook registration
hooks: HookRegistry;
// API access (permission-based)
api: PluginAPI;
// Plugin storage
storage: PluginStorage;
// HTTP router for custom endpoints
router: PluginRouter;
// Configuration
config: ConfigService;
// Event emitter
events: EventEmitter;
}
export interface Logger {
debug(message: string, meta?: object): void;
info(message: string, meta?: object): void;
warn(message: string, meta?: object): void;
error(message: string, meta?: object): void;
}
export interface HookRegistry {
on(event: HookEvent, handler: HookHandler): void;
off(event: HookEvent, handler: HookHandler): void;
once(event: HookEvent, handler: HookHandler): void;
}
export type HookEvent =
| 'message:received'
| 'message:sending'
| 'message:sent'
| 'message:failed'
| 'session:created'
| 'session:connected'
| 'session:disconnected'
| 'session:deleted'
| 'webhook:before'
| 'webhook:after'
| 'webhook:error';
export type HookHandler<T = any> = (event: T) => Promise<HookResult>;
export interface HookResult {
continue: boolean; // Whether to continue to next handler
modified?: any; // Modified event data
error?: Error; // Error to stop processing
}// @openwa/plugin-sdk/api.ts
export interface PluginAPI {
// Sessions (requires: sessions:read/write)
sessions: {
list(): Promise<Session[]>;
get(id: string): Promise<Session>;
getStatus(id: string): Promise<SessionStatus>;
};
// Messages (requires: messages:read/write)
messages: {
send(sessionId: string, input: SendMessageInput): Promise<Message>;
get(sessionId: string, messageId: string): Promise<Message>;
list(sessionId: string, phone: string): Promise<Message[]>;
};
// Contacts (requires: contacts:read/write)
contacts: {
list(sessionId: string): Promise<Contact[]>;
get(sessionId: string, phone: string): Promise<Contact>;
exists(sessionId: string, phone: string): Promise<boolean>;
};
// Groups (requires: groups:read/write)
groups: {
list(sessionId: string): Promise<Group[]>;
get(sessionId: string, groupId: string): Promise<Group>;
getParticipants(sessionId: string, groupId: string): Promise<Participant[]>;
};
// HTTP (requires: http:outbound)
http: {
get(url: string, options?: HttpOptions): Promise<HttpResponse>;
post(url: string, body: any, options?: HttpOptions): Promise<HttpResponse>;
put(url: string, body: any, options?: HttpOptions): Promise<HttpResponse>;
delete(url: string, options?: HttpOptions): Promise<HttpResponse>;
};
}// @openwa/plugin-sdk/storage.ts
export interface PluginStorage {
// Key-value storage (scoped to plugin)
get<T = any>(key: string): Promise<T | null>;
set<T = any>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<void>;
has(key: string): Promise<boolean>;
// List operations
keys(pattern?: string): Promise<string[]>;
clear(): Promise<void>;
// Atomic operations
increment(key: string, by?: number): Promise<number>;
decrement(key: string, by?: number): Promise<number>;
}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, modified: msg}
HM->>P2: message:received (modified)
P2->>HM: {continue: true}
HM->>Core: Continue processing
Core->>WH: Deliver webhook
// src/core/hooks/hook-manager.ts
export class HookManager {
private hooks: Map<HookEvent, HookHandler[]> = new Map();
private pluginHooks: Map<string, Map<HookEvent, HookHandler[]>> = new Map();
/**
* Register hook handler for a plugin
*/
register(pluginId: string, event: HookEvent, handler: HookHandler): void {
// Get plugin's hooks map
if (!this.pluginHooks.has(pluginId)) {
this.pluginHooks.set(pluginId, new Map());
}
const pluginMap = this.pluginHooks.get(pluginId)!;
if (!pluginMap.has(event)) {
pluginMap.set(event, []);
}
pluginMap.get(event)!.push(handler);
// Add to global hooks
if (!this.hooks.has(event)) {
this.hooks.set(event, []);
}
this.hooks.get(event)!.push(handler);
}
/**
* Unregister all hooks for a plugin
*/
unregisterPlugin(pluginId: string): void {
const pluginMap = this.pluginHooks.get(pluginId);
if (!pluginMap) return;
// Remove from global hooks
for (const [event, handlers] of pluginMap.entries()) {
const globalHandlers = this.hooks.get(event);
if (globalHandlers) {
const filtered = globalHandlers.filter(h => !handlers.includes(h));
this.hooks.set(event, filtered);
}
}
this.pluginHooks.delete(pluginId);
}
/**
* Execute hooks for an event
*/
async execute<T>(event: HookEvent, data: T): Promise<HookExecutionResult<T>> {
const handlers = this.hooks.get(event) || [];
let currentData = data;
let shouldContinue = true;
for (const handler of handlers) {
try {
const result = await handler(currentData);
if (result.modified) {
currentData = result.modified;
}
if (!result.continue) {
shouldContinue = false;
break;
}
if (result.error) {
throw result.error;
}
} catch (error) {
// Log error but continue to next handler
console.error(`Hook handler error for ${event}:`, error);
}
}
return {
data: currentData,
shouldContinue,
handlersExecuted: handlers.length,
};
}
}
export interface HookExecutionResult<T> {
data: T;
shouldContinue: boolean;
handlersExecuted: number;
}// src/core/plugins/plugin-loader.ts
import { OpenWAPlugin, PluginContext, PluginManifest } from './types';
import { HookManager } from '../hooks/hook-manager';
import { PluginSandbox } from './plugin-sandbox';
import { PluginStorageImpl } from './plugin-storage';
import { PluginAPIImpl } from './plugin-api';
export class PluginLoader {
private plugins: Map<string, LoadedPlugin> = new Map();
private hookManager: HookManager;
constructor(
private pluginDir: string,
hookManager: HookManager,
) {
this.hookManager = hookManager;
}
/**
* Discover and load all plugins
*/
async loadAll(): Promise<void> {
const pluginDirs = await this.discoverPlugins();
for (const dir of pluginDirs) {
try {
await this.load(dir);
} catch (error) {
console.error(`Failed to load plugin from ${dir}:`, error);
}
}
}
/**
* Load a single plugin
*/
async load(pluginPath: string): Promise<void> {
// 1. Read manifest
const manifest = await this.readManifest(pluginPath);
// 2. Validate manifest
this.validateManifest(manifest);
// 3. Check version compatibility
this.checkVersionCompatibility(manifest);
// 4. Check permissions
await this.checkPermissions(manifest);
// 5. Load plugin module
const PluginClass = await this.loadModule(pluginPath, manifest);
// 6. Create plugin instance
const plugin = new PluginClass();
// 7. Create plugin context
const ctx = this.createContext(manifest);
// 8. Load configuration
const config = await this.loadConfig(manifest);
// 9. Initialize plugin
await plugin.onLoad(ctx, config);
// 10. Store loaded plugin
this.plugins.set(manifest.name, {
instance: plugin,
manifest,
context: ctx,
config,
});
console.log(`Plugin loaded: ${manifest.name}@${manifest.version}`);
}
/**
* Unload a plugin
*/
async unload(pluginName: string): Promise<void> {
const loaded = this.plugins.get(pluginName);
if (!loaded) return;
// Call plugin cleanup
await loaded.instance.onUnload();
// Unregister hooks
this.hookManager.unregisterPlugin(pluginName);
// Remove from loaded plugins
this.plugins.delete(pluginName);
console.log(`Plugin unloaded: ${pluginName}`);
}
/**
* Reload a plugin
*/
async reload(pluginName: string): Promise<void> {
const loaded = this.plugins.get(pluginName);
if (!loaded) {
throw new Error(`Plugin ${pluginName} not loaded`);
}
const pluginPath = loaded.manifest._path;
await this.unload(pluginName);
await this.load(pluginPath);
}
/**
* Create plugin context with permissions
*/
private createContext(manifest: PluginManifest): PluginContext {
const permissions = new Set(manifest.permissions);
return {
logger: this.createLogger(manifest.name),
hooks: this.createHookRegistry(manifest.name),
api: new PluginAPIImpl(permissions),
storage: new PluginStorageImpl(manifest.name),
router: this.createRouter(manifest.name),
config: this.createConfigService(manifest),
events: this.createEventEmitter(manifest.name),
};
}
private createHookRegistry(pluginId: string) {
return {
on: (event: HookEvent, handler: HookHandler) => {
this.hookManager.register(pluginId, event, handler);
},
off: (event: HookEvent, handler: HookHandler) => {
// Implementation
},
once: (event: HookEvent, handler: HookHandler) => {
// Implementation
},
};
}
private async discoverPlugins(): Promise<string[]> {
const fs = require('fs').promises;
const path = require('path');
const entries = await fs.readdir(this.pluginDir, { withFileTypes: true });
const dirs = entries
.filter((e: any) => e.isDirectory())
.map((e: any) => path.join(this.pluginDir, e.name));
return dirs.filter(async (dir: string) => {
try {
await fs.access(path.join(dir, 'manifest.json'));
return true;
} catch {
return false;
}
});
}
// ... other helper methods
}
interface LoadedPlugin {
instance: OpenWAPlugin;
manifest: PluginManifest;
context: PluginContext;
config: any;
}// plugins/auto-reply/index.ts
import { OpenWAPlugin, PluginContext, MessageEvent } from '@openwa/plugin-sdk';
interface AutoReplyConfig {
enabled: boolean;
rules: AutoReplyRule[];
defaultReply?: string;
}
interface AutoReplyRule {
trigger: string | RegExp;
reply: string;
caseSensitive?: boolean;
matchType: 'exact' | 'contains' | 'regex' | 'startsWith';
}
export default class AutoReplyPlugin implements OpenWAPlugin<AutoReplyConfig> {
name = 'auto-reply';
version = '1.0.0';
private ctx!: PluginContext;
private config!: AutoReplyConfig;
private compiledRules: CompiledRule[] = [];
async onLoad(ctx: PluginContext, config: AutoReplyConfig): Promise<void> {
this.ctx = ctx;
this.config = config;
// Compile rules for faster matching
this.compiledRules = this.compileRules(config.rules);
// Register hook
ctx.hooks.on('message:received', this.handleMessage.bind(this));
ctx.logger.info('Auto-reply plugin loaded', {
rulesCount: this.compiledRules.length,
});
}
async onUnload(): Promise<void> {
this.ctx.logger.info('Auto-reply plugin unloaded');
}
private compileRules(rules: AutoReplyRule[]): CompiledRule[] {
return rules.map(rule => ({
...rule,
matcher: this.createMatcher(rule),
}));
}
private createMatcher(rule: AutoReplyRule): (text: string) => boolean {
const trigger = rule.caseSensitive ? rule.trigger : String(rule.trigger).toLowerCase();
switch (rule.matchType) {
case 'exact':
return (text) => text === trigger;
case 'contains':
return (text) => text.includes(trigger as string);
case 'startsWith':
return (text) => text.startsWith(trigger as string);
case 'regex':
const regex = new RegExp(trigger as string, rule.caseSensitive ? '' : 'i');
return (text) => regex.test(text);
default:
return () => false;
}
}
async handleMessage(event: MessageEvent): Promise<HookResult> {
if (!this.config.enabled) {
return { continue: true };
}
const { message, session } = event;
const text = this.config.rules[0]?.caseSensitive
? message.body || ''
: (message.body || '').toLowerCase();
// Find matching rule
const matchedRule = this.compiledRules.find(rule => rule.matcher(text));
if (matchedRule) {
await this.sendReply(session.id, message.from, matchedRule.reply);
// Track stats
await this.ctx.storage.increment('stats:replies');
return { continue: true }; // Still trigger webhook
}
// Default reply if configured
if (this.config.defaultReply) {
await this.sendReply(session.id, message.from, this.config.defaultReply);
}
return { continue: true };
}
private async sendReply(sessionId: string, to: string, reply: string): Promise<void> {
await this.ctx.api.messages.send(sessionId, {
phone: to,
type: 'text',
body: reply,
});
}
}
interface CompiledRule extends AutoReplyRule {
matcher: (text: string) => boolean;
}// plugins/translation/index.ts
import { OpenWAPlugin, PluginContext, MessageEvent } from '@openwa/plugin-sdk';
interface TranslationConfig {
enabled: boolean;
provider: 'google' | 'deepl' | 'libre';
apiKey?: string;
sourceLanguage: 'auto' | string;
targetLanguage: string;
translateIncoming: boolean;
translateOutgoing: boolean;
}
export default class TranslationPlugin implements OpenWAPlugin<TranslationConfig> {
name = 'translation';
version = '1.0.0';
private ctx!: PluginContext;
private config!: TranslationConfig;
async onLoad(ctx: PluginContext, config: TranslationConfig): Promise<void> {
this.ctx = ctx;
this.config = config;
if (config.translateIncoming) {
ctx.hooks.on('message:received', this.translateIncoming.bind(this));
}
if (config.translateOutgoing) {
ctx.hooks.on('message:sending', this.translateOutgoing.bind(this));
}
}
async onUnload(): Promise<void> {}
async translateIncoming(event: MessageEvent): Promise<HookResult> {
if (!this.config.enabled || !event.message.body) {
return { continue: true };
}
try {
const translated = await this.translate(
event.message.body,
this.config.sourceLanguage,
this.config.targetLanguage
);
// Add translation to message metadata
const modified = {
...event,
message: {
...event.message,
metadata: {
...event.message.metadata,
originalText: event.message.body,
translatedText: translated,
translatedFrom: this.config.sourceLanguage,
translatedTo: this.config.targetLanguage,
},
},
};
return { continue: true, modified };
} catch (error) {
this.ctx.logger.error('Translation failed', { error });
return { continue: true };
}
}
async translateOutgoing(event: MessageEvent): Promise<HookResult> {
// Similar implementation for outgoing messages
return { continue: true };
}
private async translate(
text: string,
from: string,
to: string
): Promise<string> {
switch (this.config.provider) {
case 'google':
return this.translateWithGoogle(text, from, to);
case 'deepl':
return this.translateWithDeepL(text, from, to);
case 'libre':
return this.translateWithLibre(text, from, to);
default:
throw new Error(`Unknown provider: ${this.config.provider}`);
}
}
private async translateWithGoogle(
text: string,
from: string,
to: string
): Promise<string> {
const response = await this.ctx.api.http.post(
'https://translation.googleapis.com/language/translate/v2',
{
q: text,
source: from === 'auto' ? undefined : from,
target: to,
key: this.config.apiKey,
}
);
return response.data.translations[0].translatedText;
}
// ... other provider implementations
}// src/api/plugins/plugins.controller.ts
@Controller('api/plugins')
@UseGuards(ApiKeyGuard)
export class PluginsController {
constructor(private pluginService: PluginService) {}
@Get()
async listPlugins(): Promise<PluginInfo[]> {
return this.pluginService.listAll();
}
@Get(':id')
async getPlugin(@Param('id') id: string): Promise<PluginInfo> {
return this.pluginService.get(id);
}
@Post(':id/enable')
async enablePlugin(@Param('id') id: string): Promise<void> {
await this.pluginService.enable(id);
}
@Post(':id/disable')
async disablePlugin(@Param('id') id: string): Promise<void> {
await this.pluginService.disable(id);
}
@Post(':id/reload')
async reloadPlugin(@Param('id') id: string): Promise<void> {
await this.pluginService.reload(id);
}
@Get(':id/config')
async getPluginConfig(@Param('id') id: string): Promise<any> {
return this.pluginService.getConfig(id);
}
@Put(':id/config')
async updatePluginConfig(
@Param('id') id: string,
@Body() config: any
): Promise<void> {
await this.pluginService.updateConfig(id, config);
}
@Post('install')
async installPlugin(@Body() input: InstallPluginInput): Promise<PluginInfo> {
return this.pluginService.install(input);
}
@Delete(':id')
async uninstallPlugin(@Param('id') id: string): Promise<void> {
await this.pluginService.uninstall(id);
}
}
interface PluginInfo {
id: string;
name: string;
version: string;
description: string;
author: string;
enabled: boolean;
permissions: string[];
config: any;
}
interface InstallPluginInput {
source: 'npm' | 'github' | 'local';
package: string; // npm package name, github repo, or local path
}// src/core/plugins/permissions.ts
export const PERMISSIONS = {
// Sessions
'sessions:read': 'Read session information',
'sessions:write': 'Create/delete sessions',
// Messages
'messages:read': 'Read messages',
'messages:write': 'Send messages',
// Contacts
'contacts:read': 'Read contacts',
'contacts:write': 'Block/unblock contacts',
// Groups
'groups:read': 'Read group information',
'groups:write': 'Manage groups',
// Storage
'storage:read': 'Read plugin storage',
'storage:write': 'Write to plugin storage',
// HTTP
'http:outbound': 'Make outbound HTTP requests',
// System
'system:info': 'Read system information',
'system:config': 'Read/write system config',
} as const;
export type Permission = keyof typeof PERMISSIONS;
export class PermissionChecker {
constructor(private allowedPermissions: Set<Permission>) {}
check(required: Permission): boolean {
return this.allowedPermissions.has(required);
}
require(required: Permission): void {
if (!this.check(required)) {
throw new PermissionDeniedError(required);
}
}
}
export class PermissionDeniedError extends Error {
constructor(permission: Permission) {
super(`Permission denied: ${permission}`);
this.name = 'PermissionDeniedError';
}
}// src/core/plugins/sandbox.ts
import { VM } from 'vm2';
export class PluginSandbox {
private vm: VM;
constructor(permissions: Set<Permission>) {
this.vm = new VM({
timeout: 10000, // 10 second timeout
sandbox: {
// Only expose allowed APIs
console: this.createSafeConsole(),
setTimeout: this.createSafeTimeout(),
setInterval: this.createSafeInterval(),
// ... other safe APIs
},
eval: false,
wasm: false,
});
}
async execute(code: string, context: object): Promise<any> {
return this.vm.run(code);
}
private createSafeConsole() {
return {
log: (...args: any[]) => console.log('[Plugin]', ...args),
error: (...args: any[]) => console.error('[Plugin]', ...args),
warn: (...args: any[]) => console.warn('[Plugin]', ...args),
};
}
private createSafeTimeout() {
return (fn: Function, ms: number) => {
if (ms > 30000) ms = 30000; // Max 30 seconds
return setTimeout(fn, ms);
};
}
private createSafeInterval() {
return (fn: Function, ms: number) => {
if (ms < 1000) ms = 1000; // Min 1 second
return setInterval(fn, ms);
};
}
}