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:
300
README/FEATURE_FLAGS_QUICKSTART.md
Normal file
300
README/FEATURE_FLAGS_QUICKSTART.md
Normal 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
|
||||
626
README/FEATURE_FLAGS_SYSTEM.md
Normal file
626
README/FEATURE_FLAGS_SYSTEM.md
Normal 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
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
173
scripts/setupFeatureFlags.ts
Normal file
173
scripts/setupFeatureFlags.ts
Normal 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);
|
||||
});
|
||||
488
src/commands/splashcmd/net/featureflags.ts
Normal file
488
src/commands/splashcmd/net/featureflags.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
265
src/core/lib/featureFlagHelpers.ts
Normal file
265
src/core/lib/featureFlagHelpers.ts
Normal 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);
|
||||
}
|
||||
22
src/core/loaders/featureFlagsLoader.ts
Normal file
22
src/core/loaders/featureFlagsLoader.ts
Normal 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;
|
||||
629
src/core/services/FeatureFlagService.ts
Normal file
629
src/core/services/FeatureFlagService.ts
Normal 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();
|
||||
142
src/core/types/featureFlags.ts
Normal file
142
src/core/types/featureFlags.ts
Normal 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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
296
test/examples/featureFlagsUsage.ts
Normal file
296
test/examples/featureFlagsUsage.ts
Normal 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
|
||||
*/
|
||||
@@ -11,5 +11,5 @@
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "test/examples"]
|
||||
}
|
||||
Reference in New Issue
Block a user