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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<phone>@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
Expand Down
15 changes: 14 additions & 1 deletion src/core/plugins/plugin-loader.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string>('plugins.dir') ?? './plugins';
}
Expand Down Expand Up @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions src/core/plugins/plugin.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,12 @@ export interface PluginEngineReadCapability {
limit?: number,
includeMedia?: boolean,
): ReturnType<IWhatsAppEngine['getChatHistory']>;
/**
* Canonical (neutral) form of a chat id: resolves a `@lid` privacy id to its stable `<phone>@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<string>;
}

/** Outbound HTTP for a plugin — always through the host SSRF guard, scoped to `manifest.net.allow`. */
Expand Down
8 changes: 8 additions & 0 deletions src/core/plugins/sandbox/capability-router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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']);
Expand Down
2 changes: 2 additions & 0 deletions src/core/plugins/sandbox/capability-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
2 changes: 2 additions & 0 deletions src/core/plugins/sandbox/worker-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface SandboxCapabilityContext {
checkNumberExists(sessionId: string, phone: string): Promise<unknown>;
getChats(sessionId: string): Promise<unknown>;
getChatHistory(sessionId: string, chatId: string, limit?: number, includeMedia?: boolean): Promise<unknown>;
canonicalChatId(sessionId: string, chatId: string): Promise<unknown>;
};
storage: {
get(key: string): Promise<unknown>;
Expand Down Expand Up @@ -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]),
Expand Down
Loading