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 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/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; } diff --git a/src/core/plugins/plugin-capability.spec.ts b/src/core/plugins/plugin-capability.spec.ts new file mode 100644 index 00000000..a907b4bf --- /dev/null +++ b/src/core/plugins/plugin-capability.spec.ts @@ -0,0 +1,153 @@ +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'; +import { SessionService } from '../../modules/session/session.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 sessionService: { getEngine: 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 }), + }; + 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({}), + } 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(moduleRef.get).toHaveBeenCalledWith(MessageService, { strict: false }); + expect(messageService.reply).toHaveBeenCalledWith('sess-1', { + chatId: '628@c.us', + quotedMessageId: 'quoted-id', + text: 'pong', + }); + }); + + 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( + PluginCapabilityError, + ); + 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', () => { + 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 db4bd6cc..c7ca3396 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -1,11 +1,15 @@ 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, + PluginEngineReadCapability, PluginManifest, + PluginMessagingCapability, PluginInstance, PluginStatus, PluginContext, @@ -14,6 +18,9 @@ import { PluginLogger, } from './plugin.interfaces'; import { PluginStorageService } from './plugin-storage.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'; /** * Resolve a plugin's `main` entry to an absolute path, asserting it stays inside @@ -39,6 +46,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 is used + // instead of constructor injection to avoid the provider cycle + // PluginLoaderService -> SessionService -> EngineFactory -> PluginLoaderService. + private readonly moduleRef: ModuleRef, ) { this.pluginsDir = this.configService.get('plugins.dir') ?? './plugins'; } @@ -273,6 +284,51 @@ 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 = ['*']. + */ + 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}`); + } + } + + /** + * 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.getSessionService().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) => @@ -299,11 +355,29 @@ 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) => { + // 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.resolveEngine(plugin.manifest, sessionId); + return this.getMessageService().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 7cb7e6e3..bd02e904 100644 --- a/src/core/plugins/plugin.interfaces.ts +++ b/src/core/plugins/plugin.interfaces.ts @@ -4,6 +4,8 @@ */ import { HookManager, HookEvent, HookHandler } from '../hooks'; +import type { MessageResponseDto } from '../../modules/message/dto'; +import type { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface'; // ============================================================================ // Plugin Types @@ -57,6 +59,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 +84,34 @@ 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; +} + +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) // ============================================================================ @@ -99,8 +136,11 @@ 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; + + // Read-only, scoped engine queries. + engine: PluginEngineReadCapability; } export interface PluginLogger { 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..2cdc605c --- /dev/null +++ b/src/plugins/extensions/auto-reply/auto-reply.plugin.spec.ts @@ -0,0 +1,82 @@ +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); + + 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 }); + }); +}); diff --git a/src/plugins/extensions/auto-reply/index.ts b/src/plugins/extensions/auto-reply/index.ts new file mode 100644 index 00000000..58cfe97f --- /dev/null +++ b/src/plugins/extensions/auto-reply/index.ts @@ -0,0 +1,38 @@ +/** + * 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 {}