Spaces:
Runtime error
Runtime error
File size: 14,643 Bytes
46252cd b58ffca 46252cd b58ffca 46252cd b58ffca 46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 | import { Module, DynamicModule, Type } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import Redis, { type RedisOptions } from 'ioredis';
import { RedisThrottlerStorage } from './common/throttler/redis-throttler.storage';
import configuration from './config/configuration';
import { validateEnv } from './config/env.validation';
import { SessionModule } from './modules/session/session.module';
import { MessageModule } from './modules/message/message.module';
import { TemplateModule } from './modules/template/template.module';
import { WebhookModule } from './modules/webhook/webhook.module';
import { HealthModule } from './modules/health/health.module';
import { AuthModule } from './modules/auth/auth.module';
import { AuditModule } from './modules/audit/audit.module';
import { EngineModule } from './engine/engine.module';
import { LoggerModule } from './common/services/logger.module';
import { SettingsModule } from './modules/settings/settings.module';
import { InfraModule } from './modules/infra/infra.module';
import { EventsModule } from './modules/events/events.module';
import { ContactModule } from './modules/contact/contact.module';
import { GroupModule } from './modules/group/group.module';
import { ProfileModule } from './modules/profile/profile.module';
import { CallModule } from './modules/call/call.module';
import { LabelModule } from './modules/label/label.module';
import { ChannelModule } from './modules/channel/channel.module';
import { CacheModule } from './common/cache';
import { StorageModule } from './common/storage/storage.module';
import { StatsModule } from './modules/stats/stats.module';
import { MetricsModule } from './modules/metrics/metrics.module';
import { StatusModule } from './modules/status/status.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { HooksModule } from './core/hooks';
import { PluginsModule } from './core/plugins';
import { PluginsApiModule } from './modules/plugins/plugins.module';
import { AgentToolsModule } from './core/agent-tools/agent-tools.module';
import { IntegrationModule } from './modules/integration/integration.module';
import { SearchModule } from './modules/search/search.module';
// Only import QueueModule if explicitly enabled to avoid Redis connection errors
const queueModules: Array<Type | DynamicModule> = [];
if (process.env.QUEUE_ENABLED === 'true') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const queueModule = require('./modules/queue/queue.module') as {
QueueModule: Type;
};
queueModules.push(queueModule.QueueModule);
}
// Global message search. Opt-out via SEARCH_ENABLED=false: the module (route + provider + registry)
// is absent entirely — zero footprint, no DI wiring. Mirrors the queueModules/MCP conditional shape so
// an opt-out deployment never even loads the search providers. Default is ON for zero-config first boot.
const searchModules: Array<Type | DynamicModule> = [];
if (process.env.SEARCH_ENABLED !== 'false') {
searchModules.push(SearchModule);
}
// Only mount the MCP server if explicitly enabled to avoid startup cost and
// the SDK import (which pulls in @modelcontextprotocol/sdk) in non-MCP deployments.
const mcpModules: Array<Type | DynamicModule> = [];
if (process.env.MCP_ENABLED === 'true') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { McpModule } = require('./modules/mcp/mcp.module') as typeof import('./modules/mcp/mcp.module');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { version } = require('../package.json') as { version: string };
mcpModules.push(
McpModule.forRoot({
basePath: '/mcp',
serverInfo: { name: 'openwa', version },
}),
);
}
// Serve the bundled dashboard SPA from this same NestJS process/port when a build is
// present (the production image copies dashboard/dist in). In local dev the build is
// absent, so this stays inert and the Vite dev server (:2886) handles the UI. Opt out
// explicitly with SERVE_DASHBOARD=false. The path + flags are exported so main.ts can
// log a clear status line (served / disabled / build missing) at startup.
export const DASHBOARD_DIST = path.resolve(__dirname, '..', 'dashboard', 'dist');
export const dashboardServingEnabled = process.env.SERVE_DASHBOARD !== 'false';
export const dashboardBuildPresent = fs.existsSync(path.join(DASHBOARD_DIST, 'index.html'));
const serveStaticModules: Array<Type | DynamicModule> = [];
if (dashboardServingEnabled && dashboardBuildPresent) {
serveStaticModules.push(
ServeStaticModule.forRoot({
rootPath: DASHBOARD_DIST,
// Let Nest own these so unknown API/socket routes return real 404s/JSON rather
// than the SPA index.html fallback (Express 5 / path-to-regexp v8 wildcard syntax).
exclude: ['/api/{*splat}', '/socket.io/{*splat}', '/mcp', '/mcp/{*splat}'],
}),
);
}
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
validate: validateEnv,
}),
// Main Database (always SQLite - boot config)
TypeOrmModule.forRootAsync({
name: 'main',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
// Default ON for zero-config first boot. When disabled
// (MAIN_DATABASE_SYNCHRONIZE=false), the main-owned migrations create the
// api_keys/audit_logs schema instead — never both at once.
const synchronize = configService.get<boolean>('database.synchronize', true);
return {
name: 'main',
type: 'better-sqlite3' as const,
database: configService.get<string>('database.database', './data/main.sqlite'),
entities: [
__dirname + '/modules/auth/**/*.entity{.ts,.js}',
__dirname + '/modules/audit/**/*.entity{.ts,.js}',
],
// Dedicated migrations dir for the main connection only (must NOT run the
// data-connection migrations, which target session/webhook/message tables).
migrations: [__dirname + '/database/migrations-main/*{.ts,.js}'],
synchronize,
migrationsRun: !synchronize,
logging: configService.get<boolean>('database.logging', false),
};
},
}),
// Data Storage Database (pluggable - user data)
TypeOrmModule.forRootAsync({
name: 'data',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const dbType = configService.get<'sqlite' | 'postgres'>('dataDatabase.type', 'sqlite');
const baseConfig = {
entities: [
__dirname + '/modules/session/**/*.entity{.ts,.js}',
__dirname + '/modules/webhook/**/*.entity{.ts,.js}',
__dirname + '/modules/message/**/*.entity{.ts,.js}',
__dirname + '/modules/template/**/*.entity{.ts,.js}',
__dirname + '/engine/**/*.entity{.ts,.js}',
__dirname + '/modules/integration/**/*.entity{.ts,.js}',
],
migrations: [__dirname + '/database/migrations/*{.ts,.js}'],
logging: configService.get<boolean>('dataDatabase.logging', false),
};
if (dbType === 'postgres') {
// Schema selection: 'public' (default) is a no-op vs the historical behavior. A non-public
// schema additionally sets the session search_path via pg's startup `options` parameter so
// the project's RAW, unqualified migration SQL (CREATE TABLE "x"..., ALTER TABLE "y"...)
// resolves to the configured schema — TypeORM's `schema` option alone does NOT set
// search_path, so without this raw DDL would land in `public` while the migration ledger
// lands in the configured schema.
const schema = configService.get<string>('dataDatabase.schema', 'public');
const useCustomSearchPath = schema && schema !== 'public';
return {
...baseConfig,
name: 'data',
type: 'postgres' as const,
schema,
host: configService.get<string>('dataDatabase.host'),
port: configService.get<number>('dataDatabase.port'),
username: configService.get<string>('dataDatabase.username'),
password: configService.get<string>('dataDatabase.password'),
database: configService.get<string>('dataDatabase.name', 'openwa'),
ssl: configService.get<boolean>('dataDatabase.ssl', false)
? {
rejectUnauthorized: configService.get<boolean>('dataDatabase.sslRejectUnauthorized', true),
}
: false,
// Never auto-sync Postgres in production; rely on migrations.
synchronize: configService.get<boolean>('dataDatabase.synchronize', false),
migrationsRun: true,
retryAttempts: 10,
retryDelay: 3000,
extra: {
max: configService.get<number>('dataDatabase.poolSize', 10),
// Runtime query/pool timeouts so a stuck query or saturated pool fails fast instead of
// hanging requests. statement_timeout bounds live runtime queries; the boot migrations
// (migrationsRun above) reset it to 0 per-transaction via SET LOCAL, so a long
// CREATE INDEX / backfill at boot is never aborted by it.
statement_timeout: configService.get<number>('dataDatabase.statementTimeoutMs', 30000),
idleTimeoutMillis: configService.get<number>('dataDatabase.idleTimeoutMs', 30000),
connectionTimeoutMillis: configService.get<number>('dataDatabase.connectionTimeoutMs', 10000),
// Only set for a non-public schema (see above). `<schema>,public` keeps public on the
// path so pg_catalog + any public helpers still resolve; the configured schema wins.
...(useCustomSearchPath ? { options: `-c search_path=${schema},public` } : {}),
},
};
}
// SQLite data DB: schema is MIGRATION-managed by default (DATABASE_SYNCHRONIZE unset/false),
// matching configuration.ts and .env.example ("Set false in production"). Set
// DATABASE_SYNCHRONIZE=true for zero-config synchronize instead. Computed once: the resolved
// value is always a boolean, so a get(..., true) fallback would never fire (and would be a trap).
const synchronize = configService.get<boolean>('dataDatabase.synchronize', false);
return {
...baseConfig,
name: 'data',
type: 'better-sqlite3' as const,
database: configService.get<string>('dataDatabase.database', './data/openwa.sqlite'),
synchronize,
migrationsRun: !synchronize,
};
},
}),
// Rate limiting. When REDIS_ENABLED, the hit-count storage moves to Redis so limits aggregate
// across replicas; otherwise the default in-memory (per-process) storage is used. Default off —
// a single-node deployment gains nothing from Redis storage, and it adds a connection dep.
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const redisTls = configService.get<RedisOptions['tls']>('redis.tls');
const throttlers = [
{
name: 'short',
ttl: configService.get<number>('api.rateLimit.shortTtl', 1000),
limit: configService.get<number>('api.rateLimit.shortLimit', 10),
},
{
name: 'medium',
ttl: configService.get<number>('api.rateLimit.mediumTtl', 60000),
limit: configService.get<number>('api.rateLimit.mediumLimit', 100),
},
{
name: 'long',
ttl: configService.get<number>('api.rateLimit.longTtl', 3600000),
limit: configService.get<number>('api.rateLimit.longLimit', 1000),
},
];
// Fail-open on Redis error (see RedisThrottlerStorage), so a Redis outage never blocks the API.
const redisStorage =
process.env.REDIS_ENABLED === 'true'
? new RedisThrottlerStorage(
new Redis({
host: configService.get<string>('redis.host', 'localhost'),
port: configService.get<number>('redis.port', 6379),
username: configService.get<string>('redis.username'),
password: configService.get<string>('redis.password'),
connectTimeout: configService.get<number>('redis.connectTimeoutMs', 5000),
...(redisTls ? { tls: redisTls } : {}),
maxRetriesPerRequest: 3,
}),
)
: undefined;
return { throttlers, ...(redisStorage ? { storage: redisStorage } : {}) };
},
}),
// Core modules
HooksModule, // Global hook system for plugin integration
PluginsModule, // Global plugin system
LoggerModule,
CacheModule,
StorageModule,
AuditModule,
EventsModule, // WebSocket real-time events
...queueModules,
AuthModule,
EngineModule,
SessionModule,
MessageModule,
TemplateModule,
WebhookModule,
HealthModule,
SettingsModule,
InfraModule,
ContactModule,
GroupModule,
ProfileModule, // Own-profile API (name / status / picture)
CallModule, // Incoming-call API (reject a ringing call)
LabelModule, // Phase 3: Labels Management
ChannelModule, // Phase 3: Channels/Newsletter
StatsModule, // Phase 3: Statistics Dashboard
MetricsModule, // Prometheus /api/metrics
StatusModule, // Phase 3: Status/Stories API
CatalogModule, // Phase 3: Catalog API (WhatsApp Business)
PluginsApiModule, // Phase 5: Plugins API
AgentToolsModule, // Agent-invocable tool registry (protocol-neutral)
IntegrationModule, // Integration Fabric: @Public provider-webhook ingress + fast-ack pipeline
...searchModules, // Global message search (opt-out via SEARCH_ENABLED=false; default ON)
...mcpModules, // MCP Streamable-HTTP server (opt-in via MCP_ENABLED=true)
...serveStaticModules, // Bundled dashboard SPA (production single-port setup)
],
})
export class AppModule {}
|