qwen_2.5_model / src /app.module.ts
Muhammad Noman
Configure managed services for Hugging Face Space
b58ffca
Raw
History Blame Contribute Delete
14.6 kB
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 {}