Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type | DynamicModule> = [];
Expand Down Expand Up @@ -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 {}
43 changes: 42 additions & 1 deletion src/core/hooks/hook-manager.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -105,7 +106,7 @@ describe('HookManager', () => {
expect(hm.hasHooks('session:created')).toBe(false);
});

it('unregisterPlugin removes only that plugins 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 }));
Expand All @@ -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<HookResult> => {
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<HookResult> => {
seen.push('received');
await manager.execute('message:sent', ctx.data, { source: 'test' });
return { continue: true };
});
manager.register('p2', 'message:sent', async (): Promise<HookResult> => {
seen.push('sent');
return { continue: true };
});

await manager.execute('message:received', { n: 1 }, { source: 'test' });

expect(seen).toEqual(['received', 'sent']);
});
});
27 changes: 24 additions & 3 deletions src/core/hooks/hook-manager.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { Injectable, Logger } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
import { HookEvent, HookHandler, HookContext, HookRegistration } from './hook.interfaces';

@Injectable()
export class HookManager {
private readonly logger = new Logger(HookManager.name);
private readonly hooks = new Map<HookEvent, HookRegistration[]>();
private readonly pluginHooks = new Map<string, Set<string>>(); // 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<Set<HookEvent>>();

/**
* Register a hook handler
Expand Down Expand Up @@ -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<HookEvent>(inFlight);
nextInFlight.add(event);
return this.inFlightEvents.run(nextInFlight, () => this.runHandlers(event, data, options));
}

private async runHandlers<T>(
event: HookEvent,
data: T,
options: { sessionId?: string; source: string },
): Promise<{ continue: boolean; data: T }> {
const registrations = this.hooks.get(event) || [];

Expand All @@ -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;
}
Expand Down
153 changes: 153 additions & 0 deletions src/core/plugins/plugin-capability.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading