From d62175640c83ef6577d00b0e7ec8c89e78fdf2ae Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 16:11:44 +0700 Subject: [PATCH 01/10] feat(hooks): add AsyncLocalStorage re-entrancy guard to HookManager --- src/core/hooks/hook-manager.service.spec.ts | 43 ++++++++++++++++++++- src/core/hooks/hook-manager.service.ts | 27 +++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/core/hooks/hook-manager.service.spec.ts b/src/core/hooks/hook-manager.service.spec.ts index 8be56cca..8725dd5e 100644 --- a/src/core/hooks/hook-manager.service.spec.ts +++ b/src/core/hooks/hook-manager.service.spec.ts @@ -2,6 +2,7 @@ // so the no-await rule doesn't apply to them here. /* eslint-disable @typescript-eslint/require-await */ import { HookManager } from './hook-manager.service'; +import { HookContext, HookResult } from './hook.interfaces'; describe('HookManager', () => { let hm: HookManager; @@ -105,7 +106,7 @@ describe('HookManager', () => { expect(hm.hasHooks('session:created')).toBe(false); }); - it('unregisterPlugin removes only that plugin’s hooks', () => { + it("unregisterPlugin removes only that plugin's hooks", () => { hm.register('A', 'session:ready', async ctx => ({ continue: true, data: ctx.data })); hm.register('A', 'session:ready', async ctx => ({ continue: true, data: ctx.data })); hm.register('B', 'session:ready', async ctx => ({ continue: true, data: ctx.data })); @@ -114,3 +115,43 @@ describe('HookManager', () => { expect(hm.getHookCount('session:ready')).toBe(1); // only B remains }); }); + +describe('HookManager re-entrancy guard', () => { + let manager: HookManager; + + beforeEach(() => { + manager = new HookManager(); + }); + + it('short-circuits a handler that re-fires the same event (no infinite recursion)', async () => { + let calls = 0; + manager.register('p1', 'message:sending', async (ctx: HookContext): Promise => { + calls += 1; + const inner = await manager.execute('message:sending', ctx.data, { source: 'test' }); + expect(inner).toEqual({ continue: true, data: ctx.data }); + return { continue: true }; + }); + + const result = await manager.execute('message:sending', { n: 1 }, { source: 'test' }); + + expect(calls).toBe(1); + expect(result.continue).toBe(true); + }); + + it('does NOT block a handler that fires a DIFFERENT event', async () => { + const seen: string[] = []; + manager.register('p1', 'message:received', async (ctx: HookContext): Promise => { + seen.push('received'); + await manager.execute('message:sent', ctx.data, { source: 'test' }); + return { continue: true }; + }); + manager.register('p2', 'message:sent', async (): Promise => { + seen.push('sent'); + return { continue: true }; + }); + + await manager.execute('message:received', { n: 1 }, { source: 'test' }); + + expect(seen).toEqual(['received', 'sent']); + }); +}); diff --git a/src/core/hooks/hook-manager.service.ts b/src/core/hooks/hook-manager.service.ts index bd764ec7..7a7bd6f6 100644 --- a/src/core/hooks/hook-manager.service.ts +++ b/src/core/hooks/hook-manager.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { HookEvent, HookHandler, HookContext, HookRegistration } from './hook.interfaces'; @Injectable() @@ -6,6 +7,11 @@ export class HookManager { private readonly logger = new Logger(HookManager.name); private readonly hooks = new Map(); private readonly pluginHooks = new Map>(); // pluginId -> hookIds + // Events in-flight on the active async context. A handler that re-fires the SAME event + // (e.g. a message:sending handler that sends) is short-circuited instead of recursing. + // NOTE: the context does not span the async engine `message_create` echo, so this guards + // synchronous re-entry only (the async message:sent echo loop is documented, deferred). + private readonly inFlightEvents = new AsyncLocalStorage>(); /** * Register a hook handler @@ -85,6 +91,24 @@ export class HookManager { event: HookEvent, data: T, options: { sessionId?: string; source: string }, + ): Promise<{ continue: boolean; data: T }> { + const inFlight = this.inFlightEvents.getStore(); + if (inFlight?.has(event)) { + this.logger.warn( + `Hook re-entrancy blocked: ${event} re-fired by a handler of the same event (source: ${options.source})`, + ); + return { continue: true, data }; + } + + const nextInFlight = new Set(inFlight); + nextInFlight.add(event); + return this.inFlightEvents.run(nextInFlight, () => this.runHandlers(event, data, options)); + } + + private async runHandlers( + event: HookEvent, + data: T, + options: { sessionId?: string; source: string }, ): Promise<{ continue: boolean; data: T }> { const registrations = this.hooks.get(event) || []; @@ -106,18 +130,15 @@ export class HookManager { ctx.data = currentData; const result = await registration.handler(ctx); - // Update data if modified if (result.data !== undefined) { currentData = result.data as T; } - // Stop chain if continue is false if (!result.continue) { this.logger.debug(`Hook chain stopped by ${registration.pluginId} at event ${event}`); return { continue: false, data: currentData }; } - // Propagate error if (result.error) { throw result.error; } From 659c4b4e80d17cef2656ec08a1a973b334751e7b Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 16:31:59 +0700 Subject: [PATCH 02/10] feat(plugins): add scoped ctx.messages capability, remove getService stub --- src/core/plugins/plugin-capability.spec.ts | 82 ++++++++++++++++++++++ src/core/plugins/plugin-loader.service.ts | 38 ++++++++-- src/core/plugins/plugin.interfaces.ts | 37 +++++++++- 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/core/plugins/plugin-capability.spec.ts diff --git a/src/core/plugins/plugin-capability.spec.ts b/src/core/plugins/plugin-capability.spec.ts new file mode 100644 index 00000000..b22b8b3d --- /dev/null +++ b/src/core/plugins/plugin-capability.spec.ts @@ -0,0 +1,82 @@ +import { ConfigService } from '@nestjs/config'; +import { ModuleRef } from '@nestjs/core'; +import { PluginLoaderService } from './plugin-loader.service'; +import { PluginStorageService } from './plugin-storage.service'; +import { HookManager } from '../hooks'; +import { + PluginCapabilityError, + PluginContext, + PluginInstance, + PluginManifest, + PluginStatus, + PluginType, +} from './plugin.interfaces'; +import { MessageService } from '../../modules/message/message.service'; + +function makePlugin(sessions?: string[]): PluginInstance { + const manifest: PluginManifest = { + id: 'test-ext', + name: 'Test Extension', + version: '1.0.0', + type: PluginType.EXTENSION, + main: 'index.ts', + sessions, + }; + return { manifest, status: PluginStatus.INSTALLED, config: {}, instance: null }; +} + +describe('PluginLoaderService capability facade — ctx.messages', () => { + let loader: PluginLoaderService; + let messageService: { sendText: jest.Mock; reply: jest.Mock }; + let moduleRef: { get: jest.Mock }; + + beforeEach(() => { + messageService = { + sendText: jest.fn().mockResolvedValue({ messageId: 'wamid', timestamp: 1 }), + reply: jest.fn().mockResolvedValue({ messageId: 'wamid', timestamp: 1 }), + }; + moduleRef = { get: jest.fn().mockReturnValue(messageService) }; + const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; + const pluginStorage = { + createPluginStorage: jest.fn().mockReturnValue({}), + } as unknown as PluginStorageService; + loader = new PluginLoaderService( + configService, + new HookManager(), + pluginStorage, + moduleRef as unknown as ModuleRef, + ); + }); + + function contextFor(plugin: PluginInstance): PluginContext { + return ( + loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext } + ).createPluginContext(plugin); + } + + it('messages.sendText delegates to MessageService.sendText with a wrapped dto', async () => { + const ctx = contextFor(makePlugin(['*'])); + await ctx.messages.sendText('sess-1', '628@c.us', 'hi'); + expect(moduleRef.get).toHaveBeenCalledWith(MessageService, { strict: false }); + expect(messageService.sendText).toHaveBeenCalledWith('sess-1', { chatId: '628@c.us', text: 'hi' }); + }); + + it('messages.reply delegates to MessageService.reply', async () => { + const ctx = contextFor(makePlugin(['*'])); + await ctx.messages.reply('sess-1', '628@c.us', 'quoted-id', 'pong'); + expect(messageService.reply).toHaveBeenCalledWith('sess-1', { + chatId: '628@c.us', + quotedMessageId: 'quoted-id', + text: 'pong', + }); + }); + + it('rejects an out-of-scope session BEFORE resolving the service', async () => { + const ctx = contextFor(makePlugin(['allowed-session'])); + await expect(ctx.messages.sendText('other-session', '628@c.us', 'hi')).rejects.toBeInstanceOf( + PluginCapabilityError, + ); + expect(moduleRef.get).not.toHaveBeenCalled(); + expect(messageService.sendText).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/plugins/plugin-loader.service.ts b/src/core/plugins/plugin-loader.service.ts index db4bd6cc..04f47aad 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -1,11 +1,14 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { ModuleRef } from '@nestjs/core'; import * as fs from 'fs'; import * as path from 'path'; import { createLogger } from '../../common/services/logger.service'; import { HookManager } from '../hooks'; import { + PluginCapabilityError, PluginManifest, + PluginMessagingCapability, PluginInstance, PluginStatus, PluginContext, @@ -14,6 +17,7 @@ import { PluginLogger, } from './plugin.interfaces'; import { PluginStorageService } from './plugin-storage.service'; +import { MessageService } from '../../modules/message/message.service'; /** * Resolve a plugin's `main` entry to an absolute path, asserting it stays inside @@ -39,6 +43,10 @@ export class PluginLoaderService implements OnModuleInit { private readonly configService: ConfigService, private readonly hookManager: HookManager, private readonly pluginStorage: PluginStorageService, + // Resolves MessageService/SessionService lazily inside capability verbs. ModuleRef + // (not constructor injection) breaks the provider cycle + // PluginLoaderService -> SessionService -> EngineFactory -> PluginLoaderService. + private readonly moduleRef: ModuleRef, ) { this.pluginsDir = this.configService.get('plugins.dir') ?? './plugins'; } @@ -273,6 +281,19 @@ export class PluginLoaderService implements OnModuleInit { }); } + /** + * Enforce a plugin's manifest session scope. Runs BEFORE any engine/message resolution — + * sessionId is supplied by the plugin, so this is the security boundary. Absent = ['*']. + */ + private assertSessionAllowed(manifest: PluginManifest, sessionId: string): void { + const allowed = manifest.sessions ?? ['*']; + if (!allowed.includes('*') && !allowed.includes(sessionId)) { + throw new PluginCapabilityError( + `Plugin ${manifest.id} is not permitted to act on session ${sessionId}`, + ); + } + } + private createPluginContext(plugin: PluginInstance): PluginContext { const pluginLogger: PluginLogger = { log: (message, meta) => @@ -299,11 +320,18 @@ export class PluginLoaderService implements OnModuleInit { registerHook: (event, handler, priority) => { this.hookManager.register(plugin.manifest.id, event, handler, priority); }, - getService: (): T | undefined => { - // Limited service access for sandboxing - // Only expose safe services - return undefined; - }, + messages: { + sendText: async (sessionId, chatId, text) => { + this.assertSessionAllowed(plugin.manifest, sessionId); + return this.moduleRef.get(MessageService, { strict: false }).sendText(sessionId, { chatId, text }); + }, + reply: async (sessionId, chatId, quotedMessageId, text) => { + this.assertSessionAllowed(plugin.manifest, sessionId); + return this.moduleRef + .get(MessageService, { strict: false }) + .reply(sessionId, { chatId, quotedMessageId, text }); + }, + } satisfies PluginMessagingCapability, }; } diff --git a/src/core/plugins/plugin.interfaces.ts b/src/core/plugins/plugin.interfaces.ts index 7cb7e6e3..adf544c6 100644 --- a/src/core/plugins/plugin.interfaces.ts +++ b/src/core/plugins/plugin.interfaces.ts @@ -4,6 +4,7 @@ */ import { HookManager, HookEvent, HookHandler } from '../hooks'; +import type { MessageResponseDto } from '../../modules/message/dto'; // ============================================================================ // Plugin Types @@ -57,6 +58,13 @@ export interface PluginManifest { // Required features from other plugins requires?: string[]; + + // Capabilities this plugin declares (informational at this tier; not per-verb enforced) + 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[]; } export interface PluginConfigSchema { @@ -75,6 +83,31 @@ export interface PluginConfigSchema { >; } +// ============================================================================ +// Plugin Capability +// ============================================================================ + +/** + * Thrown by a plugin capability when a call is rejected (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; +} + // ============================================================================ // Plugin Context (passed to plugin on initialization) // ============================================================================ @@ -99,8 +132,8 @@ export interface PluginContext { // Register a hook handler registerHook: (event: HookEvent, handler: HookHandler, priority?: number) => void; - // Get service from DI container (limited access) - getService: (token: string) => T | undefined; + // Curated write surface — routes through MessageService (persistence preserved). + messages: PluginMessagingCapability; } export interface PluginLogger { From a0b41a6eadbf842b9091c05a7dcdd846d2c64528 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 16:37:21 +0700 Subject: [PATCH 03/10] test(plugins): cover reply lazy-resolution and absent-sessions default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assert moduleRef.get is called in the reply delegation test. - Add test proving manifest.sessions absent defaults to wildcard allow-all. - Reword ModuleRef constructor comment to clarify it avoids (not causes) the PluginLoaderService → SessionService → EngineFactory cycle. --- src/core/plugins/plugin-capability.spec.ts | 7 +++++++ src/core/plugins/plugin-loader.service.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/plugins/plugin-capability.spec.ts b/src/core/plugins/plugin-capability.spec.ts index b22b8b3d..9453114b 100644 --- a/src/core/plugins/plugin-capability.spec.ts +++ b/src/core/plugins/plugin-capability.spec.ts @@ -64,6 +64,7 @@ describe('PluginLoaderService capability facade — ctx.messages', () => { it('messages.reply delegates to MessageService.reply', async () => { const ctx = contextFor(makePlugin(['*'])); await ctx.messages.reply('sess-1', '628@c.us', 'quoted-id', 'pong'); + expect(moduleRef.get).toHaveBeenCalledWith(MessageService, { strict: false }); expect(messageService.reply).toHaveBeenCalledWith('sess-1', { chatId: '628@c.us', quotedMessageId: 'quoted-id', @@ -71,6 +72,12 @@ describe('PluginLoaderService capability facade — ctx.messages', () => { }); }); + it('allows any session when manifest.sessions is absent (defaults to all)', async () => { + const ctx = contextFor(makePlugin()); // no sessions field + await ctx.messages.sendText('any-session', '628@c.us', 'hi'); + expect(messageService.sendText).toHaveBeenCalledWith('any-session', { chatId: '628@c.us', text: 'hi' }); + }); + it('rejects an out-of-scope session BEFORE resolving the service', async () => { const ctx = contextFor(makePlugin(['allowed-session'])); await expect(ctx.messages.sendText('other-session', '628@c.us', 'hi')).rejects.toBeInstanceOf( diff --git a/src/core/plugins/plugin-loader.service.ts b/src/core/plugins/plugin-loader.service.ts index 04f47aad..c50e545d 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -43,8 +43,8 @@ export class PluginLoaderService implements OnModuleInit { private readonly configService: ConfigService, private readonly hookManager: HookManager, private readonly pluginStorage: PluginStorageService, - // Resolves MessageService/SessionService lazily inside capability verbs. ModuleRef - // (not constructor injection) breaks the provider cycle + // Resolves MessageService/SessionService lazily inside capability verbs. ModuleRef is used + // instead of constructor injection to avoid the provider cycle // PluginLoaderService -> SessionService -> EngineFactory -> PluginLoaderService. private readonly moduleRef: ModuleRef, ) { From c03fc07fdf280facbc63afe6c3d844a2e53c5bda Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 16:41:34 +0700 Subject: [PATCH 04/10] feat(plugins): add scoped read-only ctx.engine capability --- src/core/plugins/plugin-capability.spec.ts | 51 ++++++++++++++++++++++ src/core/plugins/plugin-loader.service.ts | 29 ++++++++++++ src/core/plugins/plugin.interfaces.ts | 12 +++++ 3 files changed, 92 insertions(+) diff --git a/src/core/plugins/plugin-capability.spec.ts b/src/core/plugins/plugin-capability.spec.ts index 9453114b..3babd2ad 100644 --- a/src/core/plugins/plugin-capability.spec.ts +++ b/src/core/plugins/plugin-capability.spec.ts @@ -12,6 +12,7 @@ import { PluginType, } from './plugin.interfaces'; import { MessageService } from '../../modules/message/message.service'; +import { SessionService } from '../../modules/session/session.service'; function makePlugin(sessions?: string[]): PluginInstance { const manifest: PluginManifest = { @@ -87,3 +88,53 @@ describe('PluginLoaderService capability facade — ctx.messages', () => { expect(messageService.sendText).not.toHaveBeenCalled(); }); }); + +describe('PluginLoaderService capability facade — ctx.engine', () => { + let loader: PluginLoaderService; + let moduleRef: { get: jest.Mock }; + + function build(getEngineReturn: unknown): { sessionService: { getEngine: jest.Mock } } { + const sessionService = { getEngine: jest.fn().mockReturnValue(getEngineReturn) }; + moduleRef = { get: jest.fn().mockReturnValue(sessionService) }; + const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; + const pluginStorage = { + createPluginStorage: jest.fn().mockReturnValue({}), + } as unknown as PluginStorageService; + loader = new PluginLoaderService( + configService, + new HookManager(), + pluginStorage, + moduleRef as unknown as ModuleRef, + ); + return { sessionService }; + } + + function contextFor(plugin: PluginInstance): PluginContext { + return ( + loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext } + ).createPluginContext(plugin); + } + + it('engine.getGroupInfo delegates to SessionService.getEngine(id).getGroupInfo', async () => { + const engine = { getGroupInfo: jest.fn().mockResolvedValue({ id: 'g@g.us' }) }; + const { sessionService } = build(engine); + const ctx = contextFor(makePlugin(['*'])); + await ctx.engine.getGroupInfo('sess-1', 'g@g.us'); + expect(moduleRef.get).toHaveBeenCalledWith(SessionService, { strict: false }); + expect(sessionService.getEngine).toHaveBeenCalledWith('sess-1'); + expect(engine.getGroupInfo).toHaveBeenCalledWith('g@g.us'); + }); + + it('throws PluginCapabilityError when the session has no active engine', async () => { + build(undefined); + const ctx = contextFor(makePlugin(['*'])); + await expect(ctx.engine.getContacts('dead-session')).rejects.toBeInstanceOf(PluginCapabilityError); + }); + + it('rejects an out-of-scope session before resolving the engine', async () => { + const { sessionService } = build({ getChats: jest.fn() }); + const ctx = contextFor(makePlugin(['allowed'])); + await expect(ctx.engine.getChats('other')).rejects.toBeInstanceOf(PluginCapabilityError); + expect(sessionService.getEngine).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/plugins/plugin-loader.service.ts b/src/core/plugins/plugin-loader.service.ts index c50e545d..8effcb1b 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -7,6 +7,7 @@ import { createLogger } from '../../common/services/logger.service'; import { HookManager } from '../hooks'; import { PluginCapabilityError, + PluginEngineReadCapability, PluginManifest, PluginMessagingCapability, PluginInstance, @@ -18,6 +19,8 @@ import { } from './plugin.interfaces'; import { PluginStorageService } from './plugin-storage.service'; import { MessageService } from '../../modules/message/message.service'; +import { SessionService } from '../../modules/session/session.service'; +import type { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface'; /** * Resolve a plugin's `main` entry to an absolute path, asserting it stays inside @@ -294,6 +297,22 @@ export class PluginLoaderService implements OnModuleInit { } } + /** + * Scope-check, then resolve the live engine for a session. getEngine returns undefined for an + * unknown OR unstarted session (no throw), so guard it into a defined PluginCapabilityError. + * A present-but-not-READY engine throws EngineNotReadyError from the adapter on use (→ 409). + */ + private resolveEngine(manifest: PluginManifest, sessionId: string): IWhatsAppEngine { + this.assertSessionAllowed(manifest, sessionId); + const engine = this.moduleRef.get(SessionService, { strict: false }).getEngine(sessionId); + if (!engine) { + throw new PluginCapabilityError( + `Session ${sessionId} has no active engine (unknown or not started)`, + ); + } + return engine; + } + private createPluginContext(plugin: PluginInstance): PluginContext { const pluginLogger: PluginLogger = { log: (message, meta) => @@ -332,6 +351,16 @@ export class PluginLoaderService implements OnModuleInit { .reply(sessionId, { chatId, quotedMessageId, text }); }, } satisfies PluginMessagingCapability, + engine: { + getGroupInfo: async (sessionId, groupId) => + this.resolveEngine(plugin.manifest, sessionId).getGroupInfo(groupId), + getContacts: async sessionId => this.resolveEngine(plugin.manifest, sessionId).getContacts(), + getContactById: async (sessionId, contactId) => + this.resolveEngine(plugin.manifest, sessionId).getContactById(contactId), + checkNumberExists: async (sessionId, phone) => + this.resolveEngine(plugin.manifest, sessionId).checkNumberExists(phone), + getChats: async sessionId => this.resolveEngine(plugin.manifest, sessionId).getChats(), + } satisfies PluginEngineReadCapability, }; } diff --git a/src/core/plugins/plugin.interfaces.ts b/src/core/plugins/plugin.interfaces.ts index adf544c6..930462a7 100644 --- a/src/core/plugins/plugin.interfaces.ts +++ b/src/core/plugins/plugin.interfaces.ts @@ -5,6 +5,7 @@ import { HookManager, HookEvent, HookHandler } from '../hooks'; import type { MessageResponseDto } from '../../modules/message/dto'; +import type { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface'; // ============================================================================ // Plugin Types @@ -108,6 +109,14 @@ export interface PluginMessagingCapability { ): 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; +} + // ============================================================================ // Plugin Context (passed to plugin on initialization) // ============================================================================ @@ -134,6 +143,9 @@ export interface PluginContext { // Curated write surface — routes through MessageService (persistence preserved). messages: PluginMessagingCapability; + + // Read-only, scoped engine queries. + engine: PluginEngineReadCapability; } export interface PluginLogger { From 12d76972767c74a1ab8fc1596433170a3d10989e Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 16:50:38 +0700 Subject: [PATCH 05/10] feat(plugins): add disabled-by-default auto-reply reference extension plugin --- src/app.module.ts | 2 + .../auto-reply/auto-reply.plugin.spec.ts | 70 +++++++++++++++++++ src/plugins/extensions/auto-reply/index.ts | 43 ++++++++++++ .../extensions/auto-reply/manifest.json | 10 +++ src/plugins/extensions/extensions.module.ts | 37 ++++++++++ 5 files changed, 162 insertions(+) create mode 100644 src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts create mode 100644 src/plugins/extensions/auto-reply/index.ts create mode 100644 src/plugins/extensions/auto-reply/manifest.json create mode 100644 src/plugins/extensions/extensions.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 7266e5ec..4a86fb93 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { CatalogModule } from './modules/catalog/catalog.module'; import { HooksModule } from './core/hooks'; import { PluginsModule } from './core/plugins'; import { PluginsApiModule } from './modules/plugins/plugins.module'; +import { ExtensionsModule } from './plugins/extensions/extensions.module'; // Only import QueueModule if explicitly enabled to avoid Redis connection errors const queueModules: Array = []; @@ -186,6 +187,7 @@ if (process.env.QUEUE_ENABLED === 'true') { StatusModule, // Phase 3: Status/Stories API CatalogModule, // Phase 3: Catalog API (WhatsApp Business) PluginsApiModule, // Phase 5: Plugins API + ExtensionsModule, // First-party extension plugins (registered disabled) ], }) export class AppModule {} diff --git a/src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts b/src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts new file mode 100644 index 00000000..3efdf5c2 --- /dev/null +++ b/src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts @@ -0,0 +1,70 @@ +import { AutoReplyPlugin } from './index'; +import { PluginContext } from '../../../core/plugins'; +import { HookContext, HookEvent, HookHandler } from '../../../core/hooks'; +import { IncomingMessage } from '../../../engine/interfaces/whatsapp-engine.interface'; + +function makeContext(reply: jest.Mock): { context: PluginContext; getHandler: () => HookHandler } { + let captured: HookHandler | undefined; + const context = { + pluginId: 'auto-reply', + registerHook: (_event: HookEvent, handler: HookHandler) => { + captured = handler; + }, + messages: { reply, sendText: jest.fn() }, + logger: { log: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }, + } as unknown as PluginContext; + return { context, getHandler: () => captured as HookHandler }; +} + +function inbound(overrides: Partial = {}): IncomingMessage { + return { + id: 'msg-1', + from: '628@c.us', + to: 'me', + chatId: '628@c.us', + body: 'ping', + type: 'text', + timestamp: 1, + fromMe: false, + isGroup: false, + ...overrides, + }; +} + +function ctxFor(data: IncomingMessage): HookContext { + return { event: 'message:received', data, sessionId: 'sess-1', timestamp: new Date(), source: 'Engine' }; +} + +describe('AutoReplyPlugin', () => { + it('replies to an inbound direct message and keeps it in history', async () => { + const reply = jest.fn().mockResolvedValue({ messageId: 'x', timestamp: 1 }); + const { context, getHandler } = makeContext(reply); + await new AutoReplyPlugin().onEnable(context); + + const result = await getHandler()(ctxFor(inbound())); + + expect(reply).toHaveBeenCalledWith('sess-1', '628@c.us', 'msg-1', 'Auto-reply: ping'); + expect(result).toEqual({ continue: true }); + }); + + it('does NOT reply to its own outgoing messages (fromMe)', async () => { + const reply = jest.fn(); + const { context, getHandler } = makeContext(reply); + await new AutoReplyPlugin().onEnable(context); + + const result = await getHandler()(ctxFor(inbound({ fromMe: true }))); + + expect(reply).not.toHaveBeenCalled(); + expect(result).toEqual({ continue: true }); + }); + + it('does NOT reply to group messages', async () => { + const reply = jest.fn(); + const { context, getHandler } = makeContext(reply); + await new AutoReplyPlugin().onEnable(context); + + await getHandler()(ctxFor(inbound({ isGroup: true }))); + + expect(reply).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/extensions/auto-reply/index.ts b/src/plugins/extensions/auto-reply/index.ts new file mode 100644 index 00000000..f31d27c9 --- /dev/null +++ b/src/plugins/extensions/auto-reply/index.ts @@ -0,0 +1,43 @@ +/** + * Auto-reply reference extension plugin. + * + * Demonstrates the Tier-2 capability layer end-to-end: it hooks inbound messages and replies + * via ctx.messages.reply. Registered DISABLED by default — enable it from the dashboard to try + * the capability layer live. Replies only to inbound, non-group, engine-originated messages. + */ +import { PluginContext, IPlugin } from '../../../core/plugins'; +import { HookContext, HookResult } from '../../../core/hooks'; +import { IncomingMessage } from '../../../engine/interfaces/whatsapp-engine.interface'; + +export class AutoReplyPlugin implements IPlugin { + onEnable(context: PluginContext): Promise { + context.registerHook('message:received', ctx => + this.onMessage(context, ctx as HookContext), + ); + context.logger.log('Auto-reply reference plugin enabled'); + return Promise.resolve(); + } + + private async onMessage( + context: PluginContext, + ctx: HookContext, + ): Promise { + const message = ctx.data; + + // Reply only to inbound, non-group, engine-originated messages; never to our own sends. + if (ctx.source !== 'Engine' || !ctx.sessionId || message.fromMe || message.isGroup) { + return { continue: true }; + } + + try { + await context.messages.reply(ctx.sessionId, message.chatId, message.id, `Auto-reply: ${message.body}`); + } catch (error) { + context.logger.error('Auto-reply failed', error); + } + + // Keep the inbound message in history + webhooks + ws (do not swallow). + return { continue: true }; + } +} + +export default AutoReplyPlugin; diff --git a/src/plugins/extensions/auto-reply/manifest.json b/src/plugins/extensions/auto-reply/manifest.json new file mode 100644 index 00000000..11cefd31 --- /dev/null +++ b/src/plugins/extensions/auto-reply/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "auto-reply", + "name": "Auto Reply (reference)", + "version": "1.0.0", + "type": "extension", + "description": "Reference extension plugin: replies to inbound direct messages. Disabled by default.", + "main": "index.ts", + "permissions": ["messages:send"], + "sessions": ["*"] +} diff --git a/src/plugins/extensions/extensions.module.ts b/src/plugins/extensions/extensions.module.ts new file mode 100644 index 00000000..29651cd5 --- /dev/null +++ b/src/plugins/extensions/extensions.module.ts @@ -0,0 +1,37 @@ +import { Injectable, Module, OnModuleInit } from '@nestjs/common'; +import { PluginLoaderService, PluginManifest, PluginType } from '../../core/plugins'; +import { AutoReplyPlugin } from './auto-reply'; +import { createLogger } from '../../common/services/logger.service'; + +/** + * Registers first-party built-in EXTENSION plugins with the (global) PluginLoaderService. + * Mirrors EngineFactory's registration pattern so src/core never imports a concrete plugin. + * Built-in extensions are registered DISABLED; operators enable them via POST /plugins/:id/enable. + */ +@Injectable() +export class ExtensionsRegistrar implements OnModuleInit { + private readonly logger = createLogger('ExtensionsRegistrar'); + + constructor(private readonly pluginLoader: PluginLoaderService) {} + + onModuleInit(): void { + const autoReplyManifest: PluginManifest = { + id: 'auto-reply', + name: 'Auto Reply (reference)', + version: '1.0.0', + type: PluginType.EXTENSION, + description: 'Reference extension plugin: replies to inbound direct messages. Disabled by default.', + main: 'index.ts', + permissions: ['messages:send'], + sessions: ['*'], + }; + + this.pluginLoader.registerBuiltInPlugin(autoReplyManifest, new AutoReplyPlugin()); + this.logger.log('Auto-reply reference plugin registered (disabled)'); + } +} + +@Module({ + providers: [ExtensionsRegistrar], +}) +export class ExtensionsModule {} From cf076b1b1e29fc166ef775435d27560a33020a2e Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 16:54:10 +0700 Subject: [PATCH 06/10] test(plugins): cover auto-reply source gate and group continue-result - Capture and assert `{ continue: true }` from the group-message guard so a future regression returning `{ continue: false }` is caught immediately. - Add test for the `source !== 'Engine'` guard: overrides `source` to `'API'` via spread, asserts reply is not called and result is `{ continue: true }`. --- .../auto-reply/auto-reply.plugin.spec.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts b/src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts index 3efdf5c2..2cdc605c 100644 --- a/src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts +++ b/src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts @@ -63,8 +63,20 @@ describe('AutoReplyPlugin', () => { const { context, getHandler } = makeContext(reply); await new AutoReplyPlugin().onEnable(context); - await getHandler()(ctxFor(inbound({ isGroup: true }))); + const result = await getHandler()(ctxFor(inbound({ isGroup: true }))); expect(reply).not.toHaveBeenCalled(); + expect(result).toEqual({ continue: true }); + }); + + it('does NOT reply when the message did not originate from the engine', async () => { + const reply = jest.fn(); + const { context, getHandler } = makeContext(reply); + await new AutoReplyPlugin().onEnable(context); + + const result = await getHandler()({ ...ctxFor(inbound()), source: 'API' }); + + expect(reply).not.toHaveBeenCalled(); + expect(result).toEqual({ continue: true }); }); }); From 031efa42c618b1da8850b8f65f58534493389a23 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 17:01:34 +0700 Subject: [PATCH 07/10] style(plugins): apply prettier --fix to capability layer files --- src/core/plugins/plugin-capability.spec.ts | 12 ++++++------ src/core/plugins/plugin-loader.service.ts | 8 ++------ src/core/plugins/plugin.interfaces.ts | 7 +------ src/plugins/extensions/auto-reply/index.ts | 9 ++------- 4 files changed, 11 insertions(+), 25 deletions(-) diff --git a/src/core/plugins/plugin-capability.spec.ts b/src/core/plugins/plugin-capability.spec.ts index 3babd2ad..1994cf4b 100644 --- a/src/core/plugins/plugin-capability.spec.ts +++ b/src/core/plugins/plugin-capability.spec.ts @@ -50,9 +50,9 @@ describe('PluginLoaderService capability facade — ctx.messages', () => { }); function contextFor(plugin: PluginInstance): PluginContext { - return ( - loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext } - ).createPluginContext(plugin); + return (loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext }).createPluginContext( + plugin, + ); } it('messages.sendText delegates to MessageService.sendText with a wrapped dto', async () => { @@ -110,9 +110,9 @@ describe('PluginLoaderService capability facade — ctx.engine', () => { } function contextFor(plugin: PluginInstance): PluginContext { - return ( - loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext } - ).createPluginContext(plugin); + return (loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext }).createPluginContext( + plugin, + ); } it('engine.getGroupInfo delegates to SessionService.getEngine(id).getGroupInfo', async () => { diff --git a/src/core/plugins/plugin-loader.service.ts b/src/core/plugins/plugin-loader.service.ts index 8effcb1b..fdf89dd4 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -291,9 +291,7 @@ export class PluginLoaderService implements OnModuleInit { private assertSessionAllowed(manifest: PluginManifest, sessionId: string): void { const allowed = manifest.sessions ?? ['*']; if (!allowed.includes('*') && !allowed.includes(sessionId)) { - throw new PluginCapabilityError( - `Plugin ${manifest.id} is not permitted to act on session ${sessionId}`, - ); + throw new PluginCapabilityError(`Plugin ${manifest.id} is not permitted to act on session ${sessionId}`); } } @@ -306,9 +304,7 @@ export class PluginLoaderService implements OnModuleInit { this.assertSessionAllowed(manifest, sessionId); const engine = this.moduleRef.get(SessionService, { strict: false }).getEngine(sessionId); if (!engine) { - throw new PluginCapabilityError( - `Session ${sessionId} has no active engine (unknown or not started)`, - ); + throw new PluginCapabilityError(`Session ${sessionId} has no active engine (unknown or not started)`); } return engine; } diff --git a/src/core/plugins/plugin.interfaces.ts b/src/core/plugins/plugin.interfaces.ts index 930462a7..bd02e904 100644 --- a/src/core/plugins/plugin.interfaces.ts +++ b/src/core/plugins/plugin.interfaces.ts @@ -101,12 +101,7 @@ export class PluginCapabilityError extends Error { export interface PluginMessagingCapability { sendText(sessionId: string, chatId: string, text: string): Promise; - reply( - sessionId: string, - chatId: string, - quotedMessageId: string, - text: string, - ): Promise; + reply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise; } export interface PluginEngineReadCapability { diff --git a/src/plugins/extensions/auto-reply/index.ts b/src/plugins/extensions/auto-reply/index.ts index f31d27c9..58cfe97f 100644 --- a/src/plugins/extensions/auto-reply/index.ts +++ b/src/plugins/extensions/auto-reply/index.ts @@ -11,17 +11,12 @@ import { IncomingMessage } from '../../../engine/interfaces/whatsapp-engine.inte export class AutoReplyPlugin implements IPlugin { onEnable(context: PluginContext): Promise { - context.registerHook('message:received', ctx => - this.onMessage(context, ctx as HookContext), - ); + context.registerHook('message:received', ctx => this.onMessage(context, ctx as HookContext)); context.logger.log('Auto-reply reference plugin enabled'); return Promise.resolve(); } - private async onMessage( - context: PluginContext, - ctx: HookContext, - ): Promise { + private async onMessage(context: PluginContext, ctx: HookContext): Promise { const message = ctx.data; // Reply only to inbound, non-group, engine-originated messages; never to our own sends. From 171892ae92e768be60b206c40cbe07d0a0737f6b Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 17:05:26 +0700 Subject: [PATCH 08/10] fix(plugins): resolve capability services via lazy require to break module load cycle --- src/core/plugins/plugin-loader.service.ts | 32 ++++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/core/plugins/plugin-loader.service.ts b/src/core/plugins/plugin-loader.service.ts index fdf89dd4..d1fed5ca 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -18,8 +18,8 @@ import { PluginLogger, } from './plugin.interfaces'; import { PluginStorageService } from './plugin-storage.service'; -import { MessageService } from '../../modules/message/message.service'; -import { SessionService } from '../../modules/session/session.service'; +import type { MessageService } from '../../modules/message/message.service'; +import type { SessionService } from '../../modules/session/session.service'; import type { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface'; /** @@ -284,6 +284,26 @@ export class PluginLoaderService implements OnModuleInit { }); } + /** + * Resolve MessageService at call time via a lazy require so plugin-loader creates NO top-level + * module-load edge to message.service. A static import closes the cycle + * plugin-loader -> message -> session -> engine.factory -> core/plugins barrel -> plugin-loader, + * which corrupts MessageService's constructor paramtype metadata (SessionService -> undefined) at boot. + */ + private getMessageService(): MessageService { + const mod = + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../../modules/message/message.service') as typeof import('../../modules/message/message.service'); + return this.moduleRef.get(mod.MessageService, { strict: false }); + } + + private getSessionService(): SessionService { + const mod = + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../../modules/session/session.service') as typeof import('../../modules/session/session.service'); + return this.moduleRef.get(mod.SessionService, { strict: false }); + } + /** * Enforce a plugin's manifest session scope. Runs BEFORE any engine/message resolution — * sessionId is supplied by the plugin, so this is the security boundary. Absent = ['*']. @@ -302,7 +322,7 @@ export class PluginLoaderService implements OnModuleInit { */ private resolveEngine(manifest: PluginManifest, sessionId: string): IWhatsAppEngine { this.assertSessionAllowed(manifest, sessionId); - const engine = this.moduleRef.get(SessionService, { strict: false }).getEngine(sessionId); + const engine = this.getSessionService().getEngine(sessionId); if (!engine) { throw new PluginCapabilityError(`Session ${sessionId} has no active engine (unknown or not started)`); } @@ -338,13 +358,11 @@ export class PluginLoaderService implements OnModuleInit { messages: { sendText: async (sessionId, chatId, text) => { this.assertSessionAllowed(plugin.manifest, sessionId); - return this.moduleRef.get(MessageService, { strict: false }).sendText(sessionId, { chatId, text }); + return this.getMessageService().sendText(sessionId, { chatId, text }); }, reply: async (sessionId, chatId, quotedMessageId, text) => { this.assertSessionAllowed(plugin.manifest, sessionId); - return this.moduleRef - .get(MessageService, { strict: false }) - .reply(sessionId, { chatId, quotedMessageId, text }); + return this.getMessageService().reply(sessionId, { chatId, quotedMessageId, text }); }, } satisfies PluginMessagingCapability, engine: { From 74bbf2341633591db098abdc12e831268eea5db0 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 17:14:44 +0700 Subject: [PATCH 09/10] fix(plugins): guard ctx.messages writes against dead sessions (PluginCapabilityError, no orphan row) --- src/core/plugins/plugin-capability.spec.ts | 15 ++++++++++++++- src/core/plugins/plugin-loader.service.ts | 7 +++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/core/plugins/plugin-capability.spec.ts b/src/core/plugins/plugin-capability.spec.ts index 1994cf4b..a907b4bf 100644 --- a/src/core/plugins/plugin-capability.spec.ts +++ b/src/core/plugins/plugin-capability.spec.ts @@ -29,6 +29,7 @@ function makePlugin(sessions?: string[]): PluginInstance { describe('PluginLoaderService capability facade — ctx.messages', () => { let loader: PluginLoaderService; let messageService: { sendText: jest.Mock; reply: jest.Mock }; + let sessionService: { getEngine: jest.Mock }; let moduleRef: { get: jest.Mock }; beforeEach(() => { @@ -36,7 +37,12 @@ describe('PluginLoaderService capability facade — ctx.messages', () => { sendText: jest.fn().mockResolvedValue({ messageId: 'wamid', timestamp: 1 }), reply: jest.fn().mockResolvedValue({ messageId: 'wamid', timestamp: 1 }), }; - moduleRef = { get: jest.fn().mockReturnValue(messageService) }; + sessionService = { getEngine: jest.fn().mockReturnValue({}) }; // truthy live engine + moduleRef = { + get: jest + .fn() + .mockImplementation((token: unknown) => (token === SessionService ? sessionService : messageService)), + }; const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; const pluginStorage = { createPluginStorage: jest.fn().mockReturnValue({}), @@ -87,6 +93,13 @@ describe('PluginLoaderService capability facade — ctx.messages', () => { expect(moduleRef.get).not.toHaveBeenCalled(); expect(messageService.sendText).not.toHaveBeenCalled(); }); + + it('rejects sendText with PluginCapabilityError when the session has no active engine', async () => { + sessionService.getEngine.mockReturnValue(undefined); + const ctx = contextFor(makePlugin(['*'])); + await expect(ctx.messages.sendText('dead-session', '628@c.us', 'hi')).rejects.toBeInstanceOf(PluginCapabilityError); + expect(messageService.sendText).not.toHaveBeenCalled(); + }); }); describe('PluginLoaderService capability facade — ctx.engine', () => { diff --git a/src/core/plugins/plugin-loader.service.ts b/src/core/plugins/plugin-loader.service.ts index d1fed5ca..c7ca3396 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -357,11 +357,14 @@ export class PluginLoaderService implements OnModuleInit { }, messages: { sendText: async (sessionId, chatId, text) => { - this.assertSessionAllowed(plugin.manifest, sessionId); + // Validate scope + that the session has a live engine BEFORE MessageService persists a + // pending row: a dead/unstarted session must fail with PluginCapabilityError, not a raw + // TypeError + orphaned row. resolveEngine also runs assertSessionAllowed. + this.resolveEngine(plugin.manifest, sessionId); return this.getMessageService().sendText(sessionId, { chatId, text }); }, reply: async (sessionId, chatId, quotedMessageId, text) => { - this.assertSessionAllowed(plugin.manifest, sessionId); + this.resolveEngine(plugin.manifest, sessionId); return this.getMessageService().reply(sessionId, { chatId, quotedMessageId, text }); }, } satisfies PluginMessagingCapability, From bbbd1307b157d78299d539aa1fe146b121d399df Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Wed, 17 Jun 2026 17:22:43 +0700 Subject: [PATCH 10/10] docs(changelog): add plugin capability layer entry (#294) --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 641929d7..34ad2321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Adds the Tier-2 plugin capability layer — the foundation for shipping bot-shaped features as extension +plugins instead of in core (#265). + +> ⚠️ **Breaking (plugin API):** `PluginContext.getService` is removed. It was a stub returning `undefined` +> with no real consumers; out-of-tree plugins must migrate to the new `ctx.messages` / `ctx.engine` +> capabilities. As a breaking change this is slated for the next minor (v0.3.0). + +### Added + +- **Plugin capability layer (Tier-2 extension plugins):** scoped `ctx.messages` (`sendText` / `reply`, + routed through `MessageService` so persistence and the send pipeline are preserved) and read-only + `ctx.engine` (`getGroupInfo` / `getContacts` / `getContactById` / `checkNumberExists` / `getChats`) on + `PluginContext`, replacing the stubbed `getService`. A manifest-declared `sessions` scope is enforced at + the facade before any engine access (default `['*']`), and a capability call to a dead/unstarted session + fails with `PluginCapabilityError` instead of a raw error. (#294) +- **`HookManager` re-entrancy guard** (`AsyncLocalStorage`): a plugin that sends from inside a hook handler + can no longer recurse into the same event (synchronous re-entry; the async `message:sent` echo loop is + documented as out of scope for now). (#294) +- **`auto-reply` reference extension plugin**, first-party and **registered disabled by default** — enable + it via `POST /plugins/auto-reply/enable` to exercise the capability layer end-to-end. (#294) + ## [0.2.10] - 2026-06-17 Completes the v0.2.9 non-breaking batch with three dashboard/CI follow-ups that belonged to the same