From d30d0b179da29a82f74cefe05c843f6867482ee3 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Fri, 3 Jul 2026 18:57:47 +0700 Subject: [PATCH] feat(plugins): add engine.canonicalChatId capability for @lid resolution (#615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose ctx.engine.canonicalChatId(sessionId, chatId) to plugins: resolves a @lid privacy id to its stable @c.us form via the shared lid->phone table (best-effort; unresolved ids pass through), gated by engine:read like the other engine reads and bridged into the sandbox worker + router. Lets an adapter key a chat by one identity across WhatsApp's @lid migration — the host-side prerequisite for the chatwoot-adapter to stop a migrated contact's conversation from splitting. --- CHANGELOG.md | 4 ++++ src/core/plugins/plugin-loader.service.ts | 15 ++++++++++++++- src/core/plugins/plugin.interfaces.ts | 6 ++++++ .../plugins/sandbox/capability-router.spec.ts | 8 ++++++++ src/core/plugins/sandbox/capability-router.ts | 2 ++ src/core/plugins/sandbox/worker-capability.ts | 2 ++ 6 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fbd543..62df30dd 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 canonicalize a chat id** via a new `ctx.engine.canonicalChatId(sessionId, chatId)` capability, gated by the `engine:read` permission like the other engine reads. It resolves a `@lid` privacy id to its stable `@c.us` form when the mapping is known (best-effort; an unresolved id passes through), letting a plugin key a chat by one identity across WhatsApp's `@lid` migration. This is the host-side prerequisite for an adapter to keep a contact's conversation from splitting when they migrate to `@lid`. (#615) + ## [0.8.6] - 2026-07-03 ### Fixed diff --git a/src/core/plugins/plugin-loader.service.ts b/src/core/plugins/plugin-loader.service.ts index fdaee43b..86f3710e 100644 --- a/src/core/plugins/plugin-loader.service.ts +++ b/src/core/plugins/plugin-loader.service.ts @@ -1,6 +1,8 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, OnModuleInit, OnModuleDestroy, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ModuleRef } from '@nestjs/core'; +import { toNeutralJid, userPart } from '../../engine/identity/wa-id'; +import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; import { AsyncLocalStorage } from 'async_hooks'; import * as fs from 'fs'; import * as path from 'path'; @@ -146,6 +148,10 @@ export class PluginLoaderService implements OnModuleInit, OnModuleDestroy { // instead of constructor injection to avoid the provider cycle // PluginLoaderService -> SessionService -> EngineFactory -> PluginLoaderService. private readonly moduleRef: ModuleRef, + // Shared lid->phone table (EngineModule is @Global and exports it). Optional so the many unit tests + // that construct this service with the 4 prior args still compile; when absent, canonicalChatId + // degrades to identity (no @lid resolution). + @Optional() private readonly lidMappingStore?: LidMappingStoreService, ) { this.pluginsDir = this.configService.get('plugins.dir') ?? './plugins'; } @@ -1020,6 +1026,13 @@ export class PluginLoaderService implements OnModuleInit, OnModuleDestroy { Math.min(Math.max(Math.trunc(limit ?? 50), 1), 100), includeMedia ?? false, ), + canonicalChatId: (sessionId, chatId) => { + // resolveEngineRead is the gate only (engine:read permission + live session); the resolution + // itself is a synchronous host lid->phone lookup, not an engine call, mirroring the webhook + // from-filter. Not `async` (nothing to await) — a resolved promise satisfies the signature. + this.resolveEngineRead(plugin, sessionId); + return Promise.resolve(toNeutralJid(chatId, jid => this.lidMappingStore?.getCached(userPart(jid)) ?? null)); + }, } satisfies PluginEngineReadCapability, net: { fetch: async (url, init) => { diff --git a/src/core/plugins/plugin.interfaces.ts b/src/core/plugins/plugin.interfaces.ts index 9fefa0d7..e401e72a 100644 --- a/src/core/plugins/plugin.interfaces.ts +++ b/src/core/plugins/plugin.interfaces.ts @@ -286,6 +286,12 @@ export interface PluginEngineReadCapability { limit?: number, includeMedia?: boolean, ): ReturnType; + /** + * Canonical (neutral) form of a chat id: resolves a `@lid` privacy id to its stable `@c.us` + * when the lid->phone mapping is known, and otherwise returns the id unchanged. Lets a plugin key a + * chat by one identity across WhatsApp's `@lid` migration (best-effort; an unresolved lid stays `@lid`). + */ + canonicalChatId(sessionId: string, chatId: string): Promise; } /** Outbound HTTP for a plugin — always through the host SSRF guard, scoped to `manifest.net.allow`. */ diff --git a/src/core/plugins/sandbox/capability-router.spec.ts b/src/core/plugins/sandbox/capability-router.spec.ts index 4e660c63..71397d7e 100644 --- a/src/core/plugins/sandbox/capability-router.spec.ts +++ b/src/core/plugins/sandbox/capability-router.spec.ts @@ -13,6 +13,7 @@ function makeContext() { checkNumberExists: jest.fn().mockResolvedValue(true), getChats: jest.fn().mockResolvedValue([]), getChatHistory: jest.fn().mockResolvedValue([]), + canonicalChatId: jest.fn().mockResolvedValue('628@c.us'), }, storage: { get: jest.fn().mockResolvedValue('v'), @@ -63,6 +64,13 @@ describe('dispatchCapabilityVerb', () => { expect(ctx.engine.getChatHistory).toHaveBeenCalledWith('s', 'c@c.us', 20, true); }); + it('routes engine.canonicalChatId and returns the resolved id', async () => { + const ctx = makeContext(); + const out = await dispatchCapabilityVerb(ctx, 'engine.canonicalChatId', ['s', '111@lid']); + expect(ctx.engine.canonicalChatId).toHaveBeenCalledWith('s', '111@lid'); + expect(out).toBe('628@c.us'); + }); + it('routes storage.get and returns its value', async () => { const ctx = makeContext(); const out = await dispatchCapabilityVerb(ctx, 'storage.get', ['k']); diff --git a/src/core/plugins/sandbox/capability-router.ts b/src/core/plugins/sandbox/capability-router.ts index 6656af27..857f4f41 100644 --- a/src/core/plugins/sandbox/capability-router.ts +++ b/src/core/plugins/sandbox/capability-router.ts @@ -51,6 +51,8 @@ export async function dispatchCapabilityVerb( return context.engine.getChats(s(0)); case 'engine.getChatHistory': return context.engine.getChatHistory(s(0), s(1), args[2] as number | undefined, args[3] as boolean | undefined); + case 'engine.canonicalChatId': + return context.engine.canonicalChatId(s(0), s(1)); case 'storage.get': return context.storage.get(s(0)); case 'storage.set': diff --git a/src/core/plugins/sandbox/worker-capability.ts b/src/core/plugins/sandbox/worker-capability.ts index 96c9880a..9a576d4f 100644 --- a/src/core/plugins/sandbox/worker-capability.ts +++ b/src/core/plugins/sandbox/worker-capability.ts @@ -42,6 +42,7 @@ export interface SandboxCapabilityContext { checkNumberExists(sessionId: string, phone: string): Promise; getChats(sessionId: string): Promise; getChatHistory(sessionId: string, chatId: string, limit?: number, includeMedia?: boolean): Promise; + canonicalChatId(sessionId: string, chatId: string): Promise; }; storage: { get(key: string): Promise; @@ -84,6 +85,7 @@ export function buildSandboxContext(client: WorkerCapabilityClient): SandboxCapa getChats: sessionId => client.call('engine.getChats', [sessionId]), getChatHistory: (sessionId, chatId, limit, includeMedia) => client.call('engine.getChatHistory', [sessionId, chatId, limit, includeMedia]), + canonicalChatId: (sessionId, chatId) => client.call('engine.canonicalChatId', [sessionId, chatId]), }, storage: { get: key => client.call('storage.get', [key]),