diff --git a/AGENTS.md b/AGENTS.md index f711bb78..2e6d8168 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,12 +51,19 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API - Respect path aliases (`@waha/...`) defined in `tsconfig.json` - Prefer named function declarations over `const` arrow functions - Avoid naming unused variables with a leading underscore +- Always use explicit property names in object literals — never shorthand: write + `{ key: value }`, not `{ value }` (even when the variable name matches the + key) - Do not write verbose ternaries; use idiomatic helpers like `??` (nullish coalescing) -- Do not place `await` or other async calls inside ternary expressions (`?:`); - use explicit `if/else` blocks +- Do not place `await` or other async calls inside ternary expressions (`?:`) or + nullish-coalescing expressions (`??`); use explicit `if/else` blocks or assign + the awaited value to a variable first - For configs, prefer runtime configurability over constants (environment keys follow `WAHA_*` and `WAHA_SESSION_CONFIG_*`) +- Do not use decorative comment blocks (lines of dashes/underscores with a + label) such as `// ─────────── NAME ───────────`; use plain inline comments or + no comment at all ## How to Run API @@ -86,6 +93,35 @@ npm run start - Skip it on methods that only throw `NotImplementedByEngineError` / `AvailableInPlusVersion` +## MCP Tools + +MCP tools live in `src/apps/mcp/tools/` and expose the HTTP API to AI clients. +Each tool file mirrors an API domain (e.g. `chats.tools.ts` → chats endpoints). + +**When you change an existing API endpoint:** + +- Check the corresponding `*.tools.ts` file and update the tool's `inputSchema`, + description, or behavior if the API signature changed. + +**When you add a new API endpoint:** + +- Ask the user whether an MCP tool is needed for the new endpoint before + creating one. +- If yes, add the tool to the matching `*.tools.ts` file (or create a new file + for a new domain). +- Every `@Tool` decorator must include an `annotations` block with all three + fields: + ```typescript + annotations: { + readOnlyHint: true | false, // true = no side effects (GET-style) + destructiveHint: true | false, // true = irreversible deletion/logout + idempotentHint: true | false, // true = safe to repeat with same args + } + ``` +- Input schemas live in the matching `*.zod.ts` file. +- Tools call the API via `this.textRequest({ method, url, ... })` inherited from + `McpController`. + ## Related Sources - WEBJS: `../whatsapp-web.js` diff --git a/package.json b/package.json index 817ccfe8..518c0deb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@casl/ability": "^6.8.0", "@figuro/chatwoot-sdk": "^1.1.17", "@liaoliaots/nestjs-redis": "^9", + "@modelcontextprotocol/sdk": "^1.29.0", "@nestjs/axios": "^3.0.2", "@nestjs/bullmq": "^11.0.2", "@nestjs/common": "^11.0.0", @@ -116,7 +117,8 @@ "user-agents": "^1.1.669", "whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18", "write-file-atomic": "^6.0.0", - "yaml": "^2.7.1" + "yaml": "^2.7.1", + "zod": "^4.3.6" }, "optionalDependencies": { "bufferutil": "^4.0.8" diff --git a/src/apps/app_sdk/api/apps.controller.ts b/src/apps/app_sdk/api/apps.controller.ts index 33cfa393..573b1429 100644 --- a/src/apps/app_sdk/api/apps.controller.ts +++ b/src/apps/app_sdk/api/apps.controller.ts @@ -31,6 +31,7 @@ import { import { Action, session as SessionName } from '@waha/core/auth/casl.types'; import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe'; +import { APPS } from '../apps/definition'; import { App } from '../dto/app.dto'; import { ListAppsQuery } from '../dto/query.dto'; @@ -62,7 +63,7 @@ export class AppsController { async create(@Body() app: App): Promise { const result = await this.appsService.create(this.manager, app); const isRunning = this.manager.isRunning(app.session); - if (isRunning && app.enabled) { + if (isRunning && app.enabled && APPS[app.app].restartOnChange) { await this.manager.restart(app.session); } return result; @@ -112,9 +113,9 @@ export class AppsController { } const result = await this.appsService.upsert(this.manager, app); - const isRunning = this.manager.isRunning(app.session); - if (isRunning) { - await this.manager.restart(app.session); + const isRunning = this.manager.isRunning(result.session); + if (isRunning && APPS[result.app].restartOnChange) { + await this.manager.restart(result.session); } return result; } @@ -133,7 +134,7 @@ export class AppsController { } const app = await this.appsService.delete(this.manager, id); const isRunning = this.manager.isRunning(app.session); - if (isRunning) { + if (isRunning && APPS[app.app].restartOnChange) { await this.manager.restart(app.session); } } diff --git a/src/apps/app_sdk/apps/definition.ts b/src/apps/app_sdk/apps/definition.ts index d4bd4b72..ebfe6ddb 100644 --- a/src/apps/app_sdk/apps/definition.ts +++ b/src/apps/app_sdk/apps/definition.ts @@ -9,6 +9,8 @@ export interface AppDefinition { queue: boolean; // If app has any migrations migrations: boolean; + // If adding, updating, or removing this app requires a session restart + restartOnChange: boolean; } // All Apps @@ -18,11 +20,20 @@ export const APPS: Record = { plainkey: false, queue: false, migrations: false, + restartOnChange: true, }, [AppName.chatwoot]: { name: AppName.chatwoot, plainkey: true, queue: true, migrations: true, + restartOnChange: true, + }, + [AppName.mcp]: { + name: AppName.mcp, + plainkey: false, + queue: false, + migrations: false, + restartOnChange: false, }, }; diff --git a/src/apps/app_sdk/apps/name.ts b/src/apps/app_sdk/apps/name.ts index fbc49e93..342faf02 100644 --- a/src/apps/app_sdk/apps/name.ts +++ b/src/apps/app_sdk/apps/name.ts @@ -1,4 +1,5 @@ export enum AppName { chatwoot = 'chatwoot', calls = 'calls', + mcp = 'mcp', } diff --git a/src/apps/app_sdk/dto/app.dto.ts b/src/apps/app_sdk/dto/app.dto.ts index de656fad..adc54377 100644 --- a/src/apps/app_sdk/dto/app.dto.ts +++ b/src/apps/app_sdk/dto/app.dto.ts @@ -1,5 +1,6 @@ import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto'; import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto'; +import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto'; import { Type } from 'class-transformer'; import { IsBoolean, @@ -11,9 +12,12 @@ import { import { ApiExtraModels, ApiProperty } from '@nestjs/swagger'; import { AppName } from '@waha/apps/app_sdk/apps/name'; -export type AllowedAppConfig = ChatWootAppConfig | CallsAppConfig; +export type AllowedAppConfig = + | ChatWootAppConfig + | CallsAppConfig + | McpAppConfig; -@ApiExtraModels(ChatWootAppConfig, CallsAppConfig) +@ApiExtraModels(ChatWootAppConfig, CallsAppConfig, McpAppConfig) export class App { @IsString() id: string; @@ -43,6 +47,8 @@ export class App { return ChatWootAppConfig; case AppName.calls: return CallsAppConfig; + case AppName.mcp: + return McpAppConfig; default: return Object; } @@ -62,4 +68,9 @@ export class CallsAppDto extends App { config: CallsAppConfig; } -export type AppDto = ChatWootAppDto | CallsAppDto; +export class McpAppDto extends App { + @Type(() => McpAppConfig) + config: McpAppConfig; +} + +export type AppDto = ChatWootAppDto | CallsAppDto | McpAppDto; diff --git a/src/apps/app_sdk/services/AppsEnabledService.ts b/src/apps/app_sdk/services/AppsEnabledService.ts index 06359d29..d41f8b9d 100644 --- a/src/apps/app_sdk/services/AppsEnabledService.ts +++ b/src/apps/app_sdk/services/AppsEnabledService.ts @@ -9,6 +9,7 @@ import { IAppService } from '@waha/apps/app_sdk/services/IAppService'; import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService'; import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService'; import { CallsAppService } from '@waha/apps/calls/services/CallsAppService'; +import { McpAppService } from '@waha/apps/mcp/services/McpAppService'; import { DataStore } from '@waha/core/abc/DataStore'; import { SessionManager } from '@waha/core/abc/manager.abc'; import { WhatsappSession } from '@waha/core/abc/session.abc'; @@ -36,15 +37,18 @@ export class AppsEnabledService implements IAppsService { protected logger: PinoLogger, @Optional() protected readonly chatwootService: ChatWootAppService, @Optional() protected readonly callsAppService: CallsAppService, + @Optional() protected readonly mcpAppService: McpAppService, ) {} async list(manager: SessionManager, session: string): Promise { const knex = manager.store.getWAHADatabase(); const repo = new AppRepository(knex); const apps = await repo.getAllBySession(session); - apps.forEach((app) => { + for (const app of apps) { delete app.pk; - }); + const service = this.getAppService(app); + await service?.enrich(manager, app); + } return apps; } @@ -101,6 +105,9 @@ export class AppsEnabledService implements IAppsService { const result = await repo.save(app); delete result.pk; + if (app.enabled !== false) { + await service?.afterCreated(manager, result); + } return result; } @@ -112,6 +119,8 @@ export class AppsEnabledService implements IAppsService { return null; } delete (app as any).pk; + const service = this.getAppService(app); + await service?.enrich(manager, app); return app; } @@ -156,12 +165,12 @@ export class AppsEnabledService implements IAppsService { if (hasEnabledChange) { if (app.enabled) { - await service?.beforeEnabled(savedApp, app); + await service?.beforeEnabled(manager, savedApp, app); } else { - await service?.beforeDisabled(savedApp, app); + await service?.beforeDisabled(manager, savedApp, app); } } else { - await service?.beforeUpdated(savedApp, app); + await service?.beforeUpdated(manager, savedApp, app); } await repo.update(app.id, app); const updated = await repo.getById(app.id); @@ -177,7 +186,7 @@ export class AppsEnabledService implements IAppsService { throw new NotFoundException(`App '${appId}' not found`); } const service = this.getAppService(app); - await service?.beforeDeleted(app); + await service?.beforeDeleted(manager, app); await repo.delete(app.id); delete app.pk; return app; @@ -186,6 +195,11 @@ export class AppsEnabledService implements IAppsService { async removeBySession(manager: SessionManager, session: string) { const knex = manager.store.getWAHADatabase(); const repo = new AppRepository(knex); + const apps = await repo.getAllBySession(session); + for (const app of apps) { + const service = this.getAppService(app); + await service?.beforeSessionDeleted(manager, app); + } await repo.deleteBySession(session); } @@ -257,6 +271,8 @@ export class AppsEnabledService implements IAppsService { return this.chatwootService; case AppName.calls: return this.callsAppService; + case AppName.mcp: + return this.mcpAppService; default: throw new Error(`App '${app.app}' not supported`); } diff --git a/src/apps/app_sdk/services/IAppService.ts b/src/apps/app_sdk/services/IAppService.ts index 5db6caea..9146659e 100644 --- a/src/apps/app_sdk/services/IAppService.ts +++ b/src/apps/app_sdk/services/IAppService.ts @@ -1,4 +1,5 @@ import { App } from '@waha/apps/app_sdk/dto/app.dto'; +import { SessionManager } from '@waha/core/abc/manager.abc'; import { WhatsappSession } from '@waha/core/abc/session.abc'; /** @@ -9,19 +10,49 @@ export interface IAppService { beforeCreated(app: App): Promise; + /** + * Called after the app record is saved to the database during creation. + * Use this for side effects that must happen after the app exists in storage. + */ + afterCreated(manager: SessionManager, app: App): Promise; + /** * Called only when the app transitions from disabled -> enabled. */ - beforeEnabled(savedApp: App, newApp: App): Promise; + beforeEnabled( + manager: SessionManager, + savedApp: App, + newApp: App, + ): Promise; /** * Called only when the app transitions from enabled -> disabled. */ - beforeDisabled(savedApp: App, newApp: App): Promise; + beforeDisabled( + manager: SessionManager, + savedApp: App, + newApp: App, + ): Promise; - beforeUpdated(savedApp: App, newApp: App): Promise; + beforeUpdated( + manager: SessionManager, + savedApp: App, + newApp: App, + ): Promise; - beforeDeleted(app: App): Promise; + beforeDeleted(manager: SessionManager, app: App): Promise; + + /** + * Called for each app before a bulk session deletion removes all app records. + * Use this to clean up external resources tied to the app (e.g. API keys). + */ + beforeSessionDeleted(manager: SessionManager, app: App): Promise; + + /** + * Called after reading an app from storage, before returning it to the caller. + * Use this to populate transient fields that are not persisted (e.g. secret values). + */ + enrich(manager: SessionManager, app: App): Promise; beforeSessionStart(app: App, session: WhatsappSession): void; diff --git a/src/apps/app_sdk/waha/WAHASelf.ts b/src/apps/app_sdk/waha/WAHASelf.ts index c4e06413..d277cff5 100644 --- a/src/apps/app_sdk/waha/WAHASelf.ts +++ b/src/apps/app_sdk/waha/WAHASelf.ts @@ -13,8 +13,7 @@ import { WANumberExistResult, } from '@waha/structures/chatting.dto'; import { SessionInfo } from '@waha/structures/sessions.dto'; -import axios, { AxiosInstance } from 'axios'; -import { Auth } from '@waha/core/auth/config'; +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import { ContactSortField } from '@waha/structures/contacts.dto'; import { PaginationParams } from '@waha/structures/pagination.dto'; @@ -25,9 +24,7 @@ export interface RequestOptions { export class WAHASelf { public client: AxiosInstance; - constructor() { - // Set 'X-Api-Key' - const key = Auth.keyplain.value; + constructor(public key: string) { const port = parseInt(process.env.PORT) || parseInt(process.env.WHATSAPP_API_PORT) || @@ -42,6 +39,10 @@ export class WAHASelf { }); } + request(config: AxiosRequestConfig) { + return this.client.request(config); + } + async fetch(url: string, opts?: RequestOptions): Promise { const response = await this.client.get(url, { responseType: 'arraybuffer', diff --git a/src/apps/apps.module.enabled.ts b/src/apps/apps.module.enabled.ts index 641a9848..b820ebd1 100644 --- a/src/apps/apps.module.enabled.ts +++ b/src/apps/apps.module.enabled.ts @@ -5,6 +5,7 @@ import { BullBoardModule } from '@bull-board/nestjs'; import { ExpressAdapter } from '@bull-board/express'; import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth'; import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module'; +import { McpModuleExports } from '@waha/apps/mcp/mcp.module'; import { AppsController } from '@waha/apps/app_sdk/api/apps.controller'; import { CallsAppExports } from '@waha/apps/calls/calls.module'; import { AppsService } from '@waha/apps/app_sdk/services/IAppsService'; @@ -83,6 +84,8 @@ function getAppModule(name: AppName) { return CallsAppExports; case AppName.chatwoot: return ChatWootExports; + case AppName.mcp: + return McpModuleExports; default: throw Error(`App module not found for ${name}`); } @@ -91,11 +94,13 @@ function getAppModule(name: AppName) { export const AppsEnabled = { imports: [ ...QUEUES_IMPORTS, + ...getAppModule(AppName.mcp).imports, ...getAppModule(AppName.chatwoot).imports, ...getAppModule(AppName.calls).imports, ], controllers: [ AppsController, + ...getAppModule(AppName.mcp).controllers, ...getAppModule(AppName.chatwoot).controllers, ...getAppModule(AppName.calls).controllers, ], @@ -104,6 +109,7 @@ export const AppsEnabled = { provide: AppsService, useClass: AppsEnabledService, }, + ...getAppModule(AppName.mcp).providers, ...getAppModule(AppName.calls).providers, ...getAppModule(AppName.chatwoot).providers, ], diff --git a/src/apps/calls/services/CallsAppService.ts b/src/apps/calls/services/CallsAppService.ts index a1014634..05d01054 100644 --- a/src/apps/calls/services/CallsAppService.ts +++ b/src/apps/calls/services/CallsAppService.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { App } from '@waha/apps/app_sdk/dto/app.dto'; import { IAppService } from '@waha/apps/app_sdk/services/IAppService'; import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto'; +import { SessionManager } from '@waha/core/abc/manager.abc'; import { WhatsappSession } from '@waha/core/abc/session.abc'; import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; import { CallsListener } from '@waha/apps/calls/services/CallsListener'; @@ -25,32 +26,64 @@ export class CallsAppService implements IAppService { } async beforeEnabled( + manager: SessionManager, savedApp: App, newApp: App, ): Promise { - // Enabling behaves the same as creating for this lightweight app. + void manager; void savedApp; void newApp; - return; } async beforeDisabled( + manager: SessionManager, savedApp: App, newApp: App, ): Promise { - void newApp; + void manager; void savedApp; + void newApp; } async beforeUpdated( + manager: SessionManager, savedApp: App, newApp: App, ): Promise { + void manager; void savedApp; void newApp; } - async beforeDeleted(app: App): Promise { + async beforeDeleted( + manager: SessionManager, + app: App, + ): Promise { + void manager; + void app; + } + + async afterCreated( + manager: SessionManager, + app: App, + ): Promise { + void manager; + void app; + } + + async beforeSessionDeleted( + manager: SessionManager, + app: App, + ): Promise { + void manager; + void app; + } + + async enrich( + manager: SessionManager, + app: App, + ): Promise { + void manager; void app; } diff --git a/src/apps/chatwoot/di/DIContainer.ts b/src/apps/chatwoot/di/DIContainer.ts index b79d22d5..3472c367 100644 --- a/src/apps/chatwoot/di/DIContainer.ts +++ b/src/apps/chatwoot/di/DIContainer.ts @@ -30,6 +30,7 @@ import { ConversationSelector, ConversationSort, } from '@waha/apps/chatwoot/services/ConversationSelector'; +import { Auth } from '@waha/core/auth/config'; /** * Dependency Injection Container for ChatWoot @@ -194,7 +195,7 @@ export class DIContainer { */ @CacheSync() public WAHASelf(): WAHASelf { - const self = new WAHASelf(); + const self = new WAHASelf(Auth.keyplain.value); const logging = new AxiosLogging(this.Logger()); logging.applyTo(self.client); return self; diff --git a/src/apps/chatwoot/services/ChatWootAppService.ts b/src/apps/chatwoot/services/ChatWootAppService.ts index dbf65102..ac779fd3 100644 --- a/src/apps/chatwoot/services/ChatWootAppService.ts +++ b/src/apps/chatwoot/services/ChatWootAppService.ts @@ -6,6 +6,7 @@ import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto'; import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService'; import { ChatWootWAHAQueueService } from '@waha/apps/chatwoot/services/ChatWootWAHAQueueService'; import { App } from '@waha/apps/chatwoot/storage'; +import { SessionManager } from '@waha/core/abc/manager.abc'; import { WhatsappSession } from '@waha/core/abc/session.abc'; import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; @@ -38,27 +39,31 @@ export class ChatWootAppService implements IAppService { } async beforeEnabled( + manager: SessionManager, savedApp: App, newApp: App, ): Promise { + void manager; // Enabling -> behave like created await this.beforeCreated(newApp); - return; } async beforeDisabled( + manager: SessionManager, savedApp: App, newApp: App, ): Promise { + void manager; // Disabling -> behave like deleted - await this.beforeDeleted(savedApp); - return; + await this.beforeDeleted(manager, savedApp); } async beforeUpdated( + manager: SessionManager, savedApp: App, newApp: App, ) { + void manager; const isTheSameUrl = savedApp.config.url === newApp.config.url; const isTheSameInboxId = savedApp.config.inboxId === newApp.config.inboxId; const isTheSameInbox = isTheSameUrl && isTheSameInboxId; @@ -76,7 +81,11 @@ export class ChatWootAppService implements IAppService { } } - async beforeDeleted(app: App): Promise { + async beforeDeleted( + manager: SessionManager, + app: App, + ): Promise { + void manager; await this.chatWootScheduleService.unschedule(app.id, app.session); this.cleanCache(app); this.sendDisconnectedMessage(app).catch((err) => { @@ -86,6 +95,30 @@ export class ChatWootAppService implements IAppService { }); } + async afterCreated( + manager: SessionManager, + app: App, + ): Promise { + void manager; + void app; + } + + async beforeSessionDeleted( + manager: SessionManager, + app: App, + ): Promise { + void manager; + void app; + } + + async enrich( + manager: SessionManager, + app: App, + ): Promise { + void manager; + void app; + } + private async sendConnectedMessage(app: App) { const di = new DIContainer(0, app.config, this.logger, null); const repo = di.ContactConversationService(); diff --git a/src/apps/mcp/api/mcp.controller.ts b/src/apps/mcp/api/mcp.controller.ts new file mode 100644 index 00000000..5c38ceb5 --- /dev/null +++ b/src/apps/mcp/api/mcp.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Post, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { McpService } from '../mcp.service'; + +@Controller('mcp') +export class McpController { + constructor(private readonly mcp: McpService) {} + + @Post() + post(@Req() req: Request, @Res() res: Response) { + return this.mcp.handlePost(req, res); + } +} diff --git a/src/apps/mcp/decorators/controller.ts b/src/apps/mcp/decorators/controller.ts new file mode 100644 index 00000000..aec7134d --- /dev/null +++ b/src/apps/mcp/decorators/controller.ts @@ -0,0 +1,34 @@ +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { AxiosRequestConfig } from 'axios'; +import { ImageMcpResponse, TextMcpResponse } from '@waha/apps/mcp/responses'; + +export class McpController { + constructor(protected readonly api: WAHASelf) {} + + protected async request(config: AxiosRequestConfig) { + const requestConfig = { ...config }; + requestConfig.validateStatus = () => true; + const response = await this.api.request(requestConfig); + return response; + } + + protected async textRequest(config: AxiosRequestConfig) { + const response = await this.request(config); + const responseText = + typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data); + return TextMcpResponse( + JSON.stringify({ status: response.status, response: responseText }), + ); + } + + protected async imageRequest(url: string) { + const response = await this.api.request({ + method: 'GET', + url: url, + responseType: 'arraybuffer', + }); + return ImageMcpResponse(Buffer.from(response.data)); + } +} diff --git a/src/apps/mcp/decorators/resource.ts b/src/apps/mcp/decorators/resource.ts new file mode 100644 index 00000000..fc3b099a --- /dev/null +++ b/src/apps/mcp/decorators/resource.ts @@ -0,0 +1,54 @@ +import type { ResourceMetadata } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; + +export const RESOURCES_KEY = Symbol('waha:mcp:resources'); +export const RESOURCE_TEMPLATES_KEY = Symbol('waha:mcp:resource-templates'); + +export interface ResourceDef { + name: string; + uri: string; + config: ResourceMetadata; + method: string; +} + +export interface ResourceTemplateDef { + name: string; + uriTemplate: string; + config: ResourceMetadata; + method: string; +} + +export function Resource( + name: string, + uri: string, + config: ResourceMetadata = {}, +) { + return (target: object, propertyKey: string) => { + const defs: ResourceDef[] = (target as any)[RESOURCES_KEY] ?? []; + defs.push({ name, uri, config, method: propertyKey }); + (target as any)[RESOURCES_KEY] = defs; + }; +} + +export function ResourceTemplate( + name: string, + uriTemplate: string, + config: ResourceMetadata = {}, +) { + return (target: object, propertyKey: string) => { + const defs: ResourceTemplateDef[] = + (target as any)[RESOURCE_TEMPLATES_KEY] ?? []; + defs.push({ name, uriTemplate, config, method: propertyKey }); + (target as any)[RESOURCE_TEMPLATES_KEY] = defs; + }; +} + +export function getResources(controller: McpController): ResourceDef[] { + return (controller as any)[RESOURCES_KEY] ?? []; +} + +export function getResourceTemplates( + controller: McpController, +): ResourceTemplateDef[] { + return (controller as any)[RESOURCE_TEMPLATES_KEY] ?? []; +} diff --git a/src/apps/mcp/decorators/tool.ts b/src/apps/mcp/decorators/tool.ts new file mode 100644 index 00000000..880f43f6 --- /dev/null +++ b/src/apps/mcp/decorators/tool.ts @@ -0,0 +1,23 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; + +export const TOOLS_KEY = Symbol('waha:mcp:tools'); +export type ToolConfig = Parameters[1]; + +export interface ToolDef { + name: string; + config: ToolConfig; + method: string; +} + +export function Tool(name: string, config: ToolConfig) { + return (target: object, propertyKey: string) => { + const defs: ToolDef[] = (target as any)[TOOLS_KEY] ?? []; + defs.push({ name, config, method: propertyKey }); + (target as any)[TOOLS_KEY] = defs; + }; +} + +export function getTools(controller: McpController): ToolDef[] { + return (controller as any)[TOOLS_KEY] ?? []; +} diff --git a/src/apps/mcp/dto/config.dto.ts b/src/apps/mcp/dto/config.dto.ts new file mode 100644 index 00000000..314ec8d3 --- /dev/null +++ b/src/apps/mcp/dto/config.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsDefined, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { SessionActionsDTO } from '@waha/structures/apikeys.dto'; + +export class McpAppConfig { + @ApiProperty({ + type: SessionActionsDTO, + description: 'Permission scopes for the generated API key.', + }) + @IsDefined() + @ValidateNested() + @Type(() => SessionActionsDTO) + actions: SessionActionsDTO; + + @ApiProperty({ + example: 'key_id_00000000000000000000000000', + required: false, + nullable: true, + readOnly: true, + description: 'ID of the API key created for this app. Read-only.', + }) + @IsOptional() + @IsString() + key_id?: string; + + @ApiProperty({ + example: 'key_11111111111AAAAAAAAAAAAAAAAAAAAA', + required: false, + nullable: true, + readOnly: true, + description: + 'The API key value. Populated on read; not persisted in this record.', + }) + @IsOptional() + @IsString() + key?: string; +} diff --git a/src/apps/mcp/mcp.module.ts b/src/apps/mcp/mcp.module.ts new file mode 100644 index 00000000..d784f459 --- /dev/null +++ b/src/apps/mcp/mcp.module.ts @@ -0,0 +1,9 @@ +import { McpController } from '@waha/apps/mcp/api/mcp.controller'; +import { McpService } from '@waha/apps/mcp/mcp.service'; +import { McpAppService } from '@waha/apps/mcp/services/McpAppService'; + +export const McpModuleExports = { + imports: [], + controllers: [McpController], + providers: [McpService, McpAppService], +}; diff --git a/src/apps/mcp/mcp.server.ts b/src/apps/mcp/mcp.server.ts new file mode 100644 index 00000000..6d90638a --- /dev/null +++ b/src/apps/mcp/mcp.server.ts @@ -0,0 +1,64 @@ +import { + McpServer, + ResourceTemplate as SdkResourceTemplate, +} from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { VERSION } from '@waha/version'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { getTools } from '@waha/apps/mcp/decorators/tool'; +import { + getResources, + getResourceTemplates, +} from '@waha/apps/mcp/decorators/resource'; + +export class WMcpServer { + private readonly mcp: McpServer; + + constructor(controllers: McpController[]) { + this.mcp = new McpServer({ + name: 'whatsapp-server-mcp', + version: `${VERSION.version} (${VERSION.engine}, ${VERSION.tier}, ${VERSION.platform})`, + }); + + for (const controller of controllers) { + for (const { name, config, method } of getTools(controller)) { + this.mcp.registerTool( + name, + config, + (controller as any)[method].bind(controller), + ); + } + + for (const { name, uri, config, method } of getResources(controller)) { + this.mcp.registerResource( + name, + uri, + config, + (controller as any)[method].bind(controller), + ); + } + + for (const { name, uriTemplate, config, method } of getResourceTemplates( + controller, + )) { + const template = new SdkResourceTemplate(uriTemplate, { + list: undefined, + }); + this.mcp.registerResource( + name, + template, + config, + (controller as any)[method].bind(controller), + ); + } + } + } + + connect(transport: Transport) { + return this.mcp.connect(transport); + } + + close() { + return this.mcp.close(); + } +} diff --git a/src/apps/mcp/mcp.service.ts b/src/apps/mcp/mcp.service.ts new file mode 100644 index 00000000..c3f4482a --- /dev/null +++ b/src/apps/mcp/mcp.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { Request, Response } from 'express'; +import { WMcpServer } from './mcp.server'; +import { SendTools } from './tools/send.tools'; +import { AuthTools } from '@waha/apps/mcp/tools/auth.tools'; +import { SessionTools } from '@waha/apps/mcp/tools/sessions.tools'; +import { ChatTools } from '@waha/apps/mcp/tools/chats.tools'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { ApiTools } from '@waha/apps/mcp/tools/api.tools'; +import { CallTools } from '@waha/apps/mcp/tools/calls.tools'; +import { ChannelTools } from '@waha/apps/mcp/tools/channels.tools'; +import { ContactTools } from '@waha/apps/mcp/tools/contacts.tools'; +import { GroupTools } from '@waha/apps/mcp/tools/groups.tools'; +import { LabelTools } from '@waha/apps/mcp/tools/labels.tools'; +import { LidTools } from '@waha/apps/mcp/tools/lids.tools'; +import { PingTools } from '@waha/apps/mcp/tools/ping.tools'; +import { PresenceTools } from '@waha/apps/mcp/tools/presence.tools'; +import { ProfileTools } from '@waha/apps/mcp/tools/profile.tools'; +import { StatusTools } from '@waha/apps/mcp/tools/status.tools'; +import { ServerTools } from '@waha/apps/mcp/tools/server.tools'; + +@Injectable() +export class McpService { + async handlePost(req: Request, res: Response) { + const apiKey = req.headers['x-api-key'] as string | undefined; + const api = new WAHASelf(apiKey); + const server = new WMcpServer([ + new PingTools(api), + new SendTools(api), + new AuthTools(api), + new SessionTools(api), + new ChatTools(api), + new ApiTools(api), + new CallTools(api), + new ChannelTools(api), + new ContactTools(api), + new GroupTools(api), + new LabelTools(api), + new LidTools(api), + new PresenceTools(api), + new ProfileTools(api), + new StatusTools(api), + new ServerTools(api), + ]); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless + }); + + res.on('close', () => { + transport.close(); + server.close(); + }); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } +} diff --git a/src/apps/mcp/responses.ts b/src/apps/mcp/responses.ts new file mode 100644 index 00000000..9a78da82 --- /dev/null +++ b/src/apps/mcp/responses.ts @@ -0,0 +1,31 @@ +export function TextMcpResponse(text: string) { + return { content: [{ type: 'text' as const, text: text }] }; +} + +export function JsonMcpToolResponse(value: unknown) { + return TextMcpResponse(JSON.stringify(value)); +} + +export function ImageMcpResponse(buffer: Buffer) { + return { + content: [ + { + type: 'image' as const, + data: buffer.toString('base64'), + mimeType: 'image/png', + }, + ], + }; +} + +export function JsonMcpResponse(uri: URL, data: any) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'application/json', + text: JSON.stringify(data), + }, + ], + }; +} diff --git a/src/apps/mcp/schemas/DtoToZod.test.ts b/src/apps/mcp/schemas/DtoToZod.test.ts new file mode 100644 index 00000000..f7dfd3f4 --- /dev/null +++ b/src/apps/mcp/schemas/DtoToZod.test.ts @@ -0,0 +1,605 @@ +import 'reflect-metadata'; + +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsIn, + IsNumber, + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { z } from 'zod'; + +import { DtoToZod } from './DtoToZod'; + +function expectSchemasToEqual(schema1: z.ZodType, schema2: z.ZodType) { + expect(z.toJSONSchema(schema1)).toEqual(z.toJSONSchema(schema2)); +} + +describe('DtoToZod', () => { + describe('primitive types', () => { + it('converts @IsString to z.string()', () => { + class Dto { + @IsString() + name: string; + } + + const Expected = z.object({ + name: z.string(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('converts @IsNumber to z.number()', () => { + class Dto { + @IsNumber() + count: number; + } + + const Expected = z.object({ + count: z.number(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('converts @IsBoolean to z.boolean()', () => { + class Dto { + @IsBoolean() + flag: boolean; + } + + const Expected = z.object({ + flag: z.boolean(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('optional fields', () => { + it('marks @IsOptional fields as optional', () => { + class Dto { + @IsNumber() + @IsOptional() + limit?: number; + } + + const Expected = z.object({ + limit: z.number().optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('marks @ApiPropertyOptional fields as optional', () => { + class Dto { + @IsString() + @ApiPropertyOptional({ description: 'Optional name' }) + name?: string; + } + + const Expected = z.object({ + name: z.string().optional().describe('Optional name'), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('marks @ApiProperty({ required: false }) fields as optional', () => { + class Dto { + @IsBoolean() + @ApiProperty({ required: false }) + active?: boolean; + } + + const Expected = z.object({ + active: z.boolean().optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('required fields fail without value', () => { + class Dto { + @IsString() + name: string; + } + + const Expected = z.object({ + name: z.string(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('default values', () => { + it('picks up default from class field initializer', () => { + class Dto { + @IsBoolean() + @IsOptional() + active?: boolean = true; + } + + const Expected = z.object({ + active: z.boolean().optional().default(true), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('picks up default from @ApiProperty', () => { + class Dto { + @IsBoolean() + @IsOptional() + @ApiProperty({ default: false, description: 'Enable feature' }) + enabled?: boolean; + } + + const Expected = z.object({ + enabled: z + .boolean() + .optional() + .default(false) + .describe('Enable feature'), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('does not apply default to required fields', () => { + class Dto { + @IsNumber() + count: number; + } + + const Expected = z.object({ + count: z.number(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('prefers @ApiProperty default over instance default', () => { + class Dto { + @IsNumber() + @IsOptional() + @ApiProperty({ default: 99 }) + val?: number = 1; + } + + const Expected = z.object({ + val: z.number().optional().default(99), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('descriptions', () => { + it('applies @ApiProperty description to required field', () => { + class Dto { + @IsString() + @ApiProperty({ description: 'Session identifier' }) + session: string; + } + + const Expected = z.object({ + session: z.string().describe('Session identifier'), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('applies description to optional field with default', () => { + class Dto { + @IsBoolean() + @IsOptional() + @ApiProperty({ + description: 'Include stopped sessions', + default: false, + }) + all?: boolean; + } + + const Expected = z.object({ + all: z + .boolean() + .optional() + .default(false) + .describe('Include stopped sessions'), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('string refinements', () => { + it('applies @IsUrl as .url()', () => { + class Dto { + @IsUrl() + webhookUrl: string; + } + + const Expected = z.object({ + webhookUrl: z.string().url(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('applies @MaxLength', () => { + class Dto { + @IsString() + @MaxLength(10) + name: string; + } + + const Expected = z.object({ + name: z.string().max(10), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('applies @Matches regex', () => { + class Dto { + @IsString() + @Matches(/^[a-z]+$/) + slug: string; + } + + const Expected = z.object({ + slug: z.string().regex(/^[a-z]+$/), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('chains multiple string refinements', () => { + class Dto { + @IsString() + @MaxLength(54) + @Matches(/^[a-zA-Z0-9_-]*$/) + sessionName: string; + } + + const Expected = z.object({ + sessionName: z + .string() + .max(54) + .regex(/^[a-zA-Z0-9_-]*$/), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('enums', () => { + it('converts string enum with @IsEnum', () => { + enum Color { + RED = 'red', + GREEN = 'green', + BLUE = 'blue', + } + + class Dto { + @IsEnum(Color) + color: Color; + } + + const Expected = z.object({ + color: z.nativeEnum(Color), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('converts numeric enum with @IsEnum', () => { + enum Priority { + LOW = 1, + MEDIUM = 2, + HIGH = 3, + } + + class Dto { + @IsEnum(Priority) + @IsOptional() + priority?: Priority; + } + + const Expected = z.object({ + priority: z.nativeEnum(Priority).optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('@IsIn union', () => { + it('creates union of number literals from @IsIn', () => { + class Dto { + @IsIn([86400, 604800, 2592000]) + duration: number; + } + + const Expected = z.object({ + duration: z.number(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('creates union of string literals from @IsIn', () => { + class Dto { + @IsIn(['asc', 'desc']) + @IsOptional() + @ApiProperty({ default: 'desc' }) + order?: string; + } + + const Expected = z.object({ + order: z.string().optional().default('desc'), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('nested objects', () => { + it('converts @ValidateNested + @Type to nested ZodObject', () => { + class AddressDto { + @IsString() + @ApiProperty({ description: 'City name' }) + city: string; + } + + class Dto { + @ValidateNested() + @Type(() => AddressDto) + @IsOptional() + address?: AddressDto; + } + + const Expected = z.object({ + address: z + .object({ + city: z.string().describe('City name'), + }) + .optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('propagates descriptions from nested DTO fields', () => { + class AddressDto { + @IsString() + @ApiProperty({ description: 'City name' }) + city: string; + } + + class Dto { + @ValidateNested() + @Type(() => AddressDto) + @IsOptional() + address?: AddressDto; + } + + const Expected = z.object({ + address: z + .object({ + city: z.string().describe('City name'), + }) + .optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('arrays', () => { + it('converts @IsArray + @IsString({ each }) to z.array(z.string())', () => { + class Dto { + @IsArray() + @IsString({ each: true }) + tags: string[]; + } + + const Expected = z.object({ + tags: z.array(z.string()), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('converts array of nested DTOs', () => { + class ItemDto { + @IsString() + label: string; + } + + class Dto { + @ValidateNested({ each: true }) + @Type(() => ItemDto) + @IsArray() + @IsOptional() + items?: ItemDto[]; + } + + const Expected = z.object({ + items: z + .array( + z.object({ + label: z.string(), + }), + ) + .optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('converts @IsArray + @IsEnum({ each }) to array of enum', () => { + enum Status { + ACTIVE = 'active', + INACTIVE = 'inactive', + } + + class Dto { + @IsArray() + @IsEnum(Status, { each: true }) + @IsOptional() + statuses?: Status[]; + } + + const Expected = z.object({ + statuses: z.array(z.nativeEnum(Status)).optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('plugin-generated fields (undecorated)', () => { + it('handles required undecorated field', () => { + class Dto { + field: string; + static _OPENAPI_METADATA_FACTORY() { + return { field: { required: true, type: () => String } }; + } + } + + const Expected = z.object({ + field: z.string(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('handles optional undecorated field', () => { + class Dto { + field?: string; + static _OPENAPI_METADATA_FACTORY() { + return { field: { required: false, type: () => String } }; + } + } + + const Expected = z.object({ + field: z.string().optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('handles optional number with default from factory', () => { + class Dto { + count?: number; + static _OPENAPI_METADATA_FACTORY() { + return { + count: { required: false, type: () => Number, default: 10 }, + }; + } + } + + const Expected = z.object({ + count: z.number().optional().default(10), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('handles optional boolean with description from factory', () => { + class Dto { + verbose?: boolean; + static _OPENAPI_METADATA_FACTORY() { + return { + verbose: { + required: false, + type: () => Boolean, + description: 'Enable verbose output', + }, + }; + } + } + + const Expected = z.object({ + verbose: z.boolean().optional().describe('Enable verbose output'), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('handles array field from factory', () => { + class Dto { + tags: string[]; + static _OPENAPI_METADATA_FACTORY() { + return { tags: { required: true, type: () => [String] } }; + } + } + + const Expected = z.object({ + tags: z.array(z.string()), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + + it('handles mix of decorated and undecorated fields', () => { + class Dto { + @IsString() + name: string; + + age?: number; + static _OPENAPI_METADATA_FACTORY() { + return { age: { required: false, type: () => Number } }; + } + } + + const Expected = z.object({ + name: z.string(), + age: z.number().optional(), + }); + expectSchemasToEqual(DtoToZod(Dto), Expected); + }); + }); + + describe('inheritance', () => { + it('includes parent class fields', () => { + class BaseDto { + @IsString() + @ApiProperty({ description: 'Unique ID' }) + id: string; + } + + class ChildDto extends BaseDto { + @IsNumber() + @IsOptional() + count?: number; + } + + const Expected = z.object({ + id: z.string().describe('Unique ID'), + count: z.number().optional(), + }); + expectSchemasToEqual(DtoToZod(ChildDto), Expected); + }); + + it('inherits parent field types and descriptions', () => { + class BaseDto { + @IsString() + @ApiProperty({ description: 'Unique ID' }) + id: string; + } + + class ChildDto extends BaseDto { + @IsNumber() + @IsOptional() + count?: number; + } + + const Expected = z.object({ + id: z.string().describe('Unique ID'), + count: z.number().optional(), + }); + expectSchemasToEqual(DtoToZod(ChildDto), Expected); + }); + + it('parent required fields remain required in child', () => { + class BaseDto { + @IsString() + id: string; + } + + class ChildDto extends BaseDto { + @IsNumber() + @IsOptional() + count?: number; + } + + const Expected = z.object({ + id: z.string(), + count: z.number().optional(), + }); + expectSchemasToEqual(DtoToZod(ChildDto), Expected); + }); + }); +}); diff --git a/src/apps/mcp/schemas/DtoToZod.ts b/src/apps/mcp/schemas/DtoToZod.ts new file mode 100644 index 00000000..1be00937 --- /dev/null +++ b/src/apps/mcp/schemas/DtoToZod.ts @@ -0,0 +1,416 @@ +import 'reflect-metadata'; + +import { defaultMetadataStorage } from 'class-transformer/cjs/storage'; +import { getMetadataStorage } from 'class-validator'; +import { z } from 'zod'; + +// ────────────────────────────────────────────────────────────────────────────── +// Public types +// ────────────────────────────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructor = new (...args: any[]) => T; + +export type DtoShape = { + [K in keyof T & string]: z.ZodType; +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Internal metadata interfaces +// ────────────────────────────────────────────────────────────────────────────── + +interface CvConstraint { + type: string; + name: string; + propertyName: string; + constraints: unknown[]; + each: boolean; +} + +// Built-in decorators (@IsString, @IsArray, etc.) use type='customValidation' with +// the real name in `name`. Special decorators (@IsOptional, @ValidateNested) use +// a descriptive `type` directly with no `name`. +function constraintKey(c: CvConstraint): string { + return c.type === 'customValidation' ? c.name : c.type; +} + +interface TypeMeta { + typeFunction?: () => Constructor; + propertyName: string; +} + +interface ApiPropertyMeta { + description?: string; + default?: unknown; + // Explicitly set via @ApiPropertyOptional() or @ApiProperty({ required: false }) + required?: boolean; + // design:type merged by NestJS Swagger at decoration time — already a constructor + type?: unknown; + isArray?: boolean; +} + +// Shape of entries returned by the _OPENAPI_METADATA_FACTORY static method that +// the @nestjs/swagger compiler plugin generates for every DTO property. +interface FactoryPropertyMeta { + required?: boolean; + // Lazy constructor ref: `() => String`, or array notation: `() => [String]` + type?: () => unknown; + default?: unknown; + description?: string; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Constants +// ────────────────────────────────────────────────────────────────────────────── + +// class-validator constraint type names +const CV_IS_STRING = 'isString'; +const CV_IS_BOOLEAN = 'isBoolean'; +const CV_IS_NUMBER = 'isNumber'; +const CV_IS_INT = 'isInt'; +const CV_IS_ARRAY = 'isArray'; +const CV_IS_ENUM = 'isEnum'; +const CV_NESTED = 'nestedValidation'; +const CV_OPTIONAL = 'conditionalValidation'; // @IsOptional / @ValidateIf +const CV_IS_URL = 'isUrl'; +const CV_MAX_LENGTH = 'maxLength'; +const CV_MATCHES = 'matches'; +const CV_IS_IN = 'isIn'; + +// NestJS Swagger reflect-metadata keys (stable since @nestjs/swagger v3) +const SW_PROPS = 'swagger/apiModelProperties'; +const SW_PROPS_ARRAY = 'swagger/apiModelPropertiesArray'; +// Static method injected by the @nestjs/swagger compiler plugin on every DTO +const METADATA_FACTORY_NAME = '_OPENAPI_METADATA_FACTORY'; + +function collectPropertyNames(cls: Constructor): string[] { + const names = new Set(); + + // @ApiProperty-decorated properties — walk prototype chain + let proto: object = cls.prototype; + while (proto && proto !== Object.prototype) { + const arr = + (Reflect.getMetadata(SW_PROPS_ARRAY, proto) as string[] | undefined) ?? + []; + for (const entry of arr) { + names.add(entry.startsWith(':') ? entry.slice(1) : entry); + } + proto = Object.getPrototypeOf(proto) as object; + } + + // class-validator metadata (handles inheritance internally) + const cvAll = getMetadataStorage().getTargetValidationMetadatas( + cls, + '', + false, + false, + ) as CvConstraint[]; + for (const meta of cvAll) { + names.add(meta.propertyName); + } + + // _OPENAPI_METADATA_FACTORY — plugin-generated, covers undecorated properties + for (const key of Object.keys(getClassFactoryMeta(cls))) { + names.add(key); + } + + return [...names]; +} + +// Reads the _OPENAPI_METADATA_FACTORY static method walking the constructor chain +// so that parent-class factory entries are merged (child takes precedence). +function getClassFactoryMeta( + cls: Constructor, +): Record { + const result: Record = {}; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + let ctor: Function = cls; + while (ctor && ctor !== Object) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const factory = (ctor as any)[METADATA_FACTORY_NAME] as unknown; + if (typeof factory === 'function') { + const meta = (factory as () => Record)(); + for (const [key, val] of Object.entries(meta)) { + if (!(key in result)) result[key] = val; + } + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + ctor = Object.getPrototypeOf(ctor) as Function; + } + return result; +} + +function getConstraints(cls: Constructor, prop: string): CvConstraint[] { + const all = getMetadataStorage().getTargetValidationMetadatas( + cls, + '', + false, + false, + ) as CvConstraint[]; + return all.filter((m) => m.propertyName === prop); +} + +// Walks the constructor chain because class-transformer stores type metadata +// on the exact constructor where @Type() was declared. +function getTypeMeta(cls: Constructor, prop: string): TypeMeta | undefined { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + let ctor: Function = cls; + while (ctor && ctor !== Object && typeof ctor.prototype !== 'undefined') { + const meta = defaultMetadataStorage.findTypeMetadata(ctor, prop) as + | TypeMeta + | undefined; + if (meta?.typeFunction) return meta; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + ctor = Object.getPrototypeOf(ctor) as Function; + } + return undefined; +} + +function getApiPropertyMeta( + cls: Constructor, + prop: string, +): ApiPropertyMeta | undefined { + let proto: object = cls.prototype; + while (proto && proto !== Object.prototype) { + const meta = Reflect.getMetadata(SW_PROPS, proto, prop) as + | ApiPropertyMeta + | undefined; + if (meta !== undefined) return meta; + proto = Object.getPrototypeOf(proto) as object; + } + return undefined; +} + +function getDesignType(cls: Constructor, prop: string): unknown { + let proto: object = cls.prototype; + while (proto && proto !== Object.prototype) { + const type = Reflect.getMetadata('design:type', proto, prop); + if (type !== undefined) return type; + proto = Object.getPrototypeOf(proto) as object; + } + return undefined; +} + +function getInstanceDefault(cls: Constructor, prop: string): unknown { + try { + const instance = new cls() as Record; + const val = instance[prop]; + return val !== undefined ? val : undefined; + } catch { + return undefined; + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Schema builders +// ────────────────────────────────────────────────────────────────────────────── + +function inferFromDesignType(type: unknown): z.ZodTypeAny { + if (type === String) return z.string(); + if (type === Boolean) return z.boolean(); + if (type === Number) return z.number(); + if (type === Array) return z.array(z.unknown()); + // Record<*, *> and plain objects — TypeScript emits Object for both + if (type === Object) return z.record(z.string(), z.unknown()); + return z.unknown(); +} + +function buildStringSchema(cs: CvConstraint[]): z.ZodString { + let schema = z.string(); + for (const c of cs) { + const key = constraintKey(c); + if (key === CV_IS_URL) { + schema = schema.url(); + } else if (key === CV_MAX_LENGTH) { + schema = schema.max(c.constraints[0] as number); + } else if (key === CV_MATCHES) { + schema = schema.regex(c.constraints[0] as RegExp); + } + } + return schema; +} + +function buildEnumSchema(cs: CvConstraint[]): z.ZodTypeAny { + const c = cs.find((m) => constraintKey(m) === CV_IS_ENUM); + if (!c) return z.string(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return z.nativeEnum(c.constraints[0] as any); +} + +// function buildIsInSchema(cs: CvConstraint[]): z.ZodTypeAny { +// const c = cs.find((m) => m.type === CV_IS_IN); +// if (!c) return z.unknown(); +// +// const values = c.constraints[0] as unknown[]; +// if (values.length === 0) return z.unknown(); +// +// const [first, second, ...rest] = values.map((v) => z.literal(v)); +// return z.union([ +// first, +// second, +// ...rest, +// ] as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]); +// } + +// Determines the (element) Zod type from a set of constraints. +// Returns undefined when the constraints do not specify a type. +function resolveElementSchema( + cls: Constructor, + prop: string, + cs: CvConstraint[], +): z.ZodTypeAny | undefined { + const types = new Set(cs.map(constraintKey)); + + if (types.has(CV_NESTED)) { + const meta = getTypeMeta(cls, prop); + if (meta?.typeFunction) return DtoToZod(meta.typeFunction()); + return z.record(z.string(), z.unknown()); + } + + // if (types.has(CV_IS_IN)) return buildIsInSchema(cs); + if (types.has(CV_IS_ENUM)) return buildEnumSchema(cs); + + const isStringLike = + types.has(CV_IS_STRING) || + types.has(CV_IS_URL) || + types.has(CV_MAX_LENGTH) || + types.has(CV_MATCHES); + if (isStringLike) return buildStringSchema(cs); + + if (types.has(CV_IS_BOOLEAN)) return z.boolean(); + if (types.has(CV_IS_NUMBER) || types.has(CV_IS_INT)) return z.number(); + + return undefined; +} + +// Falls back to @Type() metadata when element constraints are absent (e.g. array +// with only @Type(() => SomeClass) and @ValidateNested({ each: true }), or arrays +// of primitives decorated only with @Type(() => Number)). +function resolveElementFromTypeMeta( + cls: Constructor, + prop: string, +): z.ZodTypeAny | undefined { + const meta = getTypeMeta(cls, prop); + if (!meta?.typeFunction) return undefined; + + const ctor = meta.typeFunction(); + if (ctor === String) return z.string(); + if (ctor === Number) return z.number(); + if (ctor === Boolean) return z.boolean(); + return DtoToZod(ctor); +} + +// Uses the `type` stored in @ApiProperty() metadata, which NestJS Swagger +// populates from design:type at decoration time. Handles constructor refs, +// lazy () => Class functions, and primitive string type names. +function resolveFromSwaggerType(type: unknown): z.ZodTypeAny | undefined { + if (!type) return undefined; + + // Lazy type function: @ApiProperty({ type: () => SomeClass }) or factory type: () => X + if (typeof type === 'function' && type.name === 'type') { + return resolveFromSwaggerType((type as () => unknown)()); + } + + // Array notation [Constructor]: from _OPENAPI_METADATA_FACTORY type: () => [String] + if (Array.isArray(type) && (type as unknown[]).length === 1) { + const elem = resolveFromSwaggerType((type as unknown[])[0]); + return z.array(elem ?? z.unknown()); + } + + if (type === String) return z.string(); + if (type === Boolean) return z.boolean(); + if (type === Number) return z.number(); + if (type === Array) return z.array(z.unknown()); + if (type === Object) return z.record(z.string(), z.unknown()); + + // String type names (e.g. 'string', 'number', 'boolean') from explicit @ApiProperty({ type: 'string' }) + if (type === 'string') return z.string(); + if (type === 'number') return z.number(); + if (type === 'boolean') return z.boolean(); + if (type === 'integer') return z.number().int(); + + // Class constructor — recurse + if (typeof type === 'function') { + try { + return DtoToZod(type as Constructor); + } catch { + return undefined; + } + } + + return undefined; +} + +function buildPropertySchema( + cls: Constructor, + prop: string, + cs: CvConstraint[], + apiMeta: ApiPropertyMeta | undefined, + factoryMeta: FactoryPropertyMeta | undefined, +): z.ZodTypeAny { + const ownCs = cs.filter((c) => !c.each); + const ownTypes = new Set(ownCs.map(constraintKey)); + + // Detect array from class-validator @IsArray() or from @ApiProperty({ isArray: true }) + const isArray = ownTypes.has(CV_IS_ARRAY) || apiMeta?.isArray === true; + + if (isArray) { + const eachCs = cs.filter((c) => c.each); + const elemSchema = + resolveElementSchema(cls, prop, eachCs) ?? + resolveElementFromTypeMeta(cls, prop) ?? + z.unknown(); + return z.array(elemSchema); + } + + return ( + resolveElementSchema(cls, prop, ownCs) ?? + resolveFromSwaggerType(apiMeta?.type) ?? + resolveFromSwaggerType(factoryMeta?.type) ?? + inferFromDesignType(getDesignType(cls, prop)) + ); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Public API +// ────────────────────────────────────────────────────────────────────────────── + +export function DtoToZod(cls: Constructor): z.ZodObject> { + const shape: Record = {}; + const factoryAll = getClassFactoryMeta(cls); + + for (const prop of collectPropertyNames(cls)) { + const cs = getConstraints(cls, prop); + const apiMeta = getApiPropertyMeta(cls, prop); + const factoryMeta = factoryAll[prop]; + + // @IsOptional() registers as conditionalValidation; @ApiPropertyOptional() + // / @ApiProperty({ required: false }) sets required: false on the swagger meta. + // Factory required: false covers undecorated optional fields (plugin-generated). + const isOptional = + cs.some((c) => constraintKey(c) === CV_OPTIONAL) || + apiMeta?.required === false || + factoryMeta?.required === false; + + let schema = buildPropertySchema(cls, prop, cs, apiMeta, factoryMeta); + + if (isOptional) { + schema = schema.optional(); + + const defaultVal = + apiMeta?.default ?? + factoryMeta?.default ?? + getInstanceDefault(cls, prop); + if (defaultVal !== undefined) { + schema = (schema as z.ZodOptional).default(defaultVal); + } + } + + const desc = apiMeta?.description ?? factoryMeta?.description; + if (desc) schema = schema.describe(desc); + + shape[prop] = schema; + } + + return z.object(shape) as unknown as z.ZodObject>; +} diff --git a/src/apps/mcp/services/McpAppService.ts b/src/apps/mcp/services/McpAppService.ts new file mode 100644 index 00000000..38a6970d --- /dev/null +++ b/src/apps/mcp/services/McpAppService.ts @@ -0,0 +1,183 @@ +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { App } from '@waha/apps/app_sdk/dto/app.dto'; +import { IAppService } from '@waha/apps/app_sdk/services/IAppService'; +import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository'; +import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto'; +import { SessionManager } from '@waha/core/abc/manager.abc'; +import { ApiKeyService } from '@waha/core/services/ApiKeyService'; +import { WhatsappSession } from '@waha/core/abc/session.abc'; +import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; + +@Injectable() +export class McpAppService implements IAppService { + constructor( + @InjectPinoLogger('McpAppService') + private readonly logger: PinoLogger, + ) {} + + validate(app: App): void { + if (!app.config) { + app.config = new McpAppConfig(); + } + delete app.config.key_id; + delete app.config.key; + } + + async beforeCreated(app: App): Promise { + void app; + } + + async afterCreated( + manager: SessionManager, + app: App, + ): Promise { + const keyDto = await new ApiKeyService(manager).createForApp( + { + isAdmin: false, + session: app.session, + isActive: true, + actions: app.config?.actions ?? null, + }, + app.id, + ); + const updatedConfig = { + ...(app.config ?? {}), + key_id: keyDto.id, + } as McpAppConfig; + const repo = new AppRepository(manager.store.getWAHADatabase()); + await repo.update(app.id, { config: updatedConfig }); + app.config = updatedConfig; + } + + async beforeEnabled( + manager: SessionManager, + savedApp: App, + newApp: App, + ): Promise { + await this.requireKeyExists(manager, savedApp); + await this.syncKeyActions(manager, savedApp, newApp); + await this.setKeyActive(manager, savedApp, true); + newApp.config = { + ...(newApp.config ?? {}), + key_id: savedApp.config?.key_id, + } as McpAppConfig; + } + + async beforeDisabled( + manager: SessionManager, + savedApp: App, + newApp: App, + ): Promise { + await this.setKeyActive(manager, savedApp, false); + newApp.config = { + ...(newApp.config ?? {}), + key_id: savedApp.config?.key_id, + } as McpAppConfig; + } + + async beforeUpdated( + manager: SessionManager, + savedApp: App, + newApp: App, + ): Promise { + await this.requireKeyExists(manager, savedApp); + await this.syncKeyActions(manager, savedApp, newApp); + newApp.config = { + ...(newApp.config ?? {}), + key_id: savedApp.config?.key_id, + } as McpAppConfig; + } + + async beforeDeleted( + manager: SessionManager, + app: App, + ): Promise { + await this.deleteKey(manager, app); + } + + async beforeSessionDeleted( + manager: SessionManager, + app: App, + ): Promise { + await this.deleteKey(manager, app); + } + + async enrich(manager: SessionManager, app: App): Promise { + const keyId = app.config?.key_id; + if (!keyId) { + return; + } + const keyDto = await new ApiKeyService(manager).getById(keyId); + if (!keyDto) { + return; + } + app.config = { ...(app.config ?? {}), key: keyDto.key } as McpAppConfig; + } + + beforeSessionStart(app: App, session: WhatsappSession): void { + void app; + void session; + } + + afterSessionStart(app: App, session: WhatsappSession): void { + void app; + void session; + } + + private async requireKeyExists( + manager: SessionManager, + app: App, + ): Promise { + const keyId = app.config?.key_id; + if (!keyId) { + throw new UnprocessableEntityException( + 'MCP app has no associated API key. Delete this app and create a new one.', + ); + } + const existing = await new ApiKeyService(manager).getById(keyId); + if (!existing) { + throw new UnprocessableEntityException( + `The API key for this MCP app no longer exists. Delete this app and create a new one.`, + ); + } + } + + private async syncKeyActions( + manager: SessionManager, + savedApp: App, + newApp: App, + ): Promise { + const keyId = savedApp.config?.key_id; + if (!keyId) { + return; + } + await new ApiKeyService(manager).updateForApp(keyId, { + actions: newApp.config?.actions ?? null, + }); + } + + private async setKeyActive( + manager: SessionManager, + app: App, + isActive: boolean, + ): Promise { + const keyId = app.config?.key_id; + if (!keyId) { + return; + } + await new ApiKeyService(manager).updateForApp(keyId, { + isActive: isActive, + }); + } + + private async deleteKey( + manager: SessionManager, + app: App, + ): Promise { + const keyId = app.config?.key_id; + if (!keyId) { + return; + } + await new ApiKeyService(manager).deleteForApp(keyId); + } +} diff --git a/src/apps/mcp/tools/api.tools.ts b/src/apps/mcp/tools/api.tools.ts new file mode 100644 index 00000000..f352efeb --- /dev/null +++ b/src/apps/mcp/tools/api.tools.ts @@ -0,0 +1,63 @@ +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { APIInput } from '@waha/apps/mcp/tools/api.zod'; +import { Auth } from '@waha/core/auth/config'; +import { z } from 'zod'; + +export class ApiTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('api-openapi', { + title: 'Get OpenAPI schema for HTTP API', + description: + 'Get the latest OpenAPI schema for the HTTP API available to call in the "api-call" tool. ' + + 'Use it as a last option if no native tools are found. ' + + 'Find appropriate /api/send.* methods to send messages to direct chats (lid, c.us), groups (g.us) and channels/newsletter (@newsletter). ' + + 'Use api/:session/status to send status.', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async openapi() { + const headers: Record = {}; + const swaggerUser = Auth.swagger.username.value; + const swaggerPassword = Auth.swagger.password.value; + if (swaggerUser && swaggerPassword) { + const encoded = Buffer.from(`${swaggerUser}:${swaggerPassword}`).toString( + 'base64', + ); + headers['Authorization'] = `Basic ${encoded}`; + } + return this.textRequest({ + method: 'GET', + url: '/-json', + headers: headers, + }); + } + + @Tool('api-call', { + title: 'Call HTTP API', + description: + 'Call HTTP API for certain method if no native MCP tools available. ' + + 'Before calling fetch "api-openapi" tool to get the latest openapi spec. ' + + 'Use it as last option if no native tools are found.', + inputSchema: APIInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async call({ path, method, body }: z.infer) { + return this.textRequest({ + method: method, + url: path, + data: body, + }); + } +} diff --git a/src/apps/mcp/tools/api.zod.ts b/src/apps/mcp/tools/api.zod.ts new file mode 100644 index 00000000..441b7b99 --- /dev/null +++ b/src/apps/mcp/tools/api.zod.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const APIInput = z.object({ + path: z + .string() + .describe( + 'API path to call, e.g: /api/sessions&query=here, include query in path, no host and protocol required.', + ), + method: z + .enum(['GET', 'POST', 'DELETE', 'PATCH']) + .describe('API method to call'), + body: z.any().optional().describe('Body if required by openapi spec'), +}); diff --git a/src/apps/mcp/tools/auth.tools.ts b/src/apps/mcp/tools/auth.tools.ts new file mode 100644 index 00000000..3ac4adbb --- /dev/null +++ b/src/apps/mcp/tools/auth.tools.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + AuthQRInput, + AuthRequestCodeInput, + ScreenshotInput, +} from '@waha/apps/mcp/tools/auth.zod'; + +function AuthContent(key: string): any { + return { + type: 'text' as const, + text: ` +You can either ask the user to scan a QR code or provide a phone number and call auth-request-code. auth-request-code is preferable, so ask for the phone number and pass it in international format without +. +If the user wants to open the QR code or screenshot in a browser, add "?x-api-key=${key}" to the query params. +`, + }; +} + +export class AuthTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('auth-qr', { + title: 'Get QR code', + description: + 'Get QR code to pair WhatsApp Session. ' + + 'The first QR code is valid for 60 seconds; each subsequent code is valid for 20 seconds. ' + + 'If the code expires before scanning, call this tool again to get a fresh one. ' + + 'If you run out of codes the server closes the connection — reconnect and start over.', + inputSchema: AuthQRInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + }, + }) + async authQR({ session }: z.infer) { + const result = await this.imageRequest(`/api/${session}/auth/qr`); + result.content.push(AuthContent(this.api.key)); + return result; + } + + @Tool('screenshot', { + title: 'Get screenshot', + description: + 'Get a screenshot of the current WhatsApp Web page (WEBJS/WPP only)', + inputSchema: ScreenshotInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + }, + }) + async screenshot({ session }: z.infer) { + const result = await this.imageRequest( + `/api/screenshot?session=${session}`, + ); + result.content.push(AuthContent(this.api.key)); + return result; + } + + @Tool('auth-request-code', { + title: 'Request pairing code', + description: + 'Request a one-time pairing code for phone-number-based authentication (alternative to QR). ' + + 'Leave method empty for Web pairing.', + inputSchema: AuthRequestCodeInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async requestCode({ + session, + ...body + }: z.infer) { + const result = await this.textRequest({ + method: 'POST', + url: `/api/${session}/auth/request-code`, + data: body, + }); + result.content.push({ + type: 'text', + text: + 'Share the pairing code with the user and ask them to complete linking:\n' + + '1. Open WhatsApp on your phone\n' + + '2. Tap More Options ⋮ or Settings\n' + + '3. Tap Linked Devices → Link a device\n' + + '4. Tap "Link with phone number instead" and enter the code', + }); + return result; + } +} diff --git a/src/apps/mcp/tools/auth.zod.ts b/src/apps/mcp/tools/auth.zod.ts new file mode 100644 index 00000000..f8273791 --- /dev/null +++ b/src/apps/mcp/tools/auth.zod.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { RequestCodeRequest } from '@waha/structures/auth.dto'; + +export const AuthQRInput = z.object({ + session: z.string(), +}); + +export const ScreenshotInput = z.object({ + session: z.string(), +}); + +export const AuthRequestCodeInput = DtoToZod(RequestCodeRequest).extend({ + session: z.string(), +}); diff --git a/src/apps/mcp/tools/calls.tools.ts b/src/apps/mcp/tools/calls.tools.ts new file mode 100644 index 00000000..d1cae541 --- /dev/null +++ b/src/apps/mcp/tools/calls.tools.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { RejectCallInput } from '@waha/apps/mcp/tools/calls.zod'; + +export class CallTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('calls-reject', { + title: 'Reject incoming call', + description: 'Reject an incoming WhatsApp call', + inputSchema: RejectCallInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async rejectCall({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/calls/reject`, + data: body, + }); + } +} diff --git a/src/apps/mcp/tools/calls.zod.ts b/src/apps/mcp/tools/calls.zod.ts new file mode 100644 index 00000000..b9a9b0cc --- /dev/null +++ b/src/apps/mcp/tools/calls.zod.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { RejectCallRequest } from '@waha/structures/calls.dto'; + +export const RejectCallInput = DtoToZod(RejectCallRequest).extend({ + session: z.string().describe('Session name'), +}); diff --git a/src/apps/mcp/tools/channels.tools.ts b/src/apps/mcp/tools/channels.tools.ts new file mode 100644 index 00000000..aa3bb780 --- /dev/null +++ b/src/apps/mcp/tools/channels.tools.ts @@ -0,0 +1,280 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + ChannelCreateInput, + ChannelIdInput, + ChannelPreviewMessagesInput, + ChannelsListInput, + ChannelSearchByTextInput, + ChannelSearchByViewInput, + ChannelSearchMetaInput, +} from '@waha/apps/mcp/tools/channels.zod'; + +export class ChannelTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('channels-list', { + title: 'List channels', + description: 'Get list of known WhatsApp channels for a session', + inputSchema: ChannelsListInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async list({ session, ...query }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/channels`, + params: query, + }); + } + + @Tool('channels-create', { + title: 'Create channel', + description: 'Create a new WhatsApp channel', + inputSchema: ChannelCreateInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async create({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/channels`, + data: body, + }); + } + + @Tool('channels-delete', { + title: 'Delete channel', + description: 'Delete a WhatsApp channel', + inputSchema: ChannelIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async delete({ session, id }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/${session}/channels/${id}`, + }); + } + + @Tool('channels-get', { + title: 'Get channel info', + description: + 'Get channel information by ID (123@newsletter) or invite code/link', + inputSchema: ChannelIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async get({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/channels/${id}`, + }); + } + + @Tool('channels-messages-preview', { + title: 'Preview channel messages', + description: + 'Preview recent messages from a channel by ID (123@newsletter) or invite code/link', + inputSchema: ChannelPreviewMessagesInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async previewMessages({ + session, + id, + ...query + }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/channels/${id}/messages/preview`, + params: query, + }); + } + + @Tool('channels-follow', { + title: 'Follow channel', + description: 'Follow a WhatsApp channel', + inputSchema: ChannelIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async follow({ session, id }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/channels/${id}/follow`, + }); + } + + @Tool('channels-unfollow', { + title: 'Unfollow channel', + description: 'Unfollow a WhatsApp channel', + inputSchema: ChannelIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async unfollow({ session, id }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/channels/${id}/unfollow`, + }); + } + + @Tool('channels-mute', { + title: 'Mute channel', + description: 'Mute notifications for a WhatsApp channel', + inputSchema: ChannelIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async mute({ session, id }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/channels/${id}/mute`, + }); + } + + @Tool('channels-unmute', { + title: 'Unmute channel', + description: 'Unmute notifications for a WhatsApp channel', + inputSchema: ChannelIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async unmute({ session, id }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/channels/${id}/unmute`, + }); + } + + @Tool('channels-search-by-view', { + title: 'Search channels by view', + description: + 'Search for public WhatsApp channels by view, countries, and categories', + inputSchema: ChannelSearchByViewInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async searchByView({ + session, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/channels/search/by-view`, + data: body, + }); + } + + @Tool('channels-search-by-text', { + title: 'Search channels by text', + description: 'Search for public WhatsApp channels by text query', + inputSchema: ChannelSearchByTextInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async searchByText({ + session, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/channels/search/by-text`, + data: body, + }); + } + + @Tool('channels-search-views', { + title: 'Get channel search views', + description: + 'Get available view options for channel search (e.g. RECOMMENDED)', + inputSchema: ChannelSearchMetaInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getSearchViews({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/channels/search/views`, + }); + } + + @Tool('channels-search-countries', { + title: 'Get channel search countries', + description: 'Get available country codes for channel search', + inputSchema: ChannelSearchMetaInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getSearchCountries({ + session, + }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/channels/search/countries`, + }); + } + + @Tool('channels-search-categories', { + title: 'Get channel search categories', + description: 'Get available category options for channel search', + inputSchema: ChannelSearchMetaInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getSearchCategories({ + session, + }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/channels/search/categories`, + }); + } +} diff --git a/src/apps/mcp/tools/channels.zod.ts b/src/apps/mcp/tools/channels.zod.ts new file mode 100644 index 00000000..2252cde2 --- /dev/null +++ b/src/apps/mcp/tools/channels.zod.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { + ChannelSearchByText, + ChannelSearchByView, + CreateChannelRequest, + ListChannelsQuery, + PreviewChannelMessages, +} from '@waha/structures/channels.dto'; + +const SessionField = z.string().describe('Session name'); +const ChannelIdField = z + .string() + .describe('Channel ID (123@newsletter) or invite code'); + +export const ChannelsListInput = DtoToZod(ListChannelsQuery).extend({ + session: SessionField, +}); + +export const ChannelCreateInput = DtoToZod(CreateChannelRequest).extend({ + session: SessionField, +}); + +export const ChannelIdInput = z.object({ + session: SessionField, + id: ChannelIdField, +}); + +export const ChannelPreviewMessagesInput = DtoToZod( + PreviewChannelMessages, +).extend({ + session: SessionField, + id: ChannelIdField, +}); + +export const ChannelSearchByViewInput = DtoToZod(ChannelSearchByView).extend({ + session: SessionField, +}); + +export const ChannelSearchByTextInput = DtoToZod(ChannelSearchByText).extend({ + session: SessionField, +}); + +export const ChannelSearchMetaInput = z.object({ + session: SessionField, +}); diff --git a/src/apps/mcp/tools/chats.tools.ts b/src/apps/mcp/tools/chats.tools.ts new file mode 100644 index 00000000..3de75e00 --- /dev/null +++ b/src/apps/mcp/tools/chats.tools.ts @@ -0,0 +1,382 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { TextMcpResponse } from '@waha/apps/mcp/responses'; +import { + ChatInput, + ChatMessageInput, + ChatMessagesInput, + ChatPictureInput, + ChatsListInput, + ChatsOverviewBodyInput, + ChatsOverviewInput, + DeleteMessageInput, + EditMessageInput, + PinMessageInput, + ReadChatMessagesInput, + UnpinMessageInput, +} from '@waha/apps/mcp/tools/chats.zod'; + +function FetchMediaUsingApiKeyContent(key: string) { + return { + type: 'text' as const, + text: `To fetch media use "X-Api-Key: ${key}" HTTP header. If the user wants to open it - add "?x-api-key=${key}" to query params`, + }; +} + +export class ChatTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('chats-list', { + title: 'List chats', + description: 'Get list of chats for a session', + inputSchema: ChatsListInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async list({ session, ...pagination }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/chats`, + params: pagination, + }); + } + + @Tool('chats-overview', { + title: 'Get chats overview', + description: + 'Get chats overview with last message, name, and picture. Sorted by last message timestamp', + inputSchema: ChatsOverviewInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async overview({ session, ...params }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/chats/overview`, + params: params, + }); + } + + @Tool('chats-overview-post', { + title: 'Get chats overview (POST)', + description: + 'Get chats overview via POST body — use this instead of chats-overview when filtering by many chat ids', + inputSchema: ChatsOverviewBodyInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async overviewPost({ + session, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/chats/overview`, + data: body, + }); + } + + @Tool('chats-delete', { + title: 'Delete chat', + description: 'Delete a chat', + inputSchema: ChatInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async deleteChat({ session, chatId }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/${session}/chats/${chatId}`, + }); + } + + @Tool('chats-get-picture', { + title: 'Get chat picture', + description: 'Get the profile picture URL for a chat', + inputSchema: ChatPictureInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getPicture({ + session, + chatId, + refresh, + }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/chats/${chatId}/picture`, + params: { refresh: refresh }, + }); + } + + @Tool('chats-get-messages', { + title: 'Get chat messages', + description: + 'Get messages in a chat. ' + + 'To retrieve all messages, paginate by incrementing the offset by limit until the returned array is empty or shorter than the limit. ' + + 'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here.', + inputSchema: ChatMessagesInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getMessages({ + session, + chatId, + _data, + ...query + }: z.infer) { + const response = await this.request({ + method: 'GET', + url: `/api/${session}/chats/${chatId}/messages`, + params: query, + }); + let messages = response.data; + if (!_data && Array.isArray(messages)) { + messages = messages.map((msg: any) => { + const result = { ...msg }; + delete result._data; + if (result.replyTo) { + result.replyTo = { ...result.replyTo }; + delete result.replyTo._data; + } + return result; + }); + } + const responseText = + typeof messages === 'string' ? messages : JSON.stringify(messages); + const result = TextMcpResponse( + JSON.stringify({ status: response.status, response: responseText }), + ); + if (query.downloadMedia) { + result.content.push(FetchMediaUsingApiKeyContent(this.api.key)); + } + return result; + } + + @Tool('chats-read-messages', { + title: 'Read chat messages', + description: 'Mark messages as read in a chat', + inputSchema: ReadChatMessagesInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async readMessages({ + session, + chatId, + ...query + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/chats/${chatId}/messages/read`, + params: query, + }); + } + + @Tool('chats-get-message', { + title: 'Get message by ID', + description: 'Get a specific message by its ID', + inputSchema: ChatMessageInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getMessage({ + session, + chatId, + messageId, + ...query + }: z.infer) { + const result = await this.textRequest({ + method: 'GET', + url: `/api/${session}/chats/${chatId}/messages/${messageId}`, + params: query, + }); + if (query.downloadMedia) { + result.content.push(FetchMediaUsingApiKeyContent(this.api.key)); + } + return result; + } + + @Tool('chats-pin-message', { + title: 'Pin message', + description: 'Pin a message in a chat', + inputSchema: PinMessageInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async pinMessage({ + session, + chatId, + messageId, + duration, + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/chats/${chatId}/messages/${messageId}/pin`, + data: { duration: duration }, + }); + } + + @Tool('chats-unpin-message', { + title: 'Unpin message', + description: 'Unpin a message in a chat', + inputSchema: UnpinMessageInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async unpinMessage({ + session, + chatId, + messageId, + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/chats/${chatId}/messages/${messageId}/unpin`, + }); + } + + @Tool('chats-clear-messages', { + title: 'Clear chat messages', + description: 'Delete all messages from a chat', + inputSchema: ChatInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async clearMessages({ session, chatId }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/${session}/chats/${chatId}/messages`, + }); + } + + @Tool('chats-delete-message', { + title: 'Delete message', + description: 'Delete a specific message from a chat', + inputSchema: DeleteMessageInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async deleteMessage({ + session, + chatId, + messageId, + }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/${session}/chats/${chatId}/messages/${messageId}`, + }); + } + + @Tool('chats-edit-message', { + title: 'Edit message', + description: 'Edit the text of a sent message', + inputSchema: EditMessageInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async editMessage({ + session, + chatId, + messageId, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/chats/${chatId}/messages/${messageId}`, + data: body, + }); + } + + @Tool('chats-archive', { + title: 'Archive chat', + description: 'Archive a chat', + inputSchema: ChatInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async archiveChat({ session, chatId }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/chats/${chatId}/archive`, + }); + } + + @Tool('chats-unarchive', { + title: 'Unarchive chat', + description: 'Unarchive a chat', + inputSchema: ChatInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async unarchiveChat({ session, chatId }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/chats/${chatId}/unarchive`, + }); + } + + @Tool('chats-unread', { + title: 'Mark chat as unread', + description: 'Mark a chat as unread', + inputSchema: ChatInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async unreadChat({ session, chatId }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/chats/${chatId}/unread`, + }); + } +} diff --git a/src/apps/mcp/tools/chats.zod.ts b/src/apps/mcp/tools/chats.zod.ts new file mode 100644 index 00000000..817263d4 --- /dev/null +++ b/src/apps/mcp/tools/chats.zod.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { + ChatPictureQuery, + GetChatMessageQuery, + GetChatMessagesFilter, + GetChatMessagesQuery, + GetChatsOverviewParams, + GetChatsParams, + OverviewFilter, + PinMessageRequest, + ReadChatMessagesQuery, +} from '@waha/structures/chats.dto'; + +const SessionField = z.string().describe('Session name'); +const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)'); +const MessageIdField = z.string().describe('Message ID'); + +export const ChatsListInput = DtoToZod(GetChatsParams).extend({ + session: SessionField, +}); + +export const ChatsOverviewInput = DtoToZod(GetChatsOverviewParams) + .merge(DtoToZod(OverviewFilter)) + .extend({ session: SessionField }); + +export const ChatInput = z.object({ + session: SessionField, + chatId: ChatIdField, +}); + +export const ChatPictureInput = DtoToZod(ChatPictureQuery).extend({ + session: SessionField, + chatId: ChatIdField, +}); + +export const ChatMessagesInput = DtoToZod(GetChatMessagesQuery) + .merge(DtoToZod(GetChatMessagesFilter)) + .extend({ + session: SessionField, + chatId: z.string().describe('Chat ID'), + _data: z + .boolean() + .optional() + .default(false) + .describe( + 'Include raw _data field in messages. When false (default), _data is stripped from each message and from replyTo.', + ), + }); + +export const ReadChatMessagesInput = DtoToZod(ReadChatMessagesQuery).extend({ + session: SessionField, + chatId: ChatIdField, +}); + +export const ChatMessageInput = DtoToZod(GetChatMessageQuery).extend({ + session: SessionField, + chatId: ChatIdField, + messageId: MessageIdField, +}); + +export const PinMessageInput = DtoToZod(PinMessageRequest).extend({ + session: SessionField, + chatId: ChatIdField, + messageId: MessageIdField, +}); + +export const UnpinMessageInput = z.object({ + session: SessionField, + chatId: ChatIdField, + messageId: MessageIdField, +}); + +export const DeleteMessageInput = z.object({ + session: SessionField, + chatId: ChatIdField, + messageId: MessageIdField, +}); + +export const EditMessageInput = z.object({ + session: SessionField, + chatId: ChatIdField, + messageId: MessageIdField, + text: z.string().describe('New text'), + linkPreview: z.boolean().optional().default(true), +}); + +export const ChatsOverviewBodyInput = z.object({ + session: SessionField, + pagination: DtoToZod(GetChatsOverviewParams), + filter: DtoToZod(OverviewFilter), +}); diff --git a/src/apps/mcp/tools/contacts.tools.ts b/src/apps/mcp/tools/contacts.tools.ts new file mode 100644 index 00000000..e875699c --- /dev/null +++ b/src/apps/mcp/tools/contacts.tools.ts @@ -0,0 +1,169 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + ContactCheckExistsInput, + ContactGetInput, + ContactProfilePictureInput, + ContactRequestInput, + ContactsGetAllInput, + ContactUpsertInput, +} from '@waha/apps/mcp/tools/contacts.zod'; + +export class ContactTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('contacts-get-all', { + title: 'Get all contacts', + description: 'Get all contacts for a session', + inputSchema: ContactsGetAllInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getAll({ session, ...query }: z.infer) { + return this.textRequest({ + method: 'GET', + url: '/api/contacts/all', + params: { session: session, ...query }, + }); + } + + @Tool('contacts-get', { + title: 'Get contact info', + description: + 'Get basic contact info. Always returns a result even if the number is not registered in WhatsApp — use contacts-check-exists to verify registration.', + inputSchema: ContactGetInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async get({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/contacts/${id}`, + }); + } + + @Tool('contacts-check-exists', { + title: 'Check if number is on WhatsApp', + description: 'Check whether a phone number is registered in WhatsApp', + inputSchema: ContactCheckExistsInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async checkExists(query: z.infer) { + return this.textRequest({ + method: 'GET', + url: '/api/contacts/check-exists', + params: query, + }); + } + + @Tool('contacts-get-about', { + title: "Get contact's about", + description: + 'Get the contact\'s "about" / status text. Returns null if privacy settings block access.', + inputSchema: ContactGetInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getAbout({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: '/api/contacts/about', + params: { session: session, contactId: id }, + }); + } + + @Tool('contacts-get-picture', { + title: "Get contact's profile picture", + description: + "Get the contact's profile picture URL. Returns null if privacy settings block access.", + inputSchema: ContactProfilePictureInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getProfilePicture(query: z.infer) { + return this.textRequest({ + method: 'GET', + url: '/api/contacts/profile-picture', + params: query, + }); + } + + @Tool('contacts-block', { + title: 'Block contact', + description: 'Block a contact', + inputSchema: ContactRequestInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async block(body: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/contacts/block', + data: body, + }); + } + + @Tool('contacts-unblock', { + title: 'Unblock contact', + description: 'Unblock a contact', + inputSchema: ContactRequestInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async unblock(body: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/contacts/unblock', + data: body, + }); + } + + @Tool('contacts-upsert', { + title: 'Create or update contact', + description: + 'Create or update a contact in the phone address book. May not work if multiple WhatsApp apps are installed on the same phone.', + inputSchema: ContactUpsertInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async upsert({ + session, + chatId, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/contacts/${chatId}`, + data: body, + }); + } +} diff --git a/src/apps/mcp/tools/contacts.zod.ts b/src/apps/mcp/tools/contacts.zod.ts new file mode 100644 index 00000000..87642439 --- /dev/null +++ b/src/apps/mcp/tools/contacts.zod.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { + ContactsPaginationParams, + ContactProfilePictureQuery, + ContactRequest, + ContactUpdateBody, +} from '@waha/structures/contacts.dto'; +import { CheckNumberStatusQuery } from '@waha/structures/chatting.dto'; + +const SessionField = z.string().describe('Session name'); +const ContactIdField = z.string().describe('Contact ID (e.g. 11111@c.us)'); + +export const ContactsGetAllInput = DtoToZod(ContactsPaginationParams).extend({ + session: SessionField, +}); + +export const ContactGetInput = z.object({ + session: SessionField, + id: ContactIdField, +}); + +export const ContactCheckExistsInput = DtoToZod(CheckNumberStatusQuery); + +export const ContactProfilePictureInput = DtoToZod(ContactProfilePictureQuery); + +export const ContactRequestInput = DtoToZod(ContactRequest); + +export const ContactUpsertInput = DtoToZod(ContactUpdateBody).extend({ + session: SessionField, + chatId: ContactIdField, +}); diff --git a/src/apps/mcp/tools/groups.tools.ts b/src/apps/mcp/tools/groups.tools.ts new file mode 100644 index 00000000..519478e8 --- /dev/null +++ b/src/apps/mcp/tools/groups.tools.ts @@ -0,0 +1,504 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + GroupAdminOnlyInput, + GroupCreateInput, + GroupDescriptionInput, + GroupIdInput, + GroupJoinInput, + GroupParticipantsInput, + GroupPictureInput, + GroupsListInput, + GroupsSessionInput, + GroupSetPictureInput, + GroupSubjectInput, +} from '@waha/apps/mcp/tools/groups.zod'; + +export class GroupTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('groups-list', { + title: 'List groups', + description: 'Get all groups for a session', + inputSchema: GroupsListInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async list({ session, ...query }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups`, + params: query, + }); + } + + @Tool('groups-count', { + title: 'Count groups', + description: 'Get the number of groups for a session', + inputSchema: GroupsSessionInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async count({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/count`, + }); + } + + @Tool('groups-refresh', { + title: 'Refresh groups', + description: 'Refresh the groups list from the server', + inputSchema: GroupsSessionInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async refresh({ session }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/refresh`, + }); + } + + @Tool('groups-join-info', { + title: 'Get group join info', + description: + 'Get info about a group before joining via invite code or link', + inputSchema: GroupJoinInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async joinInfo({ session, ...query }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/join-info`, + params: query, + }); + } + + @Tool('groups-join', { + title: 'Join group', + description: 'Join a group via invite code or invite link', + inputSchema: GroupJoinInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async join({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/join`, + data: body, + }); + } + + @Tool('groups-create', { + title: 'Create group', + description: 'Create a new WhatsApp group', + inputSchema: GroupCreateInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async create({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups`, + data: body, + }); + } + + @Tool('groups-get', { + title: 'Get group', + description: 'Get group information by ID', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async get({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/${id}`, + }); + } + + @Tool('groups-delete', { + title: 'Delete group', + description: 'Delete a WhatsApp group', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async delete({ session, id }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/${session}/groups/${id}`, + }); + } + + @Tool('groups-leave', { + title: 'Leave group', + description: 'Leave a WhatsApp group', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async leave({ session, id }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/${id}/leave`, + }); + } + + @Tool('groups-get-picture', { + title: 'Get group picture', + description: 'Get the group profile picture URL', + inputSchema: GroupPictureInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getPicture({ + session, + id, + ...query + }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/${id}/picture`, + params: query, + }); + } + + @Tool('groups-set-picture', { + title: 'Set group picture', + description: 'Set the group profile picture', + inputSchema: GroupSetPictureInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async setPicture({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/groups/${id}/picture`, + data: body, + }); + } + + @Tool('groups-delete-picture', { + title: 'Delete group picture', + description: 'Remove the group profile picture', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async deletePicture({ session, id }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/${session}/groups/${id}/picture`, + }); + } + + @Tool('groups-set-description', { + title: 'Set group description', + description: 'Update the group description', + inputSchema: GroupDescriptionInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setDescription({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/groups/${id}/description`, + data: body, + }); + } + + @Tool('groups-set-subject', { + title: 'Set group subject', + description: 'Update the group name/subject', + inputSchema: GroupSubjectInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setSubject({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/groups/${id}/subject`, + data: body, + }); + } + + @Tool('groups-get-info-admin-only', { + title: 'Get info-admin-only setting', + description: 'Get whether only admins can edit group info', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getInfoAdminOnly({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/${id}/settings/security/info-admin-only`, + }); + } + + @Tool('groups-set-info-admin-only', { + title: 'Set info-admin-only setting', + description: + 'Allow only admins to edit group info (title, description, photo)', + inputSchema: GroupAdminOnlyInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setInfoAdminOnly({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/groups/${id}/settings/security/info-admin-only`, + data: body, + }); + } + + @Tool('groups-get-messages-admin-only', { + title: 'Get messages-admin-only setting', + description: 'Get whether only admins can send messages in the group', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getMessagesAdminOnly({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/${id}/settings/security/messages-admin-only`, + }); + } + + @Tool('groups-set-messages-admin-only', { + title: 'Set messages-admin-only setting', + description: 'Allow only admins to send messages in the group', + inputSchema: GroupAdminOnlyInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setMessagesAdminOnly({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/groups/${id}/settings/security/messages-admin-only`, + data: body, + }); + } + + @Tool('groups-get-invite-code', { + title: 'Get group invite code', + description: 'Get the invite code for a group', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getInviteCode({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/${id}/invite-code`, + }); + } + + @Tool('groups-revoke-invite-code', { + title: 'Revoke group invite code', + description: 'Invalidate the current invite code and generate a new one', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async revokeInviteCode({ session, id }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/${id}/invite-code/revoke`, + }); + } + + @Tool('groups-get-participants', { + title: 'Get group participants', + description: 'Get the list of group participants with roles', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getParticipants({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/${id}/participants/v2`, + }); + } + + @Tool('groups-add-participants', { + title: 'Add participants', + description: 'Add participants to a group', + inputSchema: GroupParticipantsInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async addParticipants({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/${id}/participants/add`, + data: body, + }); + } + + @Tool('groups-remove-participants', { + title: 'Remove participants', + description: 'Remove participants from a group', + inputSchema: GroupParticipantsInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async removeParticipants({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/${id}/participants/remove`, + data: body, + }); + } + + @Tool('groups-promote-to-admin', { + title: 'Promote to admin', + description: 'Promote participants to group admin', + inputSchema: GroupParticipantsInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async promoteToAdmin({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/${id}/admin/promote`, + data: body, + }); + } + + @Tool('groups-demote-to-user', { + title: 'Demote to user', + description: 'Demote admin participants back to regular users', + inputSchema: GroupParticipantsInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async demoteToUser({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/${id}/admin/demote`, + data: body, + }); + } +} diff --git a/src/apps/mcp/tools/groups.zod.ts b/src/apps/mcp/tools/groups.zod.ts new file mode 100644 index 00000000..686c9c20 --- /dev/null +++ b/src/apps/mcp/tools/groups.zod.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { + CreateGroupRequest, + DescriptionRequest, + GroupsListFields, + GroupsPaginationParams, + JoinGroupRequest, + ParticipantsRequest, + SettingsSecurityChangeInfo, + SubjectRequest, +} from '@waha/structures/groups.dto'; +import { ChatPictureQuery } from '@waha/structures/chats.dto'; +import { ProfilePictureRequest } from '@waha/structures/profile.dto'; + +const SessionField = z.string().describe('Session name'); +const GroupIdField = z.string().describe('Group ID (e.g. 123456789@g.us)'); + +export const GroupsListInput = DtoToZod(GroupsPaginationParams) + .merge(DtoToZod(GroupsListFields)) + .extend({ session: SessionField }); + +export const GroupsSessionInput = z.object({ session: SessionField }); + +export const GroupIdInput = z.object({ + session: SessionField, + id: GroupIdField, +}); + +export const GroupCreateInput = DtoToZod(CreateGroupRequest).extend({ + session: SessionField, +}); + +export const GroupJoinInput = DtoToZod(JoinGroupRequest).extend({ + session: SessionField, +}); + +export const GroupPictureInput = DtoToZod(ChatPictureQuery).extend({ + session: SessionField, + id: GroupIdField, +}); + +export const GroupSetPictureInput = DtoToZod(ProfilePictureRequest).extend({ + session: SessionField, + id: GroupIdField, +}); + +export const GroupDescriptionInput = DtoToZod(DescriptionRequest).extend({ + session: SessionField, + id: GroupIdField, +}); + +export const GroupSubjectInput = DtoToZod(SubjectRequest).extend({ + session: SessionField, + id: GroupIdField, +}); + +export const GroupAdminOnlyInput = DtoToZod(SettingsSecurityChangeInfo).extend({ + session: SessionField, + id: GroupIdField, +}); + +export const GroupParticipantsInput = DtoToZod(ParticipantsRequest).extend({ + session: SessionField, + id: GroupIdField, +}); diff --git a/src/apps/mcp/tools/labels.tools.ts b/src/apps/mcp/tools/labels.tools.ts new file mode 100644 index 00000000..4fd693b8 --- /dev/null +++ b/src/apps/mcp/tools/labels.tools.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + LabelBodyInput, + LabelChatInput, + LabelIdInput, + LabelsSessionInput, + LabelUpdateInput, + SetChatLabelsInput, +} from '@waha/apps/mcp/tools/labels.zod'; + +export class LabelTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('labels-get-all', { + title: 'Get all labels', + description: 'Get all labels for a session', + inputSchema: LabelsSessionInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getAll({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/labels`, + }); + } + + @Tool('labels-create', { + title: 'Create label', + description: 'Create a new label (max 20 per session)', + inputSchema: LabelBodyInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async create({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/labels`, + data: body, + }); + } + + @Tool('labels-update', { + title: 'Update label', + description: 'Update an existing label name or color', + inputSchema: LabelUpdateInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async update({ + session, + labelId, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/labels/${labelId}`, + data: body, + }); + } + + @Tool('labels-delete', { + title: 'Delete label', + description: 'Delete a label', + inputSchema: LabelIdInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async delete({ session, labelId }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/${session}/labels/${labelId}`, + }); + } + + @Tool('labels-get-chat-labels', { + title: 'Get chat labels', + description: 'Get all labels applied to a chat', + inputSchema: LabelChatInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getChatLabels({ session, chatId }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/labels/chats/${chatId}`, + }); + } + + @Tool('labels-set-chat-labels', { + title: 'Set chat labels', + description: 'Replace all labels on a chat', + inputSchema: SetChatLabelsInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setChatLabels({ + session, + chatId, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/labels/chats/${chatId}`, + data: body, + }); + } + + @Tool('labels-get-chats-by-label', { + title: 'Get chats by label', + description: 'Get all chats that have a given label applied', + inputSchema: LabelIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getChatsByLabel({ session, labelId }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/labels/${labelId}/chats`, + }); + } +} diff --git a/src/apps/mcp/tools/labels.zod.ts b/src/apps/mcp/tools/labels.zod.ts new file mode 100644 index 00000000..3937d92a --- /dev/null +++ b/src/apps/mcp/tools/labels.zod.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { LabelBody, SetLabelsRequest } from '@waha/structures/labels.dto'; + +const SessionField = z.string().describe('Session name'); +const LabelIdField = z.string().describe('Label ID'); +const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)'); + +export const LabelsSessionInput = z.object({ session: SessionField }); + +export const LabelIdInput = z.object({ + session: SessionField, + labelId: LabelIdField, +}); + +export const LabelBodyInput = DtoToZod(LabelBody).extend({ + session: SessionField, +}); + +export const LabelUpdateInput = DtoToZod(LabelBody).extend({ + session: SessionField, + labelId: LabelIdField, +}); + +export const LabelChatInput = z.object({ + session: SessionField, + chatId: ChatIdField, +}); + +export const SetChatLabelsInput = DtoToZod(SetLabelsRequest).extend({ + session: SessionField, + chatId: ChatIdField, +}); diff --git a/src/apps/mcp/tools/lids.tools.ts b/src/apps/mcp/tools/lids.tools.ts new file mode 100644 index 00000000..4062a8b1 --- /dev/null +++ b/src/apps/mcp/tools/lids.tools.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + LidInput, + LidsListInput, + LidsSessionInput, + PhoneNumberInput, +} from '@waha/apps/mcp/tools/lids.zod'; + +export class LidTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('lids-get-all', { + title: 'Get all LIDs', + description: + 'LIDs (Linked IDs) are anonymous identifiers WhatsApp assigns to contacts in some regions instead of phone numbers. ' + + 'Get all known LID-to-phone-number mappings for a session.', + inputSchema: LidsListInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getAll({ session, ...query }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/lids`, + params: query, + }); + } + + @Tool('lids-count', { + title: 'Count LIDs', + description: 'Get the number of known LIDs for a session', + inputSchema: LidsSessionInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async count({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/lids/count`, + }); + } + + @Tool('lids-find-pn-by-lid', { + title: 'Find phone number by LID', + description: 'Look up the phone number (chat ID) for a given LID', + inputSchema: LidInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async findPNByLid({ session, lid }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/lids/${lid}`, + }); + } + + @Tool('lids-find-lid-by-pn', { + title: 'Find LID by phone number', + description: 'Look up the LID for a given phone number / chat ID', + inputSchema: PhoneNumberInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async findLidByPN({ + session, + phoneNumber, + }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/lids/pn/${phoneNumber}`, + }); + } +} diff --git a/src/apps/mcp/tools/lids.zod.ts b/src/apps/mcp/tools/lids.zod.ts new file mode 100644 index 00000000..dd645784 --- /dev/null +++ b/src/apps/mcp/tools/lids.zod.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { LidsListQueryParams } from '@waha/structures/lids.dto'; + +const SessionField = z.string().describe('Session name'); + +export const LidsListInput = DtoToZod(LidsListQueryParams).extend({ + session: SessionField, +}); + +export const LidsSessionInput = z.object({ session: SessionField }); + +export const LidInput = z.object({ + session: SessionField, + lid: z.string().describe('LID (e.g. 1111111@lid)'), +}); + +export const PhoneNumberInput = z.object({ + session: SessionField, + phoneNumber: z + .string() + .describe('Phone number / chat ID (e.g. 3333333@c.us)'), +}); diff --git a/src/apps/mcp/tools/ping.tools.ts b/src/apps/mcp/tools/ping.tools.ts new file mode 100644 index 00000000..330b6f50 --- /dev/null +++ b/src/apps/mcp/tools/ping.tools.ts @@ -0,0 +1,25 @@ +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; + +export class PingTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('ping', { + title: 'Ping the server', + description: 'Check if the WAHA server is alive and responding', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async ping() { + return this.textRequest({ + method: 'GET', + url: '/ping', + }); + } +} diff --git a/src/apps/mcp/tools/presence.tools.ts b/src/apps/mcp/tools/presence.tools.ts new file mode 100644 index 00000000..cef2ee23 --- /dev/null +++ b/src/apps/mcp/tools/presence.tools.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + PresenceChatInput, + PresenceSessionInput, + PresenceSetInput, +} from '@waha/apps/mcp/tools/presence.zod'; + +export class PresenceTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('presence-set', { + title: 'Set presence', + description: + 'Set the session presence. Use ONLINE/OFFLINE for global scope (no chatId). ' + + 'Use TYPING/RECORDING/PAUSED with a chatId for chat-scoped presence.', + inputSchema: PresenceSetInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async set({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/presence`, + data: body, + }); + } + + @Tool('presence-get-all', { + title: 'Get all presences', + description: 'Get all subscribed presence information for a session', + inputSchema: PresenceSessionInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getAll({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/presence`, + }); + } + + @Tool('presence-get', { + title: 'Get chat presence', + description: + 'Get presence for a chat. Subscribes automatically if not yet subscribed.', + inputSchema: PresenceChatInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async get({ session, chatId }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/presence/${chatId}`, + }); + } + + @Tool('presence-subscribe', { + title: 'Subscribe to presence', + description: 'Subscribe to presence events for a chat', + inputSchema: PresenceChatInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async subscribe({ session, chatId }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/presence/${chatId}/subscribe`, + }); + } +} diff --git a/src/apps/mcp/tools/presence.zod.ts b/src/apps/mcp/tools/presence.zod.ts new file mode 100644 index 00000000..984e1579 --- /dev/null +++ b/src/apps/mcp/tools/presence.zod.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { WAHASessionPresence } from '@waha/structures/presence.dto'; + +const SessionField = z.string().describe('Session name'); +const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)'); + +export const PresenceSessionInput = z.object({ session: SessionField }); + +export const PresenceSetInput = DtoToZod(WAHASessionPresence).extend({ + session: SessionField, +}); + +export const PresenceChatInput = z.object({ + session: SessionField, + chatId: ChatIdField, +}); diff --git a/src/apps/mcp/tools/profile.tools.ts b/src/apps/mcp/tools/profile.tools.ts new file mode 100644 index 00000000..13b3f5c0 --- /dev/null +++ b/src/apps/mcp/tools/profile.tools.ts @@ -0,0 +1,105 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + ProfileNameInput, + ProfilePictureInput, + ProfileSessionInput, + ProfileStatusInput, +} from '@waha/apps/mcp/tools/profile.zod'; + +export class ProfileTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('profile-get', { + title: 'Get my profile', + description: + 'Get the profile info (id, name, picture) for the session account', + inputSchema: ProfileSessionInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async get({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/profile`, + }); + } + + @Tool('profile-set-name', { + title: 'Set profile name', + description: 'Update the display name for the session account', + inputSchema: ProfileNameInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setName({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/profile/name`, + data: body, + }); + } + + @Tool('profile-set-status', { + title: 'Set profile status', + description: 'Update the "About" / status text for the session account', + inputSchema: ProfileStatusInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setStatus({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/profile/status`, + data: body, + }); + } + + @Tool('profile-set-picture', { + title: 'Set profile picture', + description: 'Update the profile picture for the session account', + inputSchema: ProfilePictureInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async setPicture({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/profile/picture`, + data: body, + }); + } + + @Tool('profile-delete-picture', { + title: 'Delete profile picture', + description: 'Remove the profile picture for the session account', + inputSchema: ProfileSessionInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async deletePicture({ session }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/${session}/profile/picture`, + }); + } +} diff --git a/src/apps/mcp/tools/profile.zod.ts b/src/apps/mcp/tools/profile.zod.ts new file mode 100644 index 00000000..4f1aa820 --- /dev/null +++ b/src/apps/mcp/tools/profile.zod.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { + ProfileNameRequest, + ProfilePictureRequest, + ProfileStatusRequest, +} from '@waha/structures/profile.dto'; + +const SessionField = z.string().describe('Session name'); + +export const ProfileSessionInput = z.object({ session: SessionField }); + +export const ProfileNameInput = DtoToZod(ProfileNameRequest).extend({ + session: SessionField, +}); + +export const ProfileStatusInput = DtoToZod(ProfileStatusRequest).extend({ + session: SessionField, +}); + +export const ProfilePictureInput = DtoToZod(ProfilePictureRequest).extend({ + session: SessionField, +}); diff --git a/src/apps/mcp/tools/send.tools.ts b/src/apps/mcp/tools/send.tools.ts new file mode 100644 index 00000000..dadb57eb --- /dev/null +++ b/src/apps/mcp/tools/send.tools.ts @@ -0,0 +1,425 @@ +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { z } from 'zod'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + ForwardMessageInput, + NewMessageIdInput, + SendEventInput, + SendButtonsInput, + SendButtonsReplyInput, + SendContactVcardInput, + SendFileInput, + SendImageInput, + SendLinkCustomPreviewInput, + SendListInput, + SendLocationInput, + SendPollInput, + SendPollVoteInput, + SendSeenInput, + SendTextInput, + SendVideoInput, + SendVoiceInput, + SetReactionInput, + SetStarInput, + TypingInput, +} from '@waha/apps/mcp/tools/send.zod'; + +const FileNote = + 'Use file.url for remote HTTP/HTTPS URLs. ' + + 'For local file paths (e.g. /tmp/file.ext) - upload it to S3 or other remote server first, or read the file, encode it as base64, and pass it via file.data instead.'; + +export class SendTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('send-text', { + title: 'Send a text message', + description: + 'Send a text message to a number. ' + + 'Always call start-typing first, wait a few seconds, call stop-typing, then send the message.', + inputSchema: SendTextInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendText(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendText', + data: data, + }); + } + + @Tool('send-image', { + title: 'Send an image', + description: 'Send an image to a chat. ' + FileNote, + inputSchema: SendImageInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendImage(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendImage', + data: data, + }); + } + + @Tool('send-file', { + title: 'Send a file', + description: 'Send a file (document) to a chat. ' + FileNote, + inputSchema: SendFileInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendFile(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendFile', + data: data, + }); + } + + @Tool('send-voice', { + title: 'Send a voice message', + description: 'Send a voice message to a chat. ' + FileNote, + inputSchema: SendVoiceInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendVoice(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendVoice', + data: data, + }); + } + + @Tool('send-video', { + title: 'Send a video', + description: 'Send a video to a chat. ' + FileNote, + inputSchema: SendVideoInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendVideo(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendVideo', + data: data, + }); + } + + @Tool('send-link-custom-preview', { + title: 'Send a text message with a custom link preview', + description: + 'Send a text message with a custom link preview (title, description, image)', + inputSchema: SendLinkCustomPreviewInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendLinkCustomPreview( + data: z.infer, + ) { + return this.textRequest({ + method: 'POST', + url: '/api/send/link-custom-preview', + data: data, + }); + } + + @Tool('send-buttons', { + title: 'Send a buttons message', + description: 'Send an interactive buttons message', + inputSchema: SendButtonsInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendButtons(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendButtons', + data: data, + }); + } + + @Tool('send-list', { + title: 'Send a list message', + description: 'Send an interactive list message with sections and rows', + inputSchema: SendListInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendList(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendList', + data: data, + }); + } + + @Tool('send-seen', { + title: 'Mark messages as seen', + description: 'Mark one or more messages as seen (read)', + inputSchema: SendSeenInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async sendSeen(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendSeen', + data: data, + }); + } + + @Tool('send-poll', { + title: 'Send a poll', + description: 'Send a poll message with options', + inputSchema: SendPollInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendPoll(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendPoll', + data: data, + }); + } + + @Tool('send-poll-vote', { + title: 'Vote on a poll', + description: 'Cast vote(s) on an existing poll message', + inputSchema: SendPollVoteInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendPollVote(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendPollVote', + data: data, + }); + } + + @Tool('send-location', { + title: 'Send a location', + description: 'Send a location with latitude, longitude, and title', + inputSchema: SendLocationInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendLocation(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendLocation', + data: data, + }); + } + + @Tool('send-contact-vcard', { + title: 'Send a contact vCard', + description: 'Send one or more contacts as a vCard', + inputSchema: SendContactVcardInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendContactVcard(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sendContactVcard', + data: data, + }); + } + + @Tool('send-buttons-reply', { + title: 'Reply to a buttons message', + description: 'Send a reply to an interactive buttons message', + inputSchema: SendButtonsReplyInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendButtonsReply(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/send/buttons/reply', + data: data, + }); + } + + @Tool('forward-message', { + title: 'Forward a message', + description: 'Forward an existing message to a chat', + inputSchema: ForwardMessageInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async forwardMessage(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/forwardMessage', + data: data, + }); + } + + @Tool('start-typing', { + title: 'Start typing indicator', + description: + 'Show a typing indicator in a chat (runs until stop-typing is called)', + inputSchema: TypingInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async startTyping(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/startTyping', + data: data, + }); + } + + @Tool('stop-typing', { + title: 'Stop typing indicator', + description: 'Stop the typing indicator in a chat', + inputSchema: TypingInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async stopTyping(data: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/stopTyping', + data: data, + }); + } + + @Tool('set-reaction', { + title: 'React to a message', + description: + 'React to a message with an emoji. Send empty string to remove reaction', + inputSchema: SetReactionInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setReaction(data: z.infer) { + return this.textRequest({ + method: 'PUT', + url: '/api/reaction', + data: data, + }); + } + + @Tool('set-star', { + title: 'Star or unstar a message', + description: 'Star or unstar a message', + inputSchema: SetStarInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setStar(data: z.infer) { + return this.textRequest({ + method: 'PUT', + url: '/api/star', + data: data, + }); + } + + @Tool('send-event-message', { + title: 'Send an event message', + description: 'Send an event/appointment message to a chat', + inputSchema: SendEventInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendEvent({ + session, + chatId, + reply_to, + ...event + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/events`, + data: { chatId: chatId, reply_to: reply_to, event: event }, + }); + } + + @Tool('new-message-id', { + title: 'Generate a new message ID', + description: 'Generate a new unique message ID for use in send requests', + inputSchema: NewMessageIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + }, + }) + async newMessageId({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/new-message-id`, + }); + } +} diff --git a/src/apps/mcp/tools/send.zod.ts b/src/apps/mcp/tools/send.zod.ts new file mode 100644 index 00000000..d2f6d2f3 --- /dev/null +++ b/src/apps/mcp/tools/send.zod.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { + ChatRequest, + MessageButtonReply, + MessageContactVcardRequest, + MessageFileRequest, + MessageForwardRequest, + MessageImageRequest, + MessageLinkCustomPreviewRequest, + MessageLocationRequest, + MessagePollRequest, + MessagePollVoteRequest, + MessageReactionRequest, + MessageStarRequest, + MessageTextRequest, + MessageVideoRequest, + MessageVoiceRequest, + SendSeenRequest, +} from '@waha/structures/chatting.dto'; +import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto'; +import { SendListRequest } from '@waha/structures/chatting.list.dto'; +import { EventMessage } from '@waha/structures/events.dto'; + +export const SendTextInput = DtoToZod(MessageTextRequest); +export const SendImageInput = DtoToZod(MessageImageRequest); +export const SendFileInput = DtoToZod(MessageFileRequest); +export const SendVoiceInput = DtoToZod(MessageVoiceRequest); +export const SendVideoInput = DtoToZod(MessageVideoRequest); +export const SendLinkCustomPreviewInput = DtoToZod( + MessageLinkCustomPreviewRequest, +); +export const SendButtonsInput = DtoToZod(SendButtonsRequest); +export const SendListInput = DtoToZod(SendListRequest); +export const SendSeenInput = DtoToZod(SendSeenRequest); +export const SendPollInput = DtoToZod(MessagePollRequest); +export const SendPollVoteInput = DtoToZod(MessagePollVoteRequest); +export const SendLocationInput = DtoToZod(MessageLocationRequest); +export const SendContactVcardInput = DtoToZod(MessageContactVcardRequest); +export const SendButtonsReplyInput = DtoToZod(MessageButtonReply); +export const ForwardMessageInput = DtoToZod(MessageForwardRequest); +export const TypingInput = DtoToZod(ChatRequest); +export const SetReactionInput = DtoToZod(MessageReactionRequest); +export const SetStarInput = DtoToZod(MessageStarRequest); +export const NewMessageIdInput = z.object({ + session: z.string().describe('Session name'), +}); + +export const SendEventInput = DtoToZod(EventMessage).extend({ + session: z.string().describe('Session name'), + chatId: z.string().describe('Chat ID (e.g. 11111@c.us)'), + reply_to: z.string().optional().describe('Message ID to reply to'), +}); diff --git a/src/apps/mcp/tools/server.tools.ts b/src/apps/mcp/tools/server.tools.ts new file mode 100644 index 00000000..f25f5768 --- /dev/null +++ b/src/apps/mcp/tools/server.tools.ts @@ -0,0 +1,81 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { EnvironmentInput, StopInput } from '@waha/apps/mcp/tools/server.zod'; + +export class ServerTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('server-version', { + title: 'Get server version', + description: 'Get the version and build information of the WAHA server', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async version() { + return this.textRequest({ + method: 'GET', + url: '/api/server/version', + }); + } + + @Tool('server-status', { + title: 'Get server status', + description: 'Get server uptime, start timestamp, and worker information', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async status() { + return this.textRequest({ + method: 'GET', + url: '/api/server/status', + }); + } + + @Tool('server-environment', { + title: 'Get server environment', + description: + 'Get environment variables from the server (WAHA_* and WHATSAPP_* by default)', + inputSchema: EnvironmentInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async environment(params: z.infer) { + return this.textRequest({ + method: 'GET', + url: '/api/server/environment', + params: params, + }); + } + + @Tool('server-stop', { + title: 'Stop the server', + description: + 'Stop (and restart) the WAHA server. Use force=true for immediate termination.', + inputSchema: StopInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async stop(body: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/server/stop', + data: body, + }); + } +} diff --git a/src/apps/mcp/tools/server.zod.ts b/src/apps/mcp/tools/server.zod.ts new file mode 100644 index 00000000..28b0e7a2 --- /dev/null +++ b/src/apps/mcp/tools/server.zod.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const EnvironmentInput = z.object({ + all: z + .boolean() + .optional() + .describe('Include all environment variables, not just WAHA_* ones'), +}); + +export const StopInput = z.object({ + force: z + .boolean() + .optional() + .describe( + 'Force-terminate immediately instead of graceful shutdown (SIGKILL vs SIGTERM)', + ), +}); diff --git a/src/apps/mcp/tools/sessions.tools.ts b/src/apps/mcp/tools/sessions.tools.ts new file mode 100644 index 00000000..9b4ab06a --- /dev/null +++ b/src/apps/mcp/tools/sessions.tools.ts @@ -0,0 +1,174 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + SessionCreateInput, + SessionListInput, + SessionNameInput, + SessionUpdateInput, +} from '@waha/apps/mcp/tools/sessions.zod'; + +export class SessionTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('sessions-list', { + title: 'List sessions', + description: 'List all WhatsApp sessions', + inputSchema: SessionListInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async list(params: z.infer) { + // Send back all sessions + params.all = true; + return this.textRequest({ + method: 'GET', + url: '/api/sessions', + params: params, + }); + } + + @Tool('sessions-get', { + title: 'Get session', + description: 'Get information about a WhatsApp session', + inputSchema: SessionNameInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async get({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/sessions/${session}`, + }); + } + + @Tool('sessions-create', { + title: 'Create session', + description: 'Create a new WhatsApp session', + inputSchema: SessionCreateInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async create(body: z.infer) { + return this.textRequest({ + method: 'POST', + url: '/api/sessions', + data: body, + }); + } + + @Tool('sessions-update', { + title: 'Update session', + description: 'Update config of an existing WhatsApp session', + inputSchema: SessionUpdateInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async update({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/sessions/${session}`, + data: body, + }); + } + + @Tool('sessions-delete', { + title: 'Delete session', + description: 'Delete a WhatsApp session (stops and logs out)', + inputSchema: SessionNameInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async delete({ session }: z.infer) { + return this.textRequest({ + method: 'DELETE', + url: `/api/sessions/${session}`, + }); + } + + @Tool('sessions-start', { + title: 'Start session', + description: 'Start an existing WhatsApp session', + inputSchema: SessionNameInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async start({ session }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/sessions/${session}/start`, + }); + } + + @Tool('sessions-stop', { + title: 'Stop session', + description: 'Stop a running WhatsApp session', + inputSchema: SessionNameInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async stop({ session }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/sessions/${session}/stop`, + }); + } + + @Tool('sessions-logout', { + title: 'Logout session', + description: 'Logout from a WhatsApp session', + inputSchema: SessionNameInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async logout({ session }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/sessions/${session}/logout`, + }); + } + + @Tool('sessions-restart', { + title: 'Restart session', + description: 'Restart a WhatsApp session', + inputSchema: SessionNameInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async restart({ session }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/sessions/${session}/restart`, + }); + } +} diff --git a/src/apps/mcp/tools/sessions.zod.ts b/src/apps/mcp/tools/sessions.zod.ts new file mode 100644 index 00000000..45ef6d6f --- /dev/null +++ b/src/apps/mcp/tools/sessions.zod.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { + ListSessionsQuery, + SessionCreateRequest, + SessionUpdateRequest, +} from '@waha/structures/sessions.dto'; + +export const SessionNameInput = z.object({ + session: z.string().describe('Session name'), +}); + +export const SessionListInput = DtoToZod(ListSessionsQuery); + +export const SessionCreateInput = DtoToZod(SessionCreateRequest); + +export const SessionUpdateInput = DtoToZod(SessionUpdateRequest).extend({ + session: z.string().describe('Session name'), +}); diff --git a/src/apps/mcp/tools/status.tools.ts b/src/apps/mcp/tools/status.tools.ts new file mode 100644 index 00000000..bcfa607b --- /dev/null +++ b/src/apps/mcp/tools/status.tools.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; +import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf'; +import { McpController } from '@waha/apps/mcp/decorators/controller'; +import { Tool } from '@waha/apps/mcp/decorators/tool'; +import { + DeleteStatusInput, + ImageStatusInput, + StatusSessionInput, + TextStatusInput, + VideoStatusInput, + VoiceStatusInput, +} from '@waha/apps/mcp/tools/status.zod'; + +const FileNote = + 'Use file.url for remote HTTP/HTTPS URLs. ' + + 'For local file paths - encode as base64 and pass via file.data instead.'; + +export class StatusTools extends McpController { + constructor(api: WAHASelf) { + super(api); + } + + @Tool('status-send-text', { + title: 'Send text status', + description: 'Post a text WhatsApp status update', + inputSchema: TextStatusInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendText({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/status/text`, + data: body, + }); + } + + @Tool('status-send-image', { + title: 'Send image status', + description: 'Post an image WhatsApp status update. ' + FileNote, + inputSchema: ImageStatusInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendImage({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/status/image`, + data: body, + }); + } + + @Tool('status-send-voice', { + title: 'Send voice status', + description: 'Post a voice WhatsApp status update. ' + FileNote, + inputSchema: VoiceStatusInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendVoice({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/status/voice`, + data: body, + }); + } + + @Tool('status-send-video', { + title: 'Send video status', + description: 'Post a video WhatsApp status update. ' + FileNote, + inputSchema: VideoStatusInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async sendVideo({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/status/video`, + data: body, + }); + } + + @Tool('status-delete', { + title: 'Delete status', + description: 'Delete a posted WhatsApp status update', + inputSchema: DeleteStatusInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, + }) + async delete({ session, ...body }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/status/delete`, + data: body, + }); + } + + @Tool('status-new-message-id', { + title: 'Generate status message ID', + description: + 'Generate a new message ID to use when sending a status update', + inputSchema: StatusSessionInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + }, + }) + async newMessageId({ session }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/status/new-message-id`, + }); + } +} diff --git a/src/apps/mcp/tools/status.zod.ts b/src/apps/mcp/tools/status.zod.ts new file mode 100644 index 00000000..7c17f745 --- /dev/null +++ b/src/apps/mcp/tools/status.zod.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod'; +import { + DeleteStatusRequest, + ImageStatus, + TextStatus, + VideoStatus, + VoiceStatus, +} from '@waha/structures/status.dto'; + +const SessionField = z.string().describe('Session name'); + +export const StatusSessionInput = z.object({ session: SessionField }); + +export const TextStatusInput = DtoToZod(TextStatus).extend({ + session: SessionField, +}); + +export const ImageStatusInput = DtoToZod(ImageStatus).extend({ + session: SessionField, +}); + +export const VoiceStatusInput = DtoToZod(VoiceStatus).extend({ + session: SessionField, +}); + +export const VideoStatusInput = DtoToZod(VideoStatus).extend({ + session: SessionField, +}); + +export const DeleteStatusInput = DtoToZod(DeleteStatusRequest).extend({ + session: SessionField, +}); diff --git a/src/core/SwaggerConfiguratorCore.ts b/src/core/SwaggerConfiguratorCore.ts index 8f101144..beca0076 100644 --- a/src/core/SwaggerConfiguratorCore.ts +++ b/src/core/SwaggerConfiguratorCore.ts @@ -190,6 +190,7 @@ export class SwaggerConfiguratorCore { const config = this.app.get(WhatsappConfigService); const exclude = lodash.uniq([ '/api/', + '/mcp', dashboardConfig.dashboardUri, '/health', '/ping', diff --git a/src/core/abc/session.abc.ts b/src/core/abc/session.abc.ts index 5f4d1003..5ddc1fce 100644 --- a/src/core/abc/session.abc.ts +++ b/src/core/abc/session.abc.ts @@ -1,6 +1,4 @@ -import { - getBrowserExecutablePath as getBrowserExecutablePathAutodetect, -} from '@waha/core/abc/session.browser'; +import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser'; import { CoreMediaConverter, IMediaConverter, @@ -145,7 +143,6 @@ const qrcode = require('qrcode-terminal'); axiosRetry(axios, { retries: 3 }); - export function ensureSuffix(phone) { const suffix = '@c.us'; if (phone.includes('@')) { diff --git a/src/core/app.module.core.ts b/src/core/app.module.core.ts index 28c315f1..1555904e 100644 --- a/src/core/app.module.core.ts +++ b/src/core/app.module.core.ts @@ -257,7 +257,7 @@ export class AppModuleCore { consumer .apply(ApiKeyAuthMiddleware) .exclude(...exclude) - .forRoutes('api', 'health'); + .forRoutes('api', 'health', 'mcp'); // Dashboard const dashboardCredentials = this.dashboardConfig.credentials; diff --git a/src/core/services/ApiKeyService.ts b/src/core/services/ApiKeyService.ts index 9ac7fa66..0956bc31 100644 --- a/src/core/services/ApiKeyService.ts +++ b/src/core/services/ApiKeyService.ts @@ -37,7 +37,7 @@ export class ApiKeyService { this.repository = manager.apiKeyRepository; } - async create(body: ApiKeyRequest): Promise { + async create(body: ApiKeyRequest, appId?: string): Promise { CheckInvariant(body); if (body.session) { const exists = await this.manager.exists(body.session); @@ -56,6 +56,7 @@ export class ApiKeyService { isAdmin: body.isAdmin, session: body.session, actions: body.actions ?? null, + app_id: appId ?? null, }; const idExists = await this.repository.getById(apikey.id); if (idExists) { @@ -87,6 +88,11 @@ export class ApiKeyService { if (!existing) { throw new NotFoundException('API key not found'); } + if (existing.app_id) { + throw new UnprocessableEntityException( + `This API key is managed by app '${existing.app_id}'. Update the app instead of the key.`, + ); + } const apikey: ApiKey = { ...existing, isActive: body.isActive, @@ -113,10 +119,52 @@ export class ApiKeyService { if (!existing) { throw new NotFoundException('API key not found'); } + if (existing.app_id) { + const appExists = await this.manager.store + .getWAHADatabase()('apps') + .where('id', existing.app_id) + .first(); + if (appExists) { + throw new UnprocessableEntityException( + `This API key is managed by app '${existing.app_id}'. Delete the app instead of the key.`, + ); + } + } await this.repository.deleteById(id); return { result: true }; } + async createForApp(body: ApiKeyRequest, appId: string): Promise { + return this.create(body, appId); + } + + async deleteForApp(id: string): Promise { + const existing = await this.repository.getById(id); + if (!existing) { + return; + } + await this.repository.deleteById(id); + } + + async updateForApp( + id: string, + updates: Partial>, + ): Promise { + const existing = await this.repository.getById(id); + if (!existing) { + return; + } + await this.repository.upsert({ ...existing, ...updates }); + } + + async getById(id: string): Promise { + const existing = await this.repository.getById(id); + if (!existing) { + return null; + } + return this.toDTO(existing); + } + private toDTO(apikey: ApiKey): ApiKeyDTO { return { id: apikey.id, diff --git a/src/core/storage/IApiKeyRepository.ts b/src/core/storage/IApiKeyRepository.ts index c13f2200..c9cb652b 100644 --- a/src/core/storage/IApiKeyRepository.ts +++ b/src/core/storage/IApiKeyRepository.ts @@ -7,6 +7,7 @@ export interface ApiKey { isAdmin: boolean; session: string | null; actions: SessionActions | null; + app_id?: string | null; } export interface IApiKeyRepository { diff --git a/src/structures/chatting.dto.ts b/src/structures/chatting.dto.ts index 61054818..623c1323 100644 --- a/src/structures/chatting.dto.ts +++ b/src/structures/chatting.dto.ts @@ -189,6 +189,7 @@ export class MessageTextRequest extends ChatRequest { mentions?: string[]; @ReplyToProperty() + @IsOptional() reply_to?: string; linkPreview?: boolean = true; diff --git a/yarn.lock b/yarn.lock index 614f31c1..08831266 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1367,6 +1367,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9": + version: 1.19.14 + resolution: "@hono/node-server@npm:1.19.14" + peerDependencies: + hono: ^4 + checksum: 10/618dd95feeb3fd11ec8502e088879cd86529523788de19602edebd16892dd61899e73564d6e3d00875cc5a49488a908ddb2aa425d28f9cdeb7f22cfecabf022c + languageName: node + linkType: hard + "@img/colour@npm:^1.0.0": version: 1.0.0 resolution: "@img/colour@npm:1.0.0" @@ -2274,6 +2283,39 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.29.0": + version: 1.29.0 + resolution: "@modelcontextprotocol/sdk@npm:1.29.0" + dependencies: + "@hono/node-server": "npm:^1.19.9" + ajv: "npm:^8.17.1" + ajv-formats: "npm:^3.0.1" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + eventsource-parser: "npm:^3.0.0" + express: "npm:^5.2.1" + express-rate-limit: "npm:^8.2.1" + hono: "npm:^4.11.4" + jose: "npm:^6.1.3" + json-schema-typed: "npm:^8.0.2" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.25 || ^4.0" + zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 10/ff551b97e06b661f95fec8fd34e112c446e69894a84a9979cdac369fb5de27f0a1a5c1f4e2a1f270cc60f93e54c28a8059a94ca51c3d528d2670ade874b244f9 + languageName: node + linkType: hard + "@mongodb-js/saslprep@npm:^1.1.5": version: 1.3.0 resolution: "@mongodb-js/saslprep@npm:1.3.0" @@ -4569,7 +4611,7 @@ __metadata: languageName: node linkType: hard -"ajv-formats@npm:3.0.1": +"ajv-formats@npm:3.0.1, ajv-formats@npm:^3.0.1": version: 3.0.1 resolution: "ajv-formats@npm:3.0.1" dependencies: @@ -4641,6 +4683,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.17.1": + version: 8.18.0 + resolution: "ajv@npm:8.18.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10/bfed9de827a2b27c6d4084324eda76a4e32bdde27410b3e9b81d06e6f8f5c78370fc6b93fe1d869f1939ff1d7c4ae8896960995acb8425e3e9288c8884247c48 + languageName: node + linkType: hard + "ansi-align@npm:^3.0.0": version: 3.0.1 resolution: "ansi-align@npm:3.0.1" @@ -5223,6 +5277,23 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.2.1": + version: 2.2.2 + resolution: "body-parser@npm:2.2.2" + dependencies: + bytes: "npm:^3.1.2" + content-type: "npm:^1.0.5" + debug: "npm:^4.4.3" + http-errors: "npm:^2.0.0" + iconv-lite: "npm:^0.7.0" + on-finished: "npm:^2.4.1" + qs: "npm:^6.14.1" + raw-body: "npm:^3.0.1" + type-is: "npm:^2.0.1" + checksum: 10/69671f67d4d5ae5974593901a92d639757231da1725ed6de4d35e86cde9ce7650afdf1cd28df9b6f7892ea7f9eb03ccb30c70fe27d679275ae4cb4aae5ce1b21 + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -5402,7 +5473,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2, bytes@npm:^3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 @@ -6131,6 +6202,16 @@ __metadata: languageName: node linkType: hard +"cors@npm:^2.8.5": + version: 2.8.6 + resolution: "cors@npm:2.8.6" + dependencies: + object-assign: "npm:^4" + vary: "npm:^1" + checksum: 10/aa7174305b21ceb90f9c84f4eaa32f04432d333addbfdc0d1eb7310393c48902e5364aada5ac2f5d054528d63b3179238444475426fcb74e1e345077de485727 + languageName: node + linkType: hard + "cosmiconfig@npm:^8.2.0": version: 8.3.6 resolution: "cosmiconfig@npm:8.3.6" @@ -6217,7 +6298,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -6436,7 +6517,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:^2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: 10/c0c8ff36079ce5ada64f46cc9d6fd47ebcf38241105b6e0c98f412e8ad91f084bcf906ff644cc3a4bd876ca27a62accb8b0fff72ea6ed1a414b89d8506f4a5ca @@ -6926,6 +7007,22 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": + version: 3.0.8 + resolution: "eventsource-parser@npm:3.0.8" + checksum: 10/286a84a7005e3e669e94dce0bb48f00acfda0d3973671ae2790e48a93f7b27a85b1828df8f3876c70afe4d577b6a7e4f8794cb596072585b584b4f207bc35c15 + languageName: node + linkType: hard + +"eventsource@npm:^3.0.2": + version: 3.0.7 + resolution: "eventsource@npm:3.0.7" + dependencies: + eventsource-parser: "npm:^3.0.1" + checksum: 10/e034915bc97068d1d38617951afd798e6776d6a3a78e36a7569c235b177c7afc2625c9fe82656f7341ab72c7eeecb3fd507b7f88e9328f2448872ff9c4742bb6 + languageName: node + linkType: hard + "execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -6986,6 +7083,17 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^8.2.1": + version: 8.3.2 + resolution: "express-rate-limit@npm:8.3.2" + dependencies: + ip-address: "npm:10.1.0" + peerDependencies: + express: ">= 4.11" + checksum: 10/71afac0fff29bf03117a89902953e7feafc67402332910cfb601b27da079b98f04ba551a1ef4f0ad5cf2538d9599c2649450af6d3f7505bcc7d24f7eaf765a4e + languageName: node + linkType: hard + "express@npm:5.1.0, express@npm:^4.21.1 || ^5.0.0": version: 5.1.0 resolution: "express@npm:5.1.0" @@ -7021,6 +7129,42 @@ __metadata: languageName: node linkType: hard +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: "npm:^2.0.0" + body-parser: "npm:^2.2.1" + content-disposition: "npm:^1.0.0" + content-type: "npm:^1.0.5" + cookie: "npm:^0.7.1" + cookie-signature: "npm:^1.2.1" + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + finalhandler: "npm:^2.1.0" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + merge-descriptors: "npm:^2.0.0" + mime-types: "npm:^3.0.0" + on-finished: "npm:^2.4.1" + once: "npm:^1.4.0" + parseurl: "npm:^1.3.3" + proxy-addr: "npm:^2.0.7" + qs: "npm:^6.14.0" + range-parser: "npm:^1.2.1" + router: "npm:^2.2.0" + send: "npm:^1.1.0" + serve-static: "npm:^2.2.0" + statuses: "npm:^2.0.1" + type-is: "npm:^2.0.1" + vary: "npm:^1.1.2" + checksum: 10/4aa545d89702ac83f645c77abda1b57bcabe288f0b380fb5580fac4e323ea0eb533005c8e666b4e19152fb16d4abf11ba87b22aa9a10857a0485cd86b94639bd + languageName: node + linkType: hard + "extend@npm:^3.0.0": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -7854,6 +7998,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.14 + resolution: "hono@npm:4.12.14" + checksum: 10/2e620adb89e773a7e982dee8d8c9917f79c71b89cb051b5bc0d2aa91b5318373e90ba7a236bdc4762dc36026c1d7e5e979d6b48cf1dbbe44fc524a5c4df227d9 + languageName: node + linkType: hard + "hookified@npm:^1.8.2, hookified@npm:^1.9.1": version: 1.9.1 resolution: "hookified@npm:1.9.1" @@ -7900,6 +8051,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: "npm:~2.0.0" + inherits: "npm:~2.0.4" + setprototypeof: "npm:~1.2.0" + statuses: "npm:~2.0.2" + toidentifier: "npm:~1.0.1" + checksum: 10/9fe31bc0edf36566c87048aed1d3d0cbe03552564adc3541626a0613f542d753fbcb13bdfcec0a3a530dbe1714bb566c89d46244616b66bddd26ac413b06a207 + languageName: node + linkType: hard + "http-proxy-agent@npm:^4.0.1": version: 4.0.1 resolution: "http-proxy-agent@npm:4.0.1" @@ -7989,6 +8153,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10/24c937b532f868e938386b62410b303b7c767ce3d08dc2829cbe59464d5a26ef86ae5ad1af6b34eec43ddfea39e7d101638644b0178d67262fa87015d59f983a + languageName: node + linkType: hard + "ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -8056,7 +8229,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.0, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.0, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 @@ -8094,6 +8267,13 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:10.1.0": + version: 10.1.0 + resolution: "ip-address@npm:10.1.0" + checksum: 10/a6979629d1ad9c1fb424bc25182203fad739b40225aebc55ec6243bbff5035faf7b9ed6efab3a097de6e713acbbfde944baacfa73e11852bb43989c45a68d79e + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -8888,6 +9068,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.2.2 + resolution: "jose@npm:6.2.2" + checksum: 10/fd66f24916d2507dbde0ca77ede86377e7d7eae42c7f94db0dfd014b0c6ce5041365dc8e757a44e54cd7606ea5e9c757aa544b51e55cb8a736e83320db1b8258 + languageName: node + linkType: hard + "joycon@npm:^3.1.1": version: 3.1.1 resolution: "joycon@npm:3.1.1" @@ -8969,6 +9156,13 @@ __metadata: languageName: node linkType: hard +"json-schema-typed@npm:^8.0.2": + version: 8.0.2 + resolution: "json-schema-typed@npm:8.0.2" + checksum: 10/fa866d1fe91e3a94aa4fe007861475cd03dcaf47b719861cab171ef2f8598478007c634d29ae45de94ee34ddff4e13414c63ea5ff06c5b868b613142c699d511 + languageName: node + linkType: hard + "json5@npm:^1.0.1": version: 1.0.2 resolution: "json5@npm:1.0.2" @@ -10991,6 +11185,13 @@ __metadata: languageName: node linkType: hard +"pkce-challenge@npm:^5.0.0": + version: 5.0.1 + resolution: "pkce-challenge@npm:5.0.1" + checksum: 10/51d11f68d5a78617cfb2e9c2706dadcc2cbe55ffb55b21d42a6ed848ac5159db2657bf6c966a5a414119aa839ceb64240afea35e9e1c06946b57606ed0b43789 + languageName: node + linkType: hard + "pkg-dir@npm:^4.2.0": version: 4.2.0 resolution: "pkg-dir@npm:4.2.0" @@ -11489,6 +11690,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.14.1": + version: 6.15.1 + resolution: "qs@npm:6.15.1" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac + languageName: node + linkType: hard + "querystring@npm:0.2.0": version: 0.2.0 resolution: "querystring@npm:0.2.0" @@ -11538,6 +11748,18 @@ __metadata: languageName: node linkType: hard +"raw-body@npm:^3.0.1": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10/4168c82157bd69175d5bd960e59b74e253e237b358213694946a427a6f750a18b8e150f036fed3421b3e83294b071a4e2bb01037a79ccacdac05360c63d3ebba + languageName: node + linkType: hard + "rc@npm:1.2.8, rc@npm:^1.2.7, rc@npm:^1.2.8": version: 1.2.8 resolution: "rc@npm:1.2.8" @@ -12059,7 +12281,7 @@ __metadata: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: 10/fde1630422502fbbc19e6844346778f99d449986b2f9cdcceb8326730d2f3d9964dbcb03c02aaadaefffecd0f2c063315ebea8b3ad895914bf1afc1747fc172e @@ -12484,7 +12706,7 @@ __metadata: languageName: node linkType: hard -"statuses@npm:^2.0.1": +"statuses@npm:^2.0.1, statuses@npm:~2.0.2": version: 2.0.2 resolution: "statuses@npm:2.0.2" checksum: 10/6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 @@ -12976,7 +13198,7 @@ __metadata: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 10/952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -13398,7 +13620,7 @@ __metadata: languageName: node linkType: hard -"unpipe@npm:1.0.0": +"unpipe@npm:1.0.0, unpipe@npm:~1.0.0": version: 1.0.0 resolution: "unpipe@npm:1.0.0" checksum: 10/4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2 @@ -13552,6 +13774,7 @@ __metadata: "@grpc/grpc-js": "npm:^1.14.1" "@grpc/proto-loader": "npm:^0.8.0" "@liaoliaots/nestjs-redis": "npm:^9" + "@modelcontextprotocol/sdk": "npm:^1.29.0" "@nestjs/axios": "npm:^3.0.2" "@nestjs/bullmq": "npm:^11.0.2" "@nestjs/cli": "npm:^11.0.0" @@ -13653,6 +13876,7 @@ __metadata: whatsapp-web.js: "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18" write-file-atomic: "npm:^6.0.0" yaml: "npm:^2.7.1" + zod: "npm:^4.3.6" dependenciesMeta: bufferutil: optional: true @@ -14105,9 +14329,25 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:^3.25.1": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10/7035328654113f1a0b8e4c2d34a06f918c93650ef8a50d4fb30ad8f22e47d5762c163af9c82494756b34776bae3c41c26cfc6945105b0eee7dceb528cc07e665 + languageName: node + linkType: hard + "zod@npm:^3.24.1": version: 3.25.63 resolution: "zod@npm:3.25.63" checksum: 10/ef3de3e94b27c94fd9c6b8d8fcd61bf6702494c91dcfb84f585f2822bcdf1d96305c953c264a221af79745795517d9d5b5ce59889d2a8d23967e6096945049bd languageName: node linkType: hard + +"zod@npm:^3.25 || ^4.0, zod@npm:^4.3.6": + version: 4.3.6 + resolution: "zod@npm:4.3.6" + checksum: 10/25fc0f62e01b557b4644bf0b393bbaf47542ab30877c37837ea8caf314a8713d220c7d7fe51f68ffa72f0e1018ddfa34d96f1973d23033f5a2a5a9b6b9d9da01 + languageName: node + linkType: hard