feat: Implement feature flag system with helpers, service, and loader

- Added feature flag helpers and decorators for easy usage in commands.
- Created a feature flag service for managing flags, including initialization, caching, and evaluation strategies.
- Implemented a loader to initialize the feature flag service on bot startup.
- Defined types for feature flags, including configurations, contexts, evaluations, and statistics.
- Provided examples of feature flag usage in commands, demonstrating various patterns such as A/B testing, gradual rollouts, and access control.
This commit is contained in:
Shni
2025-10-31 21:12:27 -05:00
parent 21a8c637ab
commit 89d475ba66
100 changed files with 2987 additions and 1 deletions

View File

@@ -0,0 +1,300 @@
# 🎮 Feature Flags - Guía Rápida de Instalación
## 📦 Lo que se creó
1. **Schema de Prisma** (`prisma/schema.prisma`)
- Modelo `FeatureFlag` con todos los campos necesarios
2. **Servicio Principal** (`src/core/services/FeatureFlagService.ts`)
- Singleton con caché en memoria
- Evaluación de flags con contexto
- Estrategias de rollout (percentage, whitelist, blacklist, gradual, random)
- Sistema de estadísticas
3. **Tipos TypeScript** (`src/core/types/featureFlags.ts`)
- Tipos completos para el sistema
- Interfaces para configuración y evaluación
4. **Helpers y Decoradores** (`src/core/lib/featureFlagHelpers.ts`)
- `@RequireFeature` - Decorador para proteger métodos
- `featureGuard` - Guard con respuesta automática
- `isFeatureEnabled` - Check básico
- `abTest` - A/B testing
- Y más...
5. **Comando de Administración** (`src/commands/admin/featureflags.ts`)
- Crear, listar, actualizar, eliminar flags
- Configurar rollouts
- Ver estadísticas
6. **Loader** (`src/core/loaders/featureFlagsLoader.ts`)
- Inicialización automática del servicio
7. **Documentación** (`README/FEATURE_FLAGS_SYSTEM.md`)
- Guía completa con ejemplos
8. **Scripts** (`scripts/setupFeatureFlags.ts`)
- Setup inicial con flags de ejemplo
9. **Ejemplos** (`src/examples/featureFlagsUsage.ts`)
- 11 patrones de uso diferentes
---
## 🚀 Instalación (Paso a Paso)
### 1. Genera el cliente de Prisma
```bash
npx prisma generate
```
Esto creará los tipos TypeScript para el nuevo modelo `FeatureFlag`.
### 2. Ejecuta la migración
```bash
npx prisma migrate dev --name add_feature_flags
```
Esto creará la tabla en tu base de datos.
### 3. (Opcional) Crea flags de ejemplo
```bash
npx tsx scripts/setupFeatureFlags.ts
```
Esto creará 8 feature flags de ejemplo para que puedas probar el sistema.
### 4. Integra el loader en tu bot
Abre tu archivo principal donde cargas los servicios (probablemente `src/main.ts` o similar) y añade:
```typescript
import { loadFeatureFlags } from './core/loaders/featureFlagsLoader';
// Antes de iniciar el bot
await loadFeatureFlags();
```
O si tienes un sistema de loaders centralizado, simplemente impórtalo ahí.
### 5. ¡Listo! Empieza a usar feature flags
---
## 💡 Uso Rápido
### En un comando nuevo o existente:
```typescript
import { featureGuard } from '@/core/lib/featureFlagHelpers';
export async function execute(interaction: CommandInteraction) {
// Check si el flag está habilitado
if (!await featureGuard('new_feature', interaction)) {
return; // Automáticamente responde al usuario si está disabled
}
// Tu código aquí (solo se ejecuta si el flag está habilitado)
await interaction.reply('✨ Feature habilitada!');
}
```
### Crear un flag desde Discord:
```
/featureflags create name:new_feature status:disabled target:global description:"Mi nueva feature"
```
### Habilitarlo:
```
/featureflags update flag:new_feature status:enabled
```
### Rollout progresivo (25% de usuarios):
```
/featureflags rollout flag:new_feature strategy:percentage percentage:25
```
---
## 🎯 Casos de Uso Comunes
### 1. Desplegar una feature nueva gradualmente
```bash
# Día 1: Deshabilitada, en desarrollo
/featureflags create name:pvp_system status:disabled target:global
# Día 5: Beta testing con usuarios específicos
/featureflags rollout flag:pvp_system strategy:whitelist
# (añade IDs mediante código o actualiza la config)
# Día 10: 10% de usuarios
/featureflags rollout flag:pvp_system strategy:percentage percentage:10
# Día 15: 50% de usuarios
/featureflags update flag:pvp_system
/featureflags rollout flag:pvp_system strategy:percentage percentage:50
# Día 20: 100% de usuarios
/featureflags update flag:pvp_system status:enabled
# Día 30: Eliminar el flag y el código del check
/featureflags delete flag:pvp_system
```
### 2. A/B Testing
```typescript
import { abTest, extractContext } from '@/core/lib/featureFlagHelpers';
const context = extractContext(interaction);
await abTest('new_algorithm', context, {
variant: async () => {
// 50% de usuarios ven esto
return newAlgorithm();
},
control: async () => {
// 50% ven esto
return oldAlgorithm();
}
});
```
### 3. Kill Switch (emergencias)
Si hay un bug crítico en una feature:
```bash
/featureflags update flag:problematic_feature status:maintenance
```
Esto la deshabilitará inmediatamente sin necesidad de redeploy.
### 4. Eventos temporales
```typescript
// Configurar con fechas
await featureFlagService.setFlag({
name: 'christmas_event',
status: 'enabled',
target: 'global',
startDate: new Date('2025-12-15'),
endDate: new Date('2025-12-31')
});
// El flag se auto-deshabilitará el 1 de enero
```
---
## 📊 Monitoreo
### Ver estadísticas de uso:
```
/featureflags stats flag:nombre_flag
```
Te mostrará:
- Total de evaluaciones
- Cuántas veces se habilitó
- Cuántas veces se deshabilitó
- Tasa de habilitación (%)
### Ver todos los flags:
```
/featureflags list
```
### Refrescar caché:
```
/featureflags refresh
```
---
## 🔧 Troubleshooting
### Los flags no se aplican
1. Verifica que el servicio está inicializado:
```typescript
await loadFeatureFlags();
```
2. Refresca el caché:
```
/featureflags refresh
```
3. Verifica que el flag existe:
```
/featureflags info flag:nombre_flag
```
### Errores de TypeScript
Si ves errores tipo "Property 'featureFlag' does not exist":
```bash
npx prisma generate
```
Esto regenerará los tipos de Prisma.
### La migración falla
Si la migración falla, verifica tu conexión a la base de datos en `.env`:
```env
XATA_DB="postgresql://..."
XATA_SHADOW_DB="postgresql://..."
```
---
## 📚 Recursos
- **Documentación completa**: `README/FEATURE_FLAGS_SYSTEM.md`
- **Ejemplos de uso**: `src/examples/featureFlagsUsage.ts`
- **Tipos**: `src/core/types/featureFlags.ts`
- **Servicio**: `src/core/services/FeatureFlagService.ts`
- **Helpers**: `src/core/lib/featureFlagHelpers.ts`
---
## 🎉 ¡Listo!
Ahora tienes un sistema completo de Feature Flags. Puedes:
✅ Desplegar features sin miedo a romper producción
✅ Hacer rollouts progresivos
✅ A/B testing
✅ Kill switches para emergencias
✅ Eventos temporales
✅ Beta testing con usuarios específicos
✅ Monitorear el uso de cada feature
---
**Tip Pro**: Combina feature flags con tus deployments. Por ejemplo:
1. Despliega código nuevo con flag disabled
2. Verifica que el deploy fue exitoso
3. Habilita el flag progresivamente (10% → 50% → 100%)
4. Monitorea métricas/errores
5. Si hay problemas, desactiva el flag instantáneamente
6. Una vez estable, elimina el flag y el código antiguo
---
Creado con 🎮 para Amayo Bot

View File

@@ -0,0 +1,626 @@
# 🎮 Feature Flags System
Sistema completo de Feature Flags para control de funcionalidades, rollouts progresivos, A/B testing y toggles dinámicos.
## 📋 Índice
- [Instalación](#instalación)
- [Conceptos](#conceptos)
- [Uso Básico](#uso-básico)
- [Ejemplos Avanzados](#ejemplos-avanzados)
- [Comando de Administración](#comando-de-administración)
- [Estrategias de Rollout](#estrategias-de-rollout)
- [Best Practices](#best-practices)
---
## 🚀 Instalación
### 1. Migración de Base de Datos
```bash
npx prisma migrate dev --name add_feature_flags
```
### 2. Inicialización del Servicio
En tu `src/loaders/` o punto de entrada principal:
```typescript
import { featureFlagService } from '@/core/services/FeatureFlagService';
// Inicializar el servicio
await featureFlagService.initialize();
```
---
## 🧠 Conceptos
### Estados de Flags
- **`enabled`**: Habilitado para todos
- **`disabled`**: Deshabilitado para todos
- **`rollout`**: Rollout progresivo según estrategia
- **`maintenance`**: Deshabilitado por mantenimiento
### Targets
- **`global`**: Aplica a todo el bot
- **`guild`**: Aplica por servidor
- **`user`**: Aplica por usuario
- **`channel`**: Aplica por canal
### Estrategias de Rollout
- **`percentage`**: Basado en % de usuarios
- **`whitelist`**: Solo IDs específicos
- **`blacklist`**: Todos excepto IDs específicos
- **`gradual`**: Rollout gradual en el tiempo
- **`random`**: Aleatorio por sesión
---
## 💡 Uso Básico
### 1. En Comandos con Decorador
```typescript
import { RequireFeature } from '@/core/lib/featureFlagHelpers';
class ShopCommand {
@RequireFeature('new_shop_system', {
fallbackMessage: '🔧 El nuevo sistema de tienda estará disponible pronto'
})
async execute(interaction: CommandInteraction) {
// Este código solo se ejecuta si el flag está habilitado
await interaction.reply('Bienvenido a la nueva tienda!');
}
}
```
### 2. Con Guard en el Handler
```typescript
import { featureGuard } from '@/core/lib/featureFlagHelpers';
async function handleMineCommand(interaction: CommandInteraction) {
// Check del flag
if (!await featureGuard('new_mining_system', interaction)) {
return; // Automáticamente responde al usuario
}
// Código del comando nuevo
await doNewMining(interaction);
}
```
### 3. Check Manual
```typescript
import { isFeatureEnabledForInteraction } from '@/core/lib/featureFlagHelpers';
async function execute(interaction: CommandInteraction) {
const useNewAlgorithm = await isFeatureEnabledForInteraction(
'improved_algorithm',
interaction
);
if (useNewAlgorithm) {
await newAlgorithm();
} else {
await oldAlgorithm();
}
}
```
---
## 🎯 Ejemplos Avanzados
### A/B Testing
```typescript
import { abTest, extractContext } from '@/core/lib/featureFlagHelpers';
async function handleShop(interaction: CommandInteraction) {
const context = extractContext(interaction);
const result = await abTest('new_shop_ui', context, {
variant: async () => {
// Nueva UI
return buildNewShopUI();
},
control: async () => {
// UI antigua
return buildOldShopUI();
}
});
await interaction.reply(result);
}
```
### Múltiples Flags (AND)
```typescript
import { requireAllFeatures, extractContext } from '@/core/lib/featureFlagHelpers';
async function handlePremiumFeature(interaction: CommandInteraction) {
const context = extractContext(interaction);
const hasAccess = await requireAllFeatures(
['premium_features', 'beta_access', 'new_ui'],
context
);
if (!hasAccess) {
await interaction.reply('No tienes acceso a esta funcionalidad');
return;
}
// Código de la feature premium
}
```
### Múltiples Flags (OR)
```typescript
import { requireAnyFeature, extractContext } from '@/core/lib/featureFlagHelpers';
async function handleSpecialEvent(interaction: CommandInteraction) {
const context = extractContext(interaction);
const hasEventAccess = await requireAnyFeature(
['halloween_event', 'christmas_event', 'beta_events'],
context
);
if (!hasEventAccess) {
await interaction.reply('No hay eventos activos para ti');
return;
}
// Código del evento
}
```
### Con Fallback
```typescript
import { withFeature, extractContext } from '@/core/lib/featureFlagHelpers';
async function getData(interaction: CommandInteraction) {
const context = extractContext(interaction);
const data = await withFeature(
'new_data_source',
context,
async () => {
// Fuente nueva
return fetchFromNewAPI();
},
async () => {
// Fuente antigua (fallback)
return fetchFromOldAPI();
}
);
return data;
}
```
---
## 🛠️ Comando de Administración
### Crear un Flag
```
/featureflags create name:new_shop_system status:disabled target:global description:"Nuevo sistema de tienda"
```
### Listar Flags
```
/featureflags list
```
### Ver Info de un Flag
```
/featureflags info flag:new_shop_system
```
### Actualizar Estado
```
/featureflags update flag:new_shop_system status:enabled
```
### Configurar Rollout Progresivo
```
/featureflags rollout flag:new_shop_system strategy:percentage percentage:25
```
Esto habilitará la feature para el 25% de los usuarios.
### Configurar Rollout Gradual
```typescript
// Programáticamente
await featureFlagService.setFlag({
name: 'new_combat_system',
status: 'rollout',
target: 'user',
rolloutStrategy: 'gradual',
rolloutConfig: {
gradual: {
startPercentage: 10, // Empieza con 10%
targetPercentage: 100, // Llega al 100%
durationDays: 7 // En 7 días
}
},
startDate: new Date()
});
```
### Ver Estadísticas
```
/featureflags stats flag:new_shop_system
```
### Refrescar Caché
```
/featureflags refresh
```
### Eliminar Flag
```
/featureflags delete flag:old_feature
```
---
## 📊 Estrategias de Rollout
### 1. Percentage (Porcentaje)
Distribuye la feature a un % de usuarios de forma determinista.
```typescript
await featureFlagService.setFlag({
name: 'feature_x',
status: 'rollout',
target: 'user',
rolloutStrategy: 'percentage',
rolloutConfig: {
percentage: 50 // 50% de usuarios
}
});
```
### 2. Whitelist (Lista Blanca)
Solo para IDs específicos.
```typescript
await featureFlagService.setFlag({
name: 'beta_features',
status: 'rollout',
target: 'user',
rolloutStrategy: 'whitelist',
rolloutConfig: {
targetIds: [
'123456789', // User ID 1
'987654321' // User ID 2
]
}
});
```
### 3. Blacklist (Lista Negra)
Para todos excepto IDs específicos.
```typescript
await featureFlagService.setFlag({
name: 'stable_feature',
status: 'rollout',
target: 'guild',
rolloutStrategy: 'blacklist',
rolloutConfig: {
targetIds: [
'guild_id_problematico'
]
}
});
```
### 4. Gradual (Progresivo en el Tiempo)
Rollout gradual durante X días.
```typescript
await featureFlagService.setFlag({
name: 'major_update',
status: 'rollout',
target: 'user',
rolloutStrategy: 'gradual',
rolloutConfig: {
gradual: {
startPercentage: 5, // Empieza con 5%
targetPercentage: 100, // Termina en 100%
durationDays: 14 // Durante 14 días
}
},
startDate: new Date() // Importante: define cuándo empieza
});
```
---
## ✨ Best Practices
### 1. Nombres Claros y Descriptivos
```typescript
// ❌ Mal
'flag_1'
'test'
'new'
// ✅ Bien
'new_shop_ui_v2'
'improved_combat_algorithm'
'halloween_2025_event'
```
### 2. Siempre con Descripción
```typescript
await featureFlagService.setFlag({
name: 'new_mining_system',
description: 'Sistema de minería rediseñado con durabilidad de herramientas',
status: 'disabled',
target: 'global'
});
```
### 3. Rollouts Graduales para Cambios Grandes
Para cambios importantes, usa rollout gradual:
1. Día 1-3: 10% de usuarios
2. Día 4-7: 50% de usuarios
3. Día 8-14: 100% de usuarios
### 4. Limpiar Flags Obsoletos
Una vez que una feature está 100% desplegada y estable:
1. Elimina el flag
2. Elimina el código del check
3. Mantén solo la nueva implementación
### 5. Usar Whitelists para Beta Testers
```typescript
await featureFlagService.setFlag({
name: 'experimental_features',
status: 'rollout',
target: 'user',
rolloutStrategy: 'whitelist',
rolloutConfig: {
targetIds: BETA_TESTER_IDS // Array de tus beta testers
}
});
```
### 6. Fechas de Expiración para Eventos
```typescript
await featureFlagService.setFlag({
name: 'christmas_2025_event',
status: 'enabled',
target: 'global',
startDate: new Date('2025-12-01'),
endDate: new Date('2025-12-31')
});
```
El flag se auto-deshabilitará después del 31 de diciembre.
### 7. Caché y Performance
El servicio cachea flags en memoria por 5 minutos. Si necesitas actualizaciones inmediatas:
```
/featureflags refresh
```
O programáticamente:
```typescript
await featureFlagService.refreshCache();
```
---
## 🔥 Casos de Uso Reales
### Lanzamiento de Comando Nuevo
```typescript
// Fase 1: Desarrollo - Deshabilitado
await featureFlagService.setFlag({
name: 'pvp_arena_command',
status: 'disabled',
target: 'global'
});
// Fase 2: Beta Testing - Solo whitelisted
await featureFlagService.setFlag({
name: 'pvp_arena_command',
status: 'rollout',
target: 'guild',
rolloutStrategy: 'whitelist',
rolloutConfig: {
targetIds: ['guild_beta_1', 'guild_beta_2']
}
});
// Fase 3: Rollout Progresivo - 25%
await featureFlagService.setFlag({
name: 'pvp_arena_command',
status: 'rollout',
target: 'user',
rolloutStrategy: 'percentage',
rolloutConfig: { percentage: 25 }
});
// Fase 4: Habilitado para Todos
await featureFlagService.setFlag({
name: 'pvp_arena_command',
status: 'enabled',
target: 'global'
});
// Fase 5: Cleanup - Eliminar flag y código del check
await featureFlagService.removeFlag('pvp_arena_command');
```
### Migración de Sistema Antiguo a Nuevo
```typescript
// En el comando
async function handleInventory(interaction: CommandInteraction) {
const context = extractContext(interaction);
await abTest('inventory_system_v2', context, {
variant: async () => {
// Sistema nuevo
return await newInventorySystem.show(interaction);
},
control: async () => {
// Sistema antiguo
return await oldInventorySystem.show(interaction);
}
});
}
```
Luego gradualmente aumentas el % hasta 100% y eliminas el código antiguo.
### Kill Switch para Emergencias
```typescript
// Si hay un bug crítico en una feature:
await featureFlagService.setFlag({
name: 'problematic_feature',
status: 'maintenance', // Deshabilitado inmediatamente
target: 'global'
});
// O via comando Discord:
// /featureflags update flag:problematic_feature status:maintenance
```
---
## 📚 API Reference
Ver `src/core/types/featureFlags.ts` para tipos completos.
### FeatureFlagService
```typescript
// Inicializar
await featureFlagService.initialize();
// Check si está habilitado
const enabled = await featureFlagService.isEnabled('flag_name', context);
// Crear/actualizar flag
await featureFlagService.setFlag(config);
// Eliminar flag
await featureFlagService.removeFlag('flag_name');
// Obtener flag
const flag = featureFlagService.getFlag('flag_name');
// Obtener todos los flags
const flags = featureFlagService.getFlags();
// Estadísticas
const stats = featureFlagService.getStats('flag_name');
const allStats = featureFlagService.getAllStats();
// Refrescar caché
await featureFlagService.refreshCache();
featureFlagService.clearEvaluationCache();
```
### Helpers
```typescript
// Check básico
await isFeatureEnabled(flagName, context);
await isFeatureEnabledForInteraction(flagName, interaction);
// Guards
await featureGuard(flagName, interaction, options);
// Decorador
@RequireFeature('flag_name', options)
// A/B Testing
await abTest(flagName, context, { variant, control });
// Wrapper
await withFeature(flagName, context, fn, fallback);
// Múltiples flags
await requireAllFeatures(flags, context); // AND
await requireAnyFeature(flags, context); // OR
```
---
## 🎮 Integración con tu Bot
El sistema se integra automáticamente si añades la inicialización en tu loader:
```typescript
// src/loaders/featureFlagsLoader.ts
import { featureFlagService } from '../services/FeatureFlagService';
import logger from '../lib/logger';
export async function loadFeatureFlags() {
try {
await featureFlagService.initialize();
logger.info('[FeatureFlags] Sistema inicializado');
} catch (error) {
logger.error('[FeatureFlags] Error al inicializar:', error);
}
}
```
Luego en tu `main.ts` o donde cargues servicios:
```typescript
import { loadFeatureFlags } from './loaders/featureFlagsLoader';
// ...
await loadFeatureFlags();
// ...
```
---
Creado con 🎮 para el bot Amayo

View File

@@ -1052,3 +1052,40 @@ model DeathLog {
@@index([userId, guildId])
@@index([createdAt])
}
/**
* -----------------------------------------------------------------------------
* Sistema de Feature Flags
* -----------------------------------------------------------------------------
* Control de features para rollouts progresivos, A/B testing y toggles
* Permite activar/desactivar funcionalidades sin deployar código
*/
model FeatureFlag {
id String @id @default(cuid())
name String @unique
description String?
status String @default("disabled") // enabled|disabled|rollout|maintenance
// Nivel de aplicación: global, guild, user, channel
target String @default("global")
// Estrategia de rollout (para status = rollout)
rolloutStrategy String? // percentage|whitelist|blacklist|gradual|random
// Configuración de la estrategia (JSON)
rolloutConfig String? // JSON serializado
// Fechas de inicio/fin
startDate DateTime?
endDate DateTime?
// Metadata adicional
metadata String? // JSON serializado
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([target])
}

View File

@@ -0,0 +1,173 @@
/**
* Setup inicial del sistema de Feature Flags
*
* Este script:
* 1. Crea algunos feature flags de ejemplo
* 2. Muestra cómo configurar diferentes estrategias
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function setupFeatureFlags() {
console.log("🎮 Configurando Feature Flags de ejemplo...\n");
try {
// 1. Flag deshabilitado por defecto (en desarrollo)
await prisma.featureFlag.upsert({
where: { name: "new_shop_system" },
create: {
name: "new_shop_system",
description: "Nuevo sistema de tienda con UI mejorada",
status: "disabled",
target: "global",
},
update: {},
});
console.log('✅ Flag "new_shop_system" creado (disabled)');
// 2. Flag habilitado globalmente
await prisma.featureFlag.upsert({
where: { name: "inventory_ui_v2" },
create: {
name: "inventory_ui_v2",
description: "Nueva UI del inventario con mejor UX",
status: "enabled",
target: "global",
},
update: {},
});
console.log('✅ Flag "inventory_ui_v2" creado (enabled)');
// 3. Flag con rollout por porcentaje (25% de usuarios)
await prisma.featureFlag.upsert({
where: { name: "improved_combat_algorithm" },
create: {
name: "improved_combat_algorithm",
description: "Algoritmo de combate mejorado con mejor balance",
status: "rollout",
target: "user",
rolloutStrategy: "percentage",
rolloutConfig: JSON.stringify({
percentage: 25,
}),
},
update: {},
});
console.log('✅ Flag "improved_combat_algorithm" creado (rollout 25%)');
// 4. Flag con rollout gradual (de 10% a 100% en 7 días)
const startDate = new Date();
await prisma.featureFlag.upsert({
where: { name: "economy_system_v2" },
create: {
name: "economy_system_v2",
description: "Sistema de economía rediseñado",
status: "rollout",
target: "user",
rolloutStrategy: "gradual",
rolloutConfig: JSON.stringify({
gradual: {
startPercentage: 10,
targetPercentage: 100,
durationDays: 7,
},
}),
startDate,
},
update: {},
});
console.log('✅ Flag "economy_system_v2" creado (gradual rollout)');
// 5. Flag de evento temporal (Halloween)
const halloweenStart = new Date("2025-10-25");
const halloweenEnd = new Date("2025-11-01");
await prisma.featureFlag.upsert({
where: { name: "halloween_2025" },
create: {
name: "halloween_2025",
description: "Evento de Halloween 2025",
status: "enabled",
target: "global",
startDate: halloweenStart,
endDate: halloweenEnd,
},
update: {},
});
console.log('✅ Flag "halloween_2025" creado (evento temporal)');
// 6. Flag experimental con whitelist (solo para beta testers)
await prisma.featureFlag.upsert({
where: { name: "experimental_features" },
create: {
name: "experimental_features",
description: "Funcionalidades experimentales solo para beta testers",
status: "rollout",
target: "user",
rolloutStrategy: "whitelist",
rolloutConfig: JSON.stringify({
targetIds: [
// Añade aquí los IDs de tus beta testers
"BETA_TESTER_USER_ID_1",
"BETA_TESTER_USER_ID_2",
],
}),
},
update: {},
});
console.log('✅ Flag "experimental_features" creado (whitelist)');
// 7. Flag premium con múltiples requisitos
await prisma.featureFlag.upsert({
where: { name: "premium_features" },
create: {
name: "premium_features",
description: "Funcionalidades premium del bot",
status: "disabled",
target: "guild",
metadata: JSON.stringify({
requiresSubscription: true,
tier: "premium",
}),
},
update: {},
});
console.log('✅ Flag "premium_features" creado (disabled, metadata)');
// 8. Flag en mantenimiento
await prisma.featureFlag.upsert({
where: { name: "trading_system" },
create: {
name: "trading_system",
description: "Sistema de intercambio entre usuarios",
status: "maintenance",
target: "global",
},
update: {},
});
console.log('✅ Flag "trading_system" creado (maintenance)');
console.log("\n🎉 Feature Flags de ejemplo creados exitosamente!");
console.log("\n📝 Próximos pasos:");
console.log("1. Usa /featureflags list para ver todos los flags");
console.log("2. Usa /featureflags info flag:nombre para ver detalles");
console.log(
"3. Usa /featureflags update flag:nombre status:enabled para habilitar"
);
console.log("4. Lee README/FEATURE_FLAGS_SYSTEM.md para más información");
} catch (error) {
console.error("❌ Error al crear flags:", error);
throw error;
} finally {
await prisma.$disconnect();
}
}
// Ejecutar
setupFeatureFlags()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,488 @@
/**
* Comando de administración de Feature Flags
*/
import {
ChatInputCommandInteraction,
EmbedBuilder,
MessageFlags,
} from "discord.js";
import { featureFlagService } from "../../../core/services/FeatureFlagService";
import {
FeatureFlagConfig,
FeatureFlagStatus,
FeatureFlagTarget,
RolloutStrategy,
} from "../../../core/types/featureFlags";
import logger from "../../../core/lib/logger";
import { CommandSlash } from "../../../core/types/commands";
export const command: CommandSlash = {
name: "featureflags",
description: "Administra los feature flags del bot",
type: "slash",
cooldown: 5,
options: [
{
name: "list",
description: "Lista todos los feature flags",
type: 1,
},
{
name: "info",
description: "Muestra información detallada de un flag",
type: 1,
options: [
{
name: "flag",
description: "Nombre del flag",
type: 3,
required: true,
autocomplete: true,
},
],
},
{
name: "create",
description: "Crea un nuevo feature flag",
type: 1,
options: [
{
name: "name",
description: "Nombre único del flag",
type: 3,
required: true,
},
{
name: "status",
description: "Estado del flag",
type: 3,
required: true,
choices: [
{ name: "Habilitado", value: "enabled" },
{ name: "Deshabilitado", value: "disabled" },
{ name: "Rollout", value: "rollout" },
{ name: "Mantenimiento", value: "maintenance" },
],
},
{
name: "target",
description: "Nivel de aplicación",
type: 3,
required: true,
choices: [
{ name: "Global", value: "global" },
{ name: "Guild", value: "guild" },
{ name: "Usuario", value: "user" },
{ name: "Canal", value: "channel" },
],
},
{
name: "description",
description: "Descripción del flag",
type: 3,
required: false,
},
],
},
{
name: "update",
description: "Actualiza un feature flag existente",
type: 1,
options: [
{
name: "flag",
description: "Nombre del flag",
type: 3,
required: true,
autocomplete: true,
},
{
name: "status",
description: "Nuevo estado",
type: 3,
required: false,
choices: [
{ name: "Habilitado", value: "enabled" },
{ name: "Deshabilitado", value: "disabled" },
{ name: "Rollout", value: "rollout" },
{ name: "Mantenimiento", value: "maintenance" },
],
},
],
},
{
name: "rollout",
description: "Configura rollout progresivo",
type: 1,
options: [
{
name: "flag",
description: "Nombre del flag",
type: 3,
required: true,
autocomplete: true,
},
{
name: "strategy",
description: "Estrategia de rollout",
type: 3,
required: true,
choices: [
{ name: "Porcentaje", value: "percentage" },
{ name: "Whitelist", value: "whitelist" },
{ name: "Blacklist", value: "blacklist" },
{ name: "Gradual", value: "gradual" },
],
},
{
name: "percentage",
description: "Porcentaje de usuarios (0-100) para rollout",
type: 4,
required: false,
min_value: 0,
max_value: 100,
},
],
},
{
name: "delete",
description: "Elimina un feature flag",
type: 1,
options: [
{
name: "flag",
description: "Nombre del flag",
type: 3,
required: true,
autocomplete: true,
},
],
},
{
name: "stats",
description: "Muestra estadísticas de uso de los flags",
type: 1,
options: [
{
name: "flag",
description: "Nombre del flag (opcional)",
type: 3,
required: false,
autocomplete: true,
},
],
},
{
name: "refresh",
description: "Refresca el caché de feature flags",
type: 1,
},
],
run: async (interaction) => {
const subcommand = interaction.options.getSubcommand();
try {
switch (subcommand) {
case "list":
await handleList(interaction);
break;
case "info":
await handleInfo(interaction);
break;
case "create":
await handleCreate(interaction);
break;
case "update":
await handleUpdate(interaction);
break;
case "rollout":
await handleRollout(interaction);
break;
case "delete":
await handleDelete(interaction);
break;
case "stats":
await handleStats(interaction);
break;
case "refresh":
await handleRefresh(interaction);
break;
default:
await interaction.reply({
content: "❌ Subcomando no reconocido",
flags: MessageFlags.Ephemeral,
});
}
} catch (error: any) {
logger.error("[FeatureFlagsCmd]", error?.message || error);
const errorMessage = "❌ Error al ejecutar el comando";
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: errorMessage,
flags: MessageFlags.Ephemeral,
});
} else {
await interaction.reply({
content: errorMessage,
flags: MessageFlags.Ephemeral,
});
}
}
},
};
async function handleList(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const flags = featureFlagService.getFlags();
if (flags.length === 0) {
await interaction.editReply("📋 No hay feature flags configurados.");
return;
}
const embed = new EmbedBuilder()
.setTitle("🎮 Feature Flags")
.setColor(0x5865f2)
.setDescription(`Total: ${flags.length} flags`);
for (const flag of flags.slice(0, 25)) {
embed.addFields({
name: `${getStatusEmoji(flag.status)} ${flag.name}`,
value: `**Status:** ${flag.status}\n**Target:** ${flag.target}${
flag.description ? `\n${flag.description}` : ""
} `,
inline: true,
});
}
await interaction.editReply({ embeds: [embed] });
}
async function handleInfo(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const flagName = interaction.options.getString("flag", true);
const flag = featureFlagService.getFlag(flagName);
if (!flag) {
await interaction.editReply(`❌ Flag "${flagName}" no encontrado`);
return;
}
const stats = featureFlagService.getStats(flagName);
const embed = new EmbedBuilder()
.setTitle(`🎮 ${flag.name}`)
.setColor(getStatusColor(flag.status))
.addFields(
{
name: "Estado",
value: `${getStatusEmoji(flag.status)} ${flag.status}`,
inline: true,
},
{ name: "Target", value: flag.target, inline: true },
{ name: "Estrategia", value: flag.rolloutStrategy || "N/A", inline: true }
);
if (flag.description) embed.setDescription(flag.description);
if (flag.rolloutConfig) {
let cfg = "";
if (flag.rolloutConfig.percentage !== undefined)
cfg += `Porcentaje: ${flag.rolloutConfig.percentage}%\n`;
if (flag.rolloutConfig.targetIds?.length)
cfg += `IDs: ${flag.rolloutConfig.targetIds.length} configurados\n`;
if (cfg) embed.addFields({ name: "Configuración", value: cfg });
}
if (flag.startDate || flag.endDate) {
let dates = "";
if (flag.startDate)
dates += `Inicio: <t:${Math.floor(flag.startDate.getTime() / 1000)}:R>\n`;
if (flag.endDate)
dates += `Fin: <t:${Math.floor(flag.endDate.getTime() / 1000)}:R>\n`;
embed.addFields({ name: "Fechas", value: dates });
}
if (stats) {
embed.addFields({
name: "📊 Estadísticas",
value: `Evaluaciones: ${stats.totalEvaluations}\nHabilitado: ${
stats.enabledCount
} (${stats.enablementRate.toFixed(1)}%)\nDeshabilitado: ${
stats.disabledCount
}`,
});
}
await interaction.editReply({ embeds: [embed] });
}
async function handleCreate(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const config: FeatureFlagConfig = {
name: interaction.options.getString("name", true),
status: interaction.options.getString("status", true) as FeatureFlagStatus,
target: interaction.options.getString("target", true) as FeatureFlagTarget,
description: interaction.options.getString("description") || undefined,
};
await featureFlagService.setFlag(config);
await interaction.editReply(
`✅ Feature flag "${config.name}" creado exitosamente`
);
}
async function handleUpdate(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const flagName = interaction.options.getString("flag", true);
const flag = featureFlagService.getFlag(flagName);
if (!flag) {
await interaction.editReply(`❌ Flag "${flagName}" no encontrado`);
return;
}
const newStatus = interaction.options.getString(
"status"
) as FeatureFlagStatus | null;
if (newStatus) flag.status = newStatus;
await featureFlagService.setFlag(flag);
await interaction.editReply(`✅ Feature flag "${flagName}" actualizado`);
}
async function handleRollout(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const flagName = interaction.options.getString("flag", true);
const flag = featureFlagService.getFlag(flagName);
if (!flag) {
await interaction.editReply(`❌ Flag "${flagName}" no encontrado`);
return;
}
flag.status = "rollout";
flag.rolloutStrategy = interaction.options.getString(
"strategy",
true
) as RolloutStrategy;
flag.rolloutConfig = flag.rolloutConfig || {};
const percentage = interaction.options.getInteger("percentage");
if (percentage !== null) flag.rolloutConfig.percentage = percentage;
await featureFlagService.setFlag(flag);
await interaction.editReply(
`✅ Rollout configurado para "${flagName}"\nEstrategia: ${
flag.rolloutStrategy
} ${percentage !== null ? `\nPorcentaje: ${percentage}%` : ""}`
);
}
async function handleDelete(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const flagName = interaction.options.getString("flag", true);
await featureFlagService.removeFlag(flagName);
await interaction.editReply(`✅ Feature flag "\${flagName}" eliminado`);
}
async function handleStats(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const flagName = interaction.options.getString("flag");
if (flagName) {
const stats = featureFlagService.getStats(flagName);
if (!stats) {
await interaction.editReply(`❌ No hay estadísticas para "${flagName}"`);
return;
}
const embed = new EmbedBuilder()
.setTitle(`📊 Estadísticas: ${flagName}`)
.setColor(0x5865f2)
.addFields(
{
name: "Total Evaluaciones",
value: stats.totalEvaluations.toString(),
inline: true,
},
{
name: "Habilitado",
value: `${stats.enabledCount} (${stats.enablementRate.toFixed(1)}%)`,
inline: true,
},
{
name: "Deshabilitado",
value: stats.disabledCount.toString(),
inline: true,
}
);
if (stats.lastEvaluation) {
embed.addFields({
name: "Última Evaluación",
value: `<t:${Math.floor(stats.lastEvaluation.getTime() / 1000)}:R>`,
});
}
await interaction.editReply({ embeds: [embed] });
} else {
const allStats = featureFlagService.getAllStats();
if (allStats.length === 0) {
await interaction.editReply("📊 No hay estadísticas disponibles");
return;
}
const embed = new EmbedBuilder()
.setTitle("📊 Estadísticas de Feature Flags")
.setColor(0x5865f2);
for (const stats of allStats.slice(0, 10)) {
embed.addFields({
name: stats.flagName,
value: `Evaluaciones: ${
stats.totalEvaluations
}\nTasa: ${stats.enablementRate.toFixed(1)}%`,
inline: true,
});
}
await interaction.editReply({ embeds: [embed] });
}
}
async function handleRefresh(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await featureFlagService.refreshCache();
featureFlagService.clearEvaluationCache();
await interaction.editReply("✅ Caché de feature flags refrescado");
}
function getStatusEmoji(status: FeatureFlagStatus): string {
switch (status) {
case "enabled":
return "✅";
case "disabled":
return "❌";
case "rollout":
return "🔄";
case "maintenance":
return "🔧";
default:
return "❓";
}
}
function getStatusColor(status: FeatureFlagStatus): number {
switch (status) {
case "enabled":
return 0x57f287;
case "disabled":
return 0xed4245;
case "rollout":
return 0xfee75c;
case "maintenance":
return 0xeb459e;
default:
return 0x5865f2;
}
}

View File

@@ -0,0 +1,265 @@
/**
* Feature Flag Helpers & Decorators
* Utilities para usar feature flags fácilmente en comandos
*/
import {
CommandInteraction,
Message,
GuildMember,
User as DiscordUser,
} from "discord.js";
import { featureFlagService } from "../services/FeatureFlagService";
import { FeatureFlagContext } from "../types/featureFlags";
import logger from "./logger";
import { MessageFlags } from "discord.js";
/**
* Extrae contexto de un comando o mensaje de Discord
*/
export function extractContext(
source: CommandInteraction | Message
): FeatureFlagContext {
const context: FeatureFlagContext = {
timestamp: Date.now(),
};
if (source instanceof CommandInteraction) {
context.userId = source.user.id;
context.guildId = source.guildId || undefined;
context.channelId = source.channelId;
} else {
context.userId = source.author.id;
context.guildId = source.guildId || undefined;
context.channelId = source.channelId;
}
return context;
}
/**
* Check si un feature flag está habilitado para un contexto
*/
export async function isFeatureEnabled(
flagName: string,
context: FeatureFlagContext
): Promise<boolean> {
try {
return await featureFlagService.isEnabled(flagName, context);
} catch (error) {
//@ts-ignore
logger.error(`[FeatureFlags] Error al verificar "${flagName}":`, error);
return false;
}
}
/**
* Check si un feature flag está habilitado para un comando/mensaje
*/
export async function isFeatureEnabledForInteraction(
flagName: string,
source: CommandInteraction | Message
): Promise<boolean> {
const context = extractContext(source);
return isFeatureEnabled(flagName, context);
}
/**
* Decorador para proteger métodos con feature flags
*
* @example
* ```ts
* class MyCommand {
* @RequireFeature('new_shop_system')
* async execute(interaction: CommandInteraction) {
* // Este método solo se ejecuta si el flag está habilitado
* }
* }
* ```
*/
export function RequireFeature(
flagName: string,
options: {
fallbackMessage?: string;
silent?: boolean;
} = {}
): MethodDecorator {
return function (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
// Intentar extraer el contexto del primer argumento
const firstArg = args[0];
let context: FeatureFlagContext = {};
if (
firstArg instanceof CommandInteraction ||
firstArg instanceof Message
) {
context = extractContext(firstArg);
}
const enabled = await isFeatureEnabled(flagName, context);
if (!enabled) {
if (!options.silent) {
const message =
options.fallbackMessage ||
"Esta funcionalidad no está disponible en este momento.";
if (firstArg instanceof CommandInteraction) {
if (firstArg.replied || firstArg.deferred) {
await firstArg.followUp({
content: message,
flags: MessageFlags.Ephemeral,
});
} else {
await firstArg.reply({
content: message,
flags: MessageFlags.Ephemeral,
});
}
} else if (firstArg instanceof Message) {
await firstArg.reply(message);
}
}
logger.debug(
`[FeatureFlags] Método ${String(
propertyKey
)} bloqueado por flag "${flagName}"`
);
return;
}
return originalMethod.apply(this, args);
};
return descriptor;
};
}
/**
* Guard para usar en handlers de comandos
*
* @example
* ```ts
* if (await featureGuard('new_shop', interaction)) {
* // Código solo si el flag está habilitado
* }
* ```
*/
export async function featureGuard(
flagName: string,
source: CommandInteraction | Message,
options: {
replyIfDisabled?: boolean;
customMessage?: string;
} = { replyIfDisabled: true }
): Promise<boolean> {
const enabled = await isFeatureEnabledForInteraction(flagName, source);
if (!enabled && options.replyIfDisabled) {
const message =
options.customMessage ||
"⚠️ Esta funcionalidad está deshabilitada temporalmente.";
if (source instanceof CommandInteraction) {
if (source.replied || source.deferred) {
await source.followUp({
content: message,
flags: MessageFlags.Ephemeral,
});
} else {
await source.reply({ content: message, flags: MessageFlags.Ephemeral });
}
} else {
await source.reply(message);
}
}
return enabled;
}
/**
* Wrapper para funciones que requieren feature flags
*
* @example
* ```ts
* await withFeature('new_mining', context, async () => {
* // Este código solo se ejecuta si el flag está habilitado
* await doMining();
* });
* ```
*/
export async function withFeature<T>(
flagName: string,
context: FeatureFlagContext,
fn: () => Promise<T>,
fallback?: () => Promise<T>
): Promise<T | undefined> {
const enabled = await isFeatureEnabled(flagName, context);
if (enabled) {
return fn();
}
if (fallback) {
return fallback();
}
return undefined;
}
/**
* Helper para A/B testing - ejecuta una función u otra según el flag
*
* @example
* ```ts
* await abTest('new_algorithm', context, {
* variant: async () => { /* nueva versión *\/ },
* control: async () => { /* versión antigua *\/ }
* });
* ```
*/
export async function abTest<T>(
flagName: string,
context: FeatureFlagContext,
variants: {
variant: () => Promise<T>;
control: () => Promise<T>;
}
): Promise<T> {
const enabled = await isFeatureEnabled(flagName, context);
return enabled ? variants.variant() : variants.control();
}
/**
* Helper para ejecutar código solo si múltiples flags están habilitados
*/
export async function requireAllFeatures(
flagNames: string[],
context: FeatureFlagContext
): Promise<boolean> {
const results = await Promise.all(
flagNames.map((flag) => isFeatureEnabled(flag, context))
);
return results.every((enabled) => enabled);
}
/**
* Helper para ejecutar código si al menos un flag está habilitado
*/
export async function requireAnyFeature(
flagNames: string[],
context: FeatureFlagContext
): Promise<boolean> {
const results = await Promise.all(
flagNames.map((flag) => isFeatureEnabled(flag, context))
);
return results.some((enabled) => enabled);
}

View File

@@ -0,0 +1,22 @@
/**
* Feature Flags Loader
* Inicializa el servicio de feature flags al arrancar el bot
*/
import { featureFlagService } from "../services/FeatureFlagService";
import logger from "../lib/logger";
export async function loadFeatureFlags(): Promise<void> {
try {
logger.info("[FeatureFlags] Inicializando servicio...");
await featureFlagService.initialize();
logger.info("[FeatureFlags] ✅ Servicio inicializado correctamente");
} catch (error) {
//@ts-ignore
logger.error("[FeatureFlags] ❌ Error al inicializar:", error);
// No lanzamos el error para no bloquear el arranque del bot
// El servicio funcionará en modo fail-safe (todos los flags disabled)
}
}
export default loadFeatureFlags;

View File

@@ -0,0 +1,629 @@
/**
* Feature Flag Service
* Sistema de control de features para rollouts progresivos, A/B testing y feature toggles
*
* Características:
* - Rollouts progresivos por porcentaje
* - Whitelisting/blacklisting de usuarios/guilds
* - A/B testing
* - Caché en memoria para performance
* - Persistencia en Prisma + AppWrite
* - Sistema de evaluación con contexto
*/
import { Collection } from "discord.js";
import { prisma } from "../database/prisma";
import logger from "../lib/logger";
import {
FeatureFlagConfig,
FeatureFlagContext,
FeatureFlagEvaluation,
FeatureFlagStats,
FeatureFlagStatus,
RolloutStrategy,
} from "../types/featureFlags";
class FeatureFlagService {
// Caché en memoria para flags (evitar golpear DB constantemente)
private flagsCache: Collection<string, FeatureFlagConfig>;
// Caché de evaluaciones por contexto (para consistencia en la sesión)
private evaluationCache: Collection<string, Map<string, boolean>>;
// Stats en memoria
private stats: Collection<string, FeatureFlagStats>;
// TTL del caché (5 minutos)
private cacheTTL: number = 5 * 60 * 1000;
// Última actualización del caché
private lastCacheUpdate: number = 0;
// Flag para saber si está inicializado
private initialized: boolean = false;
constructor() {
this.flagsCache = new Collection();
this.evaluationCache = new Collection();
this.stats = new Collection();
}
/**
* Inicializa el servicio y carga los flags desde la DB
*/
async initialize(): Promise<void> {
if (this.initialized) {
logger.warn("[FeatureFlags] Ya inicializado, omitiendo...");
return;
}
try {
logger.info("[FeatureFlags] Inicializando servicio...");
await this.refreshCache();
this.initialized = true;
logger.info(
`[FeatureFlags] Inicializado con ${this.flagsCache.size} flags`
);
} catch (error) {
//@ts-ignore
logger.error("[FeatureFlags] Error al inicializar:", error);
throw error;
}
}
/**
* Refresca el caché de flags desde la base de datos
*/
async refreshCache(): Promise<void> {
try {
const flags = await prisma.featureFlag.findMany();
this.flagsCache.clear();
for (const flag of flags) {
const config: FeatureFlagConfig = {
name: flag.name,
description: flag.description || undefined,
status: flag.status as FeatureFlagStatus,
target: flag.target as any,
rolloutStrategy: flag.rolloutStrategy as RolloutStrategy | undefined,
rolloutConfig: flag.rolloutConfig
? JSON.parse(flag.rolloutConfig as string)
: undefined,
startDate: flag.startDate || undefined,
endDate: flag.endDate || undefined,
metadata: flag.metadata
? JSON.parse(flag.metadata as string)
: undefined,
createdAt: flag.createdAt,
updatedAt: flag.updatedAt,
};
this.flagsCache.set(flag.name, config);
}
this.lastCacheUpdate = Date.now();
logger.debug(
`[FeatureFlags] Caché actualizado: ${this.flagsCache.size} flags`
);
} catch (error) {
//@ts-ignore
logger.error("[FeatureFlags] Error al refrescar caché:", error);
throw error;
}
}
/**
* Verifica si el caché necesita actualizarse
*/
private async checkCacheValidity(): Promise<void> {
const now = Date.now();
if (now - this.lastCacheUpdate > this.cacheTTL) {
await this.refreshCache();
}
}
/**
* Evalúa si un feature flag está habilitado para un contexto dado
*/
async isEnabled(
flagName: string,
context: FeatureFlagContext = {}
): Promise<boolean> {
try {
await this.checkCacheValidity();
const flag = this.flagsCache.get(flagName);
// Si el flag no existe, asumimos deshabilitado
if (!flag) {
logger.warn(
`[FeatureFlags] Flag "${flagName}" no encontrado, retornando false`
);
return false;
}
// Verificar si hay una evaluación cacheada para este contexto
const cacheKey = this.generateContextKey(flagName, context);
const contextCache = this.evaluationCache.get(cacheKey);
if (contextCache !== undefined) {
const cachedResult = contextCache.get(flagName);
if (cachedResult !== undefined) {
return cachedResult;
}
}
// Evaluar el flag
const evaluation = await this.evaluate(flag, context);
// Cachear resultado
this.cacheEvaluation(cacheKey, flagName, evaluation.enabled);
// Actualizar stats
this.updateStats(flagName, evaluation.enabled);
logger.debug(
`[FeatureFlags] "${flagName}" evaluado: ${evaluation.enabled} (${evaluation.reason})`
);
return evaluation.enabled;
} catch (error) {
//@ts-ignore
logger.error(`[FeatureFlags] Error al evaluar "${flagName}":`, error);
return false; // Fail-safe: si hay error, deshabilitamos
}
}
/**
* Evalúa un flag según su configuración
*/
private async evaluate(
flag: FeatureFlagConfig,
context: FeatureFlagContext
): Promise<FeatureFlagEvaluation> {
const now = Date.now();
// Verificar fechas de inicio/fin
if (flag.startDate && now < flag.startDate.getTime()) {
return {
flagName: flag.name,
enabled: false,
reason: "Flag aún no iniciado",
timestamp: now,
};
}
if (flag.endDate && now > flag.endDate.getTime()) {
return {
flagName: flag.name,
enabled: false,
reason: "Flag expirado",
timestamp: now,
};
}
// Evaluar según status
switch (flag.status) {
case "enabled":
return {
flagName: flag.name,
enabled: true,
reason: "Flag habilitado globalmente",
timestamp: now,
};
case "disabled":
return {
flagName: flag.name,
enabled: false,
reason: "Flag deshabilitado",
timestamp: now,
};
case "maintenance":
return {
flagName: flag.name,
enabled: false,
reason: "Flag en mantenimiento",
timestamp: now,
};
case "rollout":
return this.evaluateRollout(flag, context, now);
default:
return {
flagName: flag.name,
enabled: false,
reason: "Status desconocido",
timestamp: now,
};
}
}
/**
* Evalúa una estrategia de rollout
*/
private evaluateRollout(
flag: FeatureFlagConfig,
context: FeatureFlagContext,
timestamp: number
): FeatureFlagEvaluation {
const strategy = flag.rolloutStrategy;
const config = flag.rolloutConfig;
if (!strategy || !config) {
return {
flagName: flag.name,
enabled: false,
reason: "Rollout sin configuración",
strategy,
timestamp,
};
}
switch (strategy) {
case "whitelist":
return this.evaluateWhitelist(flag, context, timestamp);
case "blacklist":
return this.evaluateBlacklist(flag, context, timestamp);
case "percentage":
return this.evaluatePercentage(flag, context, timestamp);
case "gradual":
return this.evaluateGradual(flag, context, timestamp);
case "random":
return this.evaluateRandom(flag, context, timestamp);
default:
return {
flagName: flag.name,
enabled: false,
reason: "Estrategia desconocida",
strategy,
timestamp,
};
}
}
/**
* Evalúa estrategia de whitelist
*/
private evaluateWhitelist(
flag: FeatureFlagConfig,
context: FeatureFlagContext,
timestamp: number
): FeatureFlagEvaluation {
const targetIds = flag.rolloutConfig?.targetIds || [];
const contextId = this.getContextId(flag, context);
const enabled = targetIds.includes(contextId);
return {
flagName: flag.name,
enabled,
reason: enabled ? "ID en whitelist" : "ID no en whitelist",
strategy: "whitelist",
timestamp,
};
}
/**
* Evalúa estrategia de blacklist
*/
private evaluateBlacklist(
flag: FeatureFlagConfig,
context: FeatureFlagContext,
timestamp: number
): FeatureFlagEvaluation {
const targetIds = flag.rolloutConfig?.targetIds || [];
const contextId = this.getContextId(flag, context);
const enabled = !targetIds.includes(contextId);
return {
flagName: flag.name,
enabled,
reason: enabled ? "ID no en blacklist" : "ID en blacklist",
strategy: "blacklist",
timestamp,
};
}
/**
* Evalúa estrategia de porcentaje
*/
private evaluatePercentage(
flag: FeatureFlagConfig,
context: FeatureFlagContext,
timestamp: number
): FeatureFlagEvaluation {
const percentage = flag.rolloutConfig?.percentage || 0;
const contextId = this.getContextId(flag, context);
// Hash determinista basado en el ID
const hash = this.hashString(contextId + flag.name);
const userPercentage = (hash % 100) + 1;
const enabled = userPercentage <= percentage;
return {
flagName: flag.name,
enabled,
reason: `Porcentaje: ${userPercentage}% <= ${percentage}%`,
strategy: "percentage",
timestamp,
};
}
/**
* Evalúa estrategia de rollout gradual
*/
private evaluateGradual(
flag: FeatureFlagConfig,
context: FeatureFlagContext,
timestamp: number
): FeatureFlagEvaluation {
const gradual = flag.rolloutConfig?.gradual;
if (!gradual || !flag.startDate) {
return {
flagName: flag.name,
enabled: false,
reason: "Gradual sin configuración válida",
strategy: "gradual",
timestamp,
};
}
const startTime = flag.startDate.getTime();
const durationMs = gradual.durationDays * 24 * 60 * 60 * 1000;
const elapsed = timestamp - startTime;
if (elapsed < 0) {
return {
flagName: flag.name,
enabled: false,
reason: "Rollout gradual no iniciado",
strategy: "gradual",
timestamp,
};
}
// Calcular porcentaje actual del rollout
const progress = Math.min(1, elapsed / durationMs);
const currentPercentage =
gradual.startPercentage +
(gradual.targetPercentage - gradual.startPercentage) * progress;
// Evaluar con el porcentaje actual
const contextId = this.getContextId(flag, context);
const hash = this.hashString(contextId + flag.name);
const userPercentage = (hash % 100) + 1;
const enabled = userPercentage <= currentPercentage;
return {
flagName: flag.name,
enabled,
reason: `Gradual: ${currentPercentage.toFixed(1)}% (día ${Math.floor(
elapsed / (24 * 60 * 60 * 1000)
)}/${gradual.durationDays})`,
strategy: "gradual",
timestamp,
};
}
/**
* Evalúa estrategia aleatoria
*/
private evaluateRandom(
flag: FeatureFlagConfig,
context: FeatureFlagContext,
timestamp: number
): FeatureFlagEvaluation {
const seed = flag.rolloutConfig?.randomSeed || 0;
const contextId = this.getContextId(flag, context);
// Pseudo-random determinista basado en seed y context
const hash = this.hashString(contextId + flag.name + seed);
const enabled = hash % 2 === 0;
return {
flagName: flag.name,
enabled,
reason: enabled ? "Random: true" : "Random: false",
strategy: "random",
timestamp,
};
}
/**
* Obtiene el ID relevante del contexto según el target del flag
*/
private getContextId(
flag: FeatureFlagConfig,
context: FeatureFlagContext
): string {
switch (flag.target) {
case "user":
return context.userId || "unknown";
case "guild":
return context.guildId || "unknown";
case "channel":
return context.channelId || "unknown";
case "global":
default:
return "global";
}
}
/**
* Genera un hash simple de un string (para distribución consistente)
*/
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
/**
* Genera una clave única para el contexto
*/
private generateContextKey(
flagName: string,
context: FeatureFlagContext
): string {
return `${flagName}:${context.userId || "none"}:${
context.guildId || "none"
}:${context.channelId || "none"}`;
}
/**
* Cachea una evaluación
*/
private cacheEvaluation(
contextKey: string,
flagName: string,
enabled: boolean
): void {
if (!this.evaluationCache.has(contextKey)) {
this.evaluationCache.set(contextKey, new Map());
}
this.evaluationCache.get(contextKey)!.set(flagName, enabled);
}
/**
* Actualiza las estadísticas de un flag
*/
private updateStats(flagName: string, enabled: boolean): void {
if (!this.stats.has(flagName)) {
this.stats.set(flagName, {
flagName,
totalEvaluations: 0,
enabledCount: 0,
disabledCount: 0,
enablementRate: 0,
lastEvaluation: new Date(),
});
}
const stats = this.stats.get(flagName)!;
stats.totalEvaluations++;
if (enabled) {
stats.enabledCount++;
} else {
stats.disabledCount++;
}
stats.enablementRate = (stats.enabledCount / stats.totalEvaluations) * 100;
stats.lastEvaluation = new Date();
}
/**
* Crea o actualiza un feature flag
*/
async setFlag(config: FeatureFlagConfig): Promise<void> {
try {
const data = {
name: config.name,
description: config.description || null,
status: config.status,
target: config.target,
rolloutStrategy: config.rolloutStrategy || null,
rolloutConfig: config.rolloutConfig
? JSON.stringify(config.rolloutConfig)
: null,
startDate: config.startDate || null,
endDate: config.endDate || null,
metadata: config.metadata ? JSON.stringify(config.metadata) : null,
};
await prisma.featureFlag.upsert({
where: { name: config.name },
create: data,
update: data,
});
// Actualizar caché
this.flagsCache.set(config.name, config);
logger.info(`[FeatureFlags] Flag "${config.name}" actualizado`);
} catch (error) {
logger.error(
`[FeatureFlags] Error al setear flag "${config.name}":`,
//@ts-ignore
error
);
throw error;
}
}
/**
* Elimina un feature flag
*/
async removeFlag(flagName: string): Promise<void> {
try {
await prisma.featureFlag.delete({
where: { name: flagName },
});
this.flagsCache.delete(flagName);
this.stats.delete(flagName);
logger.info(`[FeatureFlags] Flag "${flagName}" eliminado`);
} catch (error) {
logger.error(
`[FeatureFlags] Error al eliminar flag "${flagName}":`,
//@ts-ignore
error
);
throw error;
}
}
/**
* Obtiene todos los flags
*/
getFlags(): FeatureFlagConfig[] {
return Array.from(this.flagsCache.values());
}
/**
* Obtiene un flag específico
*/
getFlag(flagName: string): FeatureFlagConfig | undefined {
return this.flagsCache.get(flagName);
}
/**
* Obtiene las estadísticas de un flag
*/
getStats(flagName: string): FeatureFlagStats | undefined {
return this.stats.get(flagName);
}
/**
* Obtiene todas las estadísticas
*/
getAllStats(): FeatureFlagStats[] {
return Array.from(this.stats.values());
}
/**
* Limpia el caché de evaluaciones
*/
clearEvaluationCache(): void {
this.evaluationCache.clear();
logger.info("[FeatureFlags] Caché de evaluaciones limpiado");
}
}
// Singleton
export const featureFlagService = new FeatureFlagService();

View File

@@ -0,0 +1,142 @@
/**
* Feature Flags Types
* Sistema de control de features para rollouts progresivos, A/B testing y toggles
*/
export type FeatureFlagStatus =
| "enabled"
| "disabled"
| "rollout"
| "maintenance";
export type FeatureFlagTarget = "global" | "guild" | "user" | "channel";
export type RolloutStrategy =
| "percentage" // Basado en % de usuarios
| "whitelist" // Lista específica de IDs
| "blacklist" // Todos excepto lista
| "gradual" // Rollout gradual basado en tiempo
| "random"; // Aleatorio por sesión
export interface FeatureFlagConfig {
/** Nombre único del flag */
name: string;
/** Descripción del flag */
description?: string;
/** Estado del flag */
status: FeatureFlagStatus;
/** Nivel de aplicación del flag */
target: FeatureFlagTarget;
/** Estrategia de rollout (si status es 'rollout') */
rolloutStrategy?: RolloutStrategy;
/** Configuración específica de la estrategia */
rolloutConfig?: RolloutConfig;
/** Fecha de inicio del flag */
startDate?: Date;
/** Fecha de fin del flag (auto-deshabilitar) */
endDate?: Date;
/** Metadata adicional */
metadata?: Record<string, any>;
/** Timestamp de creación */
createdAt?: Date;
/** Timestamp de última actualización */
updatedAt?: Date;
}
export interface RolloutConfig {
/** Porcentaje de usuarios (0-100) para estrategia 'percentage' */
percentage?: number;
/** Lista de IDs (guild/user/channel) para whitelist/blacklist */
targetIds?: string[];
/** Configuración de rollout gradual */
gradual?: {
/** Porcentaje inicial */
startPercentage: number;
/** Porcentaje objetivo */
targetPercentage: number;
/** Duración del rollout en días */
durationDays: number;
};
/** Seed para aleatorización consistente */
randomSeed?: number;
}
export interface FeatureFlagContext {
/** ID del usuario */
userId?: string;
/** ID del guild */
guildId?: string;
/** ID del canal */
channelId?: string;
/** Timestamp de la evaluación */
timestamp?: number;
/** Metadata adicional del contexto */
metadata?: Record<string, any>;
}
export interface FeatureFlagEvaluation {
/** Nombre del flag evaluado */
flagName: string;
/** Resultado de la evaluación */
enabled: boolean;
/** Razón de la decisión */
reason: string;
/** Estrategia aplicada */
strategy?: RolloutStrategy;
/** Timestamp de evaluación */
timestamp: number;
}
export interface FeatureFlagStats {
/** Nombre del flag */
flagName: string;
/** Total de evaluaciones */
totalEvaluations: number;
/** Evaluaciones positivas (enabled) */
enabledCount: number;
/** Evaluaciones negativas (disabled) */
disabledCount: number;
/** Tasa de activación (%) */
enablementRate: number;
/** Última evaluación */
lastEvaluation?: Date;
}
/** Decorador para proteger comandos con feature flags */
export interface FeatureFlagDecorator {
(
flagName: string,
options?: {
fallbackMessage?: string;
silent?: boolean;
checkUser?: boolean;
checkGuild?: boolean;
}
): MethodDecorator;
}

View File

@@ -14,6 +14,7 @@ import { cleanExpiredGuildCache } from "./core/database/guildCache";
import logger from "./core/lib/logger";
import { applyModalSubmitInteractionPatch } from "./core/patches/discordModalPatch";
import { server } from "./server/server";
import loadFeatureFlags from "./core/loaders/featureFlagsLoader";
// Activar monitor de memoria si se define la variable
const __memInt = parseInt(process.env.MEMORY_LOG_INTERVAL_SECONDS || "0", 10);
@@ -211,7 +212,13 @@ async function bootstrap() {
} catch (e) {
logger.error({ err: e }, "Error cargando eventos");
}
try {
await loadFeatureFlags();
} catch (e) {
logger.error({ err: e }, "Error cargando feature flags");
}
/*
// Inicializar repositorio de mobs (intenta cargar mobs desde DB si existe)
try {
// import dinamico para evitar ciclos en startup
@@ -220,6 +227,7 @@ async function bootstrap() {
} catch (e) {
logger.warn({ err: e }, "No se pudo inicializar el repositorio de mobs");
}
*/
// Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot
withRetry("Registrar slash commands", async () => {

View File

@@ -0,0 +1,296 @@
/**
* Ejemplo de Uso de Feature Flags en Comandos
*
* Este archivo muestra varios patrones de uso del sistema de feature flags
*/
import { CommandInteraction, Message } from "discord.js";
import {
RequireFeature,
featureGuard,
isFeatureEnabledForInteraction,
abTest,
extractContext,
requireAllFeatures,
} from "../core/lib/featureFlagHelpers";
// ============================================================================
// Ejemplo 1: Usar decorador @RequireFeature
// ============================================================================
export class ShopCommand {
/**
* El decorador RequireFeature bloquea automáticamente la ejecución
* si el flag no está habilitado y responde al usuario
*/
@RequireFeature("new_shop_system", {
fallbackMessage: "🔧 El nuevo sistema de tienda estará disponible pronto.",
})
async execute(interaction: CommandInteraction) {
// Este código solo se ejecuta si el flag está habilitado
await interaction.reply("¡Bienvenido a la nueva tienda!");
}
}
// ============================================================================
// Ejemplo 2: Usar featureGuard (más control)
// ============================================================================
export async function handleMiningCommand(interaction: CommandInteraction) {
// featureGuard devuelve true/false y opcionalmente responde al usuario
if (
!(await featureGuard("new_mining_system", interaction, {
replyIfDisabled: true,
customMessage: "⛏️ El nuevo sistema de minería está en mantenimiento.",
}))
) {
return; // Sale si el flag está deshabilitado
}
// Código del nuevo sistema de minería
await interaction.reply("⛏️ Iniciando minería con el nuevo sistema...");
}
// ============================================================================
// Ejemplo 3: Check manual (para lógica condicional)
// ============================================================================
export async function handleInventoryCommand(interaction: CommandInteraction) {
const useNewUI = await isFeatureEnabledForInteraction(
"inventory_ui_v2",
interaction
);
if (useNewUI) {
// Muestra el inventario con la nueva UI
await showInventoryV2(interaction);
} else {
// Muestra el inventario con la UI antigua
await showInventoryV1(interaction);
}
}
async function showInventoryV2(interaction: CommandInteraction) {
await interaction.reply("📦 Inventario (UI v2)");
}
async function showInventoryV1(interaction: CommandInteraction) {
await interaction.reply("📦 Inventario (UI v1)");
}
// ============================================================================
// Ejemplo 4: A/B Testing
// ============================================================================
export async function handleCombatCommand(interaction: CommandInteraction) {
const context = extractContext(interaction);
// A/B testing: mitad de usuarios usa el algoritmo nuevo, mitad el viejo
const result = await abTest("improved_combat_algorithm", context, {
variant: async () => {
// Nueva versión del algoritmo
return calculateDamageV2();
},
control: async () => {
// Versión antigua del algoritmo
return calculateDamageV1();
},
});
await interaction.reply(`⚔️ Daño calculado: ${result}`);
}
function calculateDamageV2(): number {
// Lógica nueva
return Math.floor(Math.random() * 100) + 50;
}
function calculateDamageV1(): number {
// Lógica antigua
return Math.floor(Math.random() * 50) + 25;
}
// ============================================================================
// Ejemplo 5: Múltiples flags (acceso premium)
// ============================================================================
export async function handlePremiumFeature(interaction: CommandInteraction) {
const context = extractContext(interaction);
// Requiere que TODOS los flags estén habilitados
const hasAccess = await requireAllFeatures(
["premium_features", "beta_access", "advanced_commands"],
context
);
if (!hasAccess) {
await interaction.reply({
content: "❌ No tienes acceso a esta funcionalidad premium.",
flags: ["Ephemeral"],
});
return;
}
await interaction.reply("✨ Funcionalidad premium activada!");
}
// ============================================================================
// Ejemplo 6: Migrando de sistema antiguo a nuevo gradualmente
// ============================================================================
export async function handleEconomyCommand(interaction: CommandInteraction) {
const useNewSystem = await isFeatureEnabledForInteraction(
"economy_system_v2",
interaction
);
if (useNewSystem) {
// Nuevo sistema de economía
await newEconomySystem.processTransaction(interaction);
} else {
// Sistema antiguo (mantener por compatibilidad durante el rollout)
await oldEconomySystem.processTransaction(interaction);
}
}
// Simulación de sistemas
const newEconomySystem = {
async processTransaction(interaction: CommandInteraction) {
await interaction.reply("💰 Transacción procesada (Sistema v2)");
},
};
const oldEconomySystem = {
async processTransaction(interaction: CommandInteraction) {
await interaction.reply("💰 Transacción procesada (Sistema v1)");
},
};
// ============================================================================
// Ejemplo 7: Eventos temporales con fechas
// ============================================================================
export async function handleHalloweenEvent(interaction: CommandInteraction) {
// El flag 'halloween_2025' tiene startDate y endDate configurados
// Se habilitará automáticamente durante el período del evento
if (
!(await featureGuard("halloween_2025", interaction, {
replyIfDisabled: true,
customMessage:
"🎃 El evento de Halloween no está activo en este momento.",
}))
) {
return;
}
await interaction.reply("🎃 ¡Bienvenido al evento de Halloween 2025!");
}
// ============================================================================
// Ejemplo 8: Kill Switch para emergencias
// ============================================================================
export async function handleProblematicFeature(
interaction: CommandInteraction
) {
// Si hay un bug crítico, el administrador puede cambiar el flag a 'maintenance'
// inmediatamente sin necesidad de redeploy
if (
!(await featureGuard("experimental_feature", interaction, {
replyIfDisabled: true,
customMessage:
"🔧 Esta funcionalidad está en mantenimiento temporalmente.",
}))
) {
return;
}
// Código que podría tener bugs
await experimentalLogic(interaction);
}
async function experimentalLogic(interaction: CommandInteraction) {
await interaction.reply("🧪 Funcionalidad experimental activada");
}
// ============================================================================
// Ejemplo 9: Beta Testing por Guild (servidor)
// ============================================================================
export async function handleBetaCommand(interaction: CommandInteraction) {
// El flag 'beta_features' está configurado con:
// - target: 'guild'
// - rolloutStrategy: 'whitelist'
// - rolloutConfig: { targetIds: ['guild_id_1', 'guild_id_2'] }
const context = extractContext(interaction);
if (
!(await featureGuard("beta_features", interaction, {
replyIfDisabled: true,
customMessage:
"🔒 Tu servidor no tiene acceso a las funcionalidades beta.",
}))
) {
return;
}
await interaction.reply(
"🧪 Funcionalidades beta activadas para este servidor!"
);
}
// ============================================================================
// Ejemplo 10: Rollout progresivo por porcentaje
// ============================================================================
export async function handleNewGameMode(interaction: CommandInteraction) {
// El flag 'new_game_mode' está configurado con:
// - status: 'rollout'
// - rolloutStrategy: 'percentage'
// - rolloutConfig: { percentage: 25 }
//
// Esto significa que el 25% de usuarios verán el nuevo modo de juego
// de forma determinista (el mismo usuario siempre verá lo mismo)
if (!(await featureGuard("new_game_mode", interaction))) {
return;
}
await interaction.reply("🎮 ¡Nuevo modo de juego desbloqueado!");
}
// ============================================================================
// Ejemplo 11: Usando en Message Commands (comandos de texto)
// ============================================================================
export async function handleTextCommand(message: Message, args: string[]) {
// También funciona con comandos de texto tradicionales
const context = extractContext(message);
const useNewParser = await isFeatureEnabledForInteraction(
"new_command_parser",
message
);
if (useNewParser) {
await parseCommandV2(message, args);
} else {
await parseCommandV1(message, args);
}
}
async function parseCommandV2(message: Message, args: string[]) {
await message.reply("Comando parseado con parser v2");
}
async function parseCommandV1(message: Message, args: string[]) {
await message.reply("Comando parseado con parser v1");
}
// ============================================================================
// RESUMEN DE PATRONES
// ============================================================================
/*
* 1. @RequireFeature - Para bloquear métodos enteros fácilmente
* 2. featureGuard - Para checks con respuesta automática al usuario
* 3. isFeatureEnabled - Para lógica condicional if/else
* 4. abTest - Para A/B testing
* 5. requireAllFeatures - Para requerir múltiples flags (AND)
* 6. requireAnyFeature - Para requerir al menos uno (OR)
* 7. withFeature - Para ejecutar código con fallback opcional
*
* Configuración de flags vía comando:
* /featureflags create name:flag_name status:disabled target:global
* /featureflags update flag:flag_name status:enabled
* /featureflags rollout flag:flag_name strategy:percentage percentage:25
*/

View File

@@ -11,5 +11,5 @@
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
"include": ["src", "test/examples"]
}