From 3a13b15a28f7811f73dd3def3039a0ba46a10fee Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Fri, 3 Jul 2026 16:04:21 +0700 Subject: [PATCH] feat(plugins): expose engine.getChatHistory to plugins (#609) Add a getChatHistory(sessionId, chatId, limit?, includeMedia?) method to PluginEngineReadCapability, bound in the loader through the same resolveEngineRead gate as the other engine reads (engine:read permission + active session). The limit is clamped inline to 100 (the REST non-deep ceiling) so an untrusted plugin can't request an unbounded fetch; both message directions are returned. Host-side prerequisite for the chatwoot-adapter history backfill. --- CHANGELOG.md | 4 ++++ src/core/plugins/plugin-capability.spec.ts | 25 ++++++++++++++++++++++ src/core/plugins/plugin-loader.service.ts | 8 +++++++ src/core/plugins/plugin.interfaces.ts | 7 ++++++ 4 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d93249b3f..00620e4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Plugins can read recent chat history** via a new `ctx.engine.getChatHistory(sessionId, chatId, limit?, includeMedia?)` capability, gated by the `engine:read` permission and the plugin's active-session scope like the other engine reads. The limit is clamped host-side (max 100), and both message directions are returned. This is the host-side prerequisite for an adapter to backfill prior conversation context. (#609) + ## [0.8.4] - 2026-07-03 ### Added diff --git a/src/core/plugins/plugin-capability.spec.ts b/src/core/plugins/plugin-capability.spec.ts index 0c23e0d00..3b8ec9911 100644 --- a/src/core/plugins/plugin-capability.spec.ts +++ b/src/core/plugins/plugin-capability.spec.ts @@ -219,6 +219,31 @@ describe('PluginLoaderService capability facade — ctx.engine', () => { await ctx.engine.getGroupInfo('sess-1', 'g@g.us'); expect(engine.getGroupInfo).toHaveBeenCalledWith('g@g.us'); }); + + it('engine.getChatHistory delegates to the engine and clamps the limit to 100', async () => { + const engine = { getChatHistory: jest.fn().mockResolvedValue([]) }; + build(engine); + const ctx = contextFor(makePlugin(['*'], ['engine:read'])); + await ctx.engine.getChatHistory('sess-1', 'c@c.us', 500, true); + expect(engine.getChatHistory).toHaveBeenCalledWith('c@c.us', 100, true); // 500 clamped to 100 + }); + + it('engine.getChatHistory defaults the limit and clamps a non-positive value to 1', async () => { + const engine = { getChatHistory: jest.fn().mockResolvedValue([]) }; + build(engine); + const ctx = contextFor(makePlugin(['*'], ['engine:read'])); + await ctx.engine.getChatHistory('sess-1', 'c@c.us'); // no limit → default 50, includeMedia → false + await ctx.engine.getChatHistory('sess-1', 'c@c.us', 0); + expect(engine.getChatHistory).toHaveBeenNthCalledWith(1, 'c@c.us', 50, false); + expect(engine.getChatHistory).toHaveBeenNthCalledWith(2, 'c@c.us', 1, false); + }); + + it('denies engine.getChatHistory without the engine:read permission', async () => { + const { sessionService } = build({ getChatHistory: jest.fn() }); + const ctx = contextFor(makePlugin(['*'], ['messages:send'])); + await expect(ctx.engine.getChatHistory('sess-1', 'c@c.us')).rejects.toBeInstanceOf(PluginCapabilityError); + expect(sessionService.getEngine).not.toHaveBeenCalled(); + }); }); describe('PluginLoaderService capability facade — ctx.net', () => { diff --git a/src/core/plugins/plugin-loader.service.ts b/src/core/plugins/plugin-loader.service.ts index 315aa0d8b..fdaee43b6 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -1012,6 +1012,14 @@ export class PluginLoaderService implements OnModuleInit, OnModuleDestroy { checkNumberExists: async (sessionId, phone) => this.resolveEngineRead(plugin, sessionId).checkNumberExists(phone), getChats: async sessionId => this.resolveEngineRead(plugin, sessionId).getChats(), + getChatHistory: async (sessionId, chatId, limit, includeMedia) => + this.resolveEngineRead(plugin, sessionId).getChatHistory( + chatId, + // Clamp to the REST non-deep ceiling (MessageService.MAX_CHAT_HISTORY_LIMIT = 100) so an + // untrusted plugin can't request an unbounded history fetch. + Math.min(Math.max(Math.trunc(limit ?? 50), 1), 100), + includeMedia ?? false, + ), } satisfies PluginEngineReadCapability, net: { fetch: async (url, init) => { diff --git a/src/core/plugins/plugin.interfaces.ts b/src/core/plugins/plugin.interfaces.ts index 8ef0586a8..9fefa0d79 100644 --- a/src/core/plugins/plugin.interfaces.ts +++ b/src/core/plugins/plugin.interfaces.ts @@ -279,6 +279,13 @@ export interface PluginEngineReadCapability { getContactById(sessionId: string, contactId: string): ReturnType; checkNumberExists(sessionId: string, phone: string): ReturnType; getChats(sessionId: string): ReturnType; + /** Recent messages for a chat (both directions), for history backfill. `limit` is clamped host-side. */ + getChatHistory( + sessionId: string, + chatId: string, + limit?: number, + includeMedia?: boolean, + ): ReturnType; } /** Outbound HTTP for a plugin — always through the host SSRF guard, scoped to `manifest.net.allow`. */