Browse Source

updated agents anniversaries and countdown

Pablo Barrera Yaksic 2 weeks ago
parent
commit
e023e8b780

+ 226 - 0
AGENTS.md

@@ -0,0 +1,226 @@
+# AGENTS.md
+
+This file contains guidelines and commands for agentic coding agents working in this TypeScript news aggregation and Mastodon bot repository.
+
+## Project Overview
+
+This is a TypeScript-based news aggregation system that scrapes 20+ Chilean news portals and posts articles to Mastodon (mastodon.cl). The system runs on AWS Lambda using the Serverless Framework and includes interactive bots for fortune telling, reminders, and anniversaries.
+
+## Build and Development Commands
+
+### Core Commands
+```bash
+# Build TypeScript to JavaScript
+bun run build
+# or
+npx tsc
+
+# Run tests (currently placeholder)
+bun run test
+
+# Lint code
+bun run lint
+# or
+npx eslint src/
+
+# Start local development server
+bun run offline
+# or
+serverless offline
+```
+
+### Running Individual Tests
+Currently no testing framework is implemented. The test script is a placeholder (`"test": "exit 0"`). When adding tests, use Jest or Vitest and update this section.
+
+### Deployment
+```bash
+# Deploy to AWS Lambda
+serverless deploy
+
+# Deploy specific function
+serverless deploy function -f emol
+```
+
+## Code Style Guidelines
+
+### TypeScript Configuration
+- **Target**: ES6
+- **Module System**: CommonJS
+- **Strict Mode**: Enabled (`strictNullChecks: true`)
+- **Output Directory**: `dist/`
+- **Source Directory**: `src/`
+
+### Import Style
+```typescript
+// External libraries first
+import "dotenv/config";
+import { type Handler } from "aws-lambda";
+import { createRestAPIClient, mastodon } from "masto";
+
+// Internal modules with relative paths
+import ScraperArticles from "../utils/scraper-articles";
+import RedisClient from "../libs/redis-client";
+import LogLevels from "../enums/log-levels";
+import config from "../config";
+
+// Type imports
+import { type IScraperArticlesOptions } from "../interfaces/scaper-articles-options";
+```
+
+### Naming Conventions
+- **Classes**: PascalCase (`Portal`, `RedisClient`)
+- **Interfaces**: PascalCase with `I` prefix (`IArticle`, `IScraperOptions`)
+- **Enums**: PascalCase (`LogLevels`, `Emojis`)
+- **Variables**: camelCase (`_name`, `_mastodonClient`)
+- **Constants**: UPPER_SNAKE_CASE (`LOG_LEVEL`, `REDIS_CONN`)
+- **Files**: kebab-case (`scraper-articles.ts`, `log-levels.ts`)
+
+### Class Structure
+```typescript
+export default class Portal {
+  private readonly _name: string;
+  private readonly _mastodonClient: mastodon.rest.Client;
+
+  constructor(name: string, accessToken: string, options: IOptions) {
+    this._name = name;
+    // Initialize properties
+  }
+
+  public async run(): Promise<void> {
+    try {
+      // Main logic
+    } catch (err: any) {
+      // Error handling
+    }
+  }
+
+  public getHandler(): Handler {
+    return async (event, context) => {
+      await this.run(event, context);
+    }
+  }
+}
+```
+
+### Interface Definitions
+```typescript
+export interface IArticle {
+  title: string
+  content: string
+  link: string
+  image: File | null
+  author: string
+  date: string
+}
+```
+
+### Enum Definitions
+```typescript
+enum LogLevels {
+  DEBUG = "debug",
+  INFO = "info"
+};
+
+export default LogLevels;
+```
+
+### Error Handling
+```typescript
+try {
+  // Code that might throw
+} catch (err: any) {
+  console.log(`${this._name} | An error has occurred\n`);
+  console.error(err.message);
+  if (config.LOG_LEVEL === LogLevels.DEBUG) {
+    console.debug("Additional context:", event, context);
+  }
+}
+```
+
+### Logging
+- Use `console.log()` for general logging
+- Use `console.error()` for errors
+- Use `console.debug()` for debug information (only when `LOG_LEVEL === "debug"`)
+- Include class name in logs: `${this._name} | Message`
+
+### Environment Configuration
+- All configuration in `src/config.ts`
+- Use environment variables with fallbacks: `process.env.VAR ?? "default"`
+- Import config: `import config from "../config"`
+
+### ESLint Rules
+- Quotes: Disabled (can use single or double)
+- Semicolons: Disabled (optional)
+- Base: `standard-with-typescript`
+
+### File Organization
+```
+src/
+├── agents/          # Interactive Mastodon bots
+├── enums/           # TypeScript enums
+├── interfaces/      # TypeScript interfaces
+├── libs/            # Client libraries (redis, postgres)
+├── portales/        # News portal scrapers
+│   └── [portal]/
+│       ├── handler.ts
+│       ├── definition.yml
+│       └── scraper.ts
+├── utils/           # Utility functions
+├── test/            # Test files
+└── config.ts        # Central configuration
+```
+
+### Portal Implementation Pattern
+Each news portal follows this structure:
+1. `handler.ts` - Lambda handler function
+2. `definition.yml` - Serverless function definition
+3. `scraper.ts` - Portal-specific scraping logic
+4. Extends base `Portal` class from `src/portales/portal.ts`
+
+### Mastodon Integration
+- Use `masto` library for API calls
+- Create client: `createRestAPIClient({ url: config.MASTODON_URL, accessToken })`
+- Post statuses: `client.v1.statuses.create({ status: message, mediaIds })`
+- Upload media: `client.v2.media.create({ file: image, description })`
+
+### Redis Caching
+- Use Redis client for duplicate prevention
+- Store article links with expiration
+- Key pattern: article link, Value: date string
+- Default expiration: 24 hours (`60 * 60 * 24`)
+
+### Message Formatting
+- Include emojis from `Emojis` enum
+- Limit message length to 400 characters
+- Format: `${EMOJI} Title\n\nContent\n\n${HASHTAGS}\n\n${LINK}`
+- Handle special cases (subscriber-only content, publirreportajes)
+
+## Development Notes
+
+### Package Manager
+- Primary: Bun (uses `bun.lockb`)
+- Fallback: npm (has `package-lock.json`)
+
+### Dependencies
+- **Scraping**: `axios`, `cheerio`, `chrono-node`
+- **Social**: `masto` (Mastodon API)
+- **Database**: `postgres`, `redis`
+- **Utilities**: `date-fns-tz`, `dotenv`, `cron`
+
+### Testing
+- No testing framework currently implemented
+- When adding tests, consider Jest or Vitest
+- Test files should be excluded from TypeScript compilation
+- Place tests in `src/test/` directory
+
+### Deployment
+- Uses Serverless Framework
+- AWS Lambda with Node.js 20.x runtime
+- Functions triggered hourly via CloudWatch Events
+- Timeout: 60 seconds
+
+### Security
+- Never commit secrets or API keys
+- Use environment variables for sensitive data
+- Access tokens stored in environment variables
+- Redis connection string configurable

+ 1 - 1
src/agents/anniversaries/index.ts

@@ -104,7 +104,7 @@ try {
     config.DEFAULT_TIMEZONE
   );
 
-  if (config.LOG_LEVEL == LogLevels.DEBUG) {
+  if (config.DEVELOP) {
     anniversaries.start();
   }
 } catch (error) {

+ 200 - 0
src/agents/anonybot/README.md

@@ -0,0 +1,200 @@
+# Anonybot 🎭
+
+Bot para compartir mensajes anónimos de forma segura en Mastodon.cl.
+
+## Funcionalidades
+
+### ✨ Características Principales
+- **Publicación inmediata** de mensajes anónimos
+- **Filtrado conservador** de contenido
+- **Protección de privacidad** con detección de PII
+- **Rate limiting** para prevenir abuso (5 mensajes por hora)
+- **Content Warnings** automáticos para temas sensibles
+- **Feedback claro** al usuario sobre el estado de su mensaje
+
+### 🛡️ Seguridad y Moderación
+- Detección y remoción de información personal (RUT, teléfonos, emails)
+- Bloqueo de discurso de odio, violencia y contenido sexualmente explícito
+- Filtros contra spam y estafas
+- Validación de longitud (mínimo 10, máximo 500 caracteres)
+
+### 📝 Formato de Mensajes
+Los mensajes anónimos se publican con el siguiente formato:
+```
+🎭 Alguien dijo:
+
+[contenido del mensaje]
+
+#mensajeanónimo #confesión
+```
+
+Para temas sensibles se añade Content Warning automáticamente.
+
+## Uso
+
+### Para Usuarios
+1. **Mencionar al bot**: `@anonybot tu mensaje aquí`
+2. **Esperar confirmación**: El bot responderá con el estado de tu mensaje
+3. **Ver publicación**: Tu mensaje aparecerá anónimamente en la timeline
+
+### Ejemplos de Uso
+```
+@anonybot A veces siento que nadie me entiende realmente
+@anonybot Tengo miedo de decirle a mi familia que quiero cambiar de carrera
+@anonybot Hoy me sentí muy solo aunque estuviera rodeado de gente
+```
+
+## Configuración
+
+### Variables de Entorno
+Agregar a tu archivo `.env`:
+```bash
+MASTODON_KEY_ANONYBOT=tu_access_token_aqui
+DEVELOP=false  # Para modo producción
+```
+
+### Configuración en `src/config.ts`
+```typescript
+ANONYBOT_MAX_MESSAGE_LENGTH: 500,          # Máximo caracteres
+ANONYBOT_RATE_LIMIT_HOURS: 1,             # Ventana de tiempo para rate limit
+ANONYBOT_MAX_MESSAGES_PER_HOUR: 5,        # Máximo mensajes por hora
+ANONYBOT_MODERATION_LEVEL: "conservative" # Nivel de moderación
+```
+
+## Arquitectura
+
+### 📁 Estructura de Archivos
+```
+src/agents/anonybot/
+├── index.ts         # Clase principal del agente
+├── filters.ts       # Filtrado de contenido y PII
+├── moderation.ts    # Lógica de moderación
+├── interfaces.ts    # Tipos TypeScript
+└── README.md        # Esta documentación
+```
+
+### 🔧 Componentes
+
+#### ContentFilter (`filters.ts`)
+- **Extracción de contenido**: Remueve HTML y menciones
+- **Detección de PII**: Identifica RUT, teléfonos, emails chilenos
+- **Validación**: Verifica longitud y formato del mensaje
+- **Detección de temas sensibles**: Para Content Warnings
+
+#### ContentModerator (`moderation.ts`)
+- **Reglas conservadoras**: Bloqueo automático de contenido inapropiado
+- **Niveles de moderación**: Conservative, Moderate, Liberal
+- **Detección de spam**: Patrones repetitivos y mayúsculas excesivas
+
+#### Anonybot (`index.ts`)
+- **Streaming API**: Escucha notificaciones en tiempo real
+- **Rate limiting**: Control de uso por usuario con Redis
+- **Publicación**: Formateo y envío de mensajes anónimos
+- **Manejo de errores**: Respuestas informativas al usuario
+
+## Flujo de Procesamiento
+
+1. **Recepción**: Notificación de mención vía Streaming API
+2. **Verificación**: Rate limit y existencia de contenido
+3. **Limpieza**: Remoción de HTML, menciones y PII
+4. **Moderación**: Aplicación de reglas conservadoras
+5. **Validación**: Longitud y formato del mensaje
+6. **Publicación**: Formato final con hashtags automáticos
+7. **Confirmación**: Feedback al usuario original
+
+## Respuestas al Usuario
+
+### ✅ Mensajes Exitosos
+```
+@usuario ✔️ Tu mensaje anónimo ha sido publicado exitosamente 🎉
+
+ℹ️ Se ha añadido un Content Warning debido al tema sensible del mensaje.
+```
+
+### ⚠️ Mensajes Bloqueados
+```
+@usuario ❌ No se pudo publicar tu mensaje anónimo. Razón: Contenido ofensivo
+
+ℹ️ Si crees que es un error, contacta al administrador del bot.
+```
+
+### 🚫 Rate Limit
+```
+@usuario ⏳ Has alcanzado el límite de 5 mensajes por hora. Podrás enviar otro mensaje en 1h 30min.
+```
+
+## Seguridad
+
+### 🔒 Privacidad Garantizada
+- **Sin almacenamiento**: No se guarda el contenido original ni autor
+- **Anonimato completo**: Remoción de todas las menciones
+- **PII filtrado**: Detección automática de información personal
+- **Logging seguro**: Solo metadata de depuración, sin contenido
+
+### 🛡️ Protección Contra Abuso
+- **Rate limiting**: Máximo 5 mensajes por hora por usuario
+- **Moderación estricta**: Bloqueo automático de contenido dañino
+- **Detección de patrones**: Spam y comportamiento anómalo
+- **Feedback claro**: Explicaciones transparentes de bloqueos
+
+## Despliegue
+
+### Desarrollo
+```bash
+# Construir
+bun run build
+
+# Ejecutar en modo desarrollo
+DEVELOP=true node dist/agents/anonybot/index.js
+```
+
+### Producción
+```bash
+# Configurar token en .env
+echo "MASTODON_KEY_ANONYBOT=tu_token" >> .env
+
+# Desplegar
+bun run build
+DEVELOP=false node dist/agents/anonybot/index.js
+```
+
+## Mantenimiento
+
+### 📊 Monitoreo
+- Logs de eventos en tiempo real
+- Métricas de uso por Redis
+- Estadísticas de moderación
+- Alertas de errores
+
+### 🔄 Actualización de Reglas
+- Modificar `moderation.ts` para ajustar filtros
+- Actualizar patrones PII en `filters.ts`
+- Configurar niveles en variables de entorno
+- Reiniciar servicio para aplicar cambios
+
+## Consideraciones Éticas
+
+Este bot está diseñado con principios de:
+- **Consentimiento informado**: Los usuarios saben que su mensaje será anónimo
+- **Protección de datos**: No se retiene información personal
+- **Moderación responsable**: Prevención de daño mientras permite expresión
+- **Transparencia**: Explicaciones claras de decisiones de moderación
+
+## Contribuciones
+
+Para mejorar el bot:
+1. **Tests**: Agregar casos de prueba en `src/test/`
+2. **Documentación**: Actualizar este README
+3. **Reglas**: Mejorar patrones de moderación
+4. **Seguridad**: Identificar nuevas formas de PII o spam
+
+## Soporte
+
+Para reportar problemas o sugerir mejoras:
+- **GitHub issues**: Repo principal del proyecto
+- **Contacto admin**: Para problemas urgentes de moderación
+- **Feedback de usuarios**: Para mejoras de usabilidad
+
+---
+
+*Anonybot es parte del ecosistema de bots de Mastodon.cl, manteniendo un equilibrio entre libertad de expresión y seguridad comunitaria.*

+ 91 - 0
src/agents/anonybot/filters.ts

@@ -0,0 +1,91 @@
+import config from "../../config";
+
+export class ContentFilter {
+  private readonly _botUsername: string;
+
+  constructor(botUsername: string) {
+    this._botUsername = botUsername.toLowerCase();
+  }
+
+  public extractMessageContent(content: string): string {
+    // Remove HTML tags (Mastodon content comes as HTML)
+    const cleanText = content.replace(/<[^>]+>/g, "").trim();
+    
+    // Remove bot username mention (e.g., "@anonybot")
+    const withoutBotMention = cleanText.replace(new RegExp(`@${this._botUsername}\\b`, 'gi'), "").trim();
+    
+    // Remove all other @mentions to ensure anonymity
+    const withoutAllMentions = withoutBotMention.replace(/@\w+/g, "").trim();
+    
+    // Remove extra whitespace
+    const finalText = withoutAllMentions.replace(/\s+/g, " ").trim();
+    
+    return finalText;
+  }
+
+  public detectAndRemovePII(text: string): string {
+    return text
+      // Chilean ID (RUN/RUT) - format: XX.XXX.XXX-X
+      .replace(/\b\d{1,2}\.\d{3}\.\d{3}[-][0-9kK]\b/g, "[ID]")
+      // Chilean phone numbers - formats: +56 X XXXX XXXX, 56 X XXXX XXXX, etc.
+      .replace(/\b(\+56|56)?[-s]?(\d{2}|\d{3})[-s]?\d{3}[-s]?\d{4}\b/g, "[TELÉFONO]")
+      // International phone numbers
+      .replace(/\b\+\d{1,3}[-s]?\d{1,4}[-s]?\d{1,4}[-s]?\d{1,9}\b/g, "[TELÉFONO]")
+      // General email patterns
+      .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "[EMAIL]")
+      // Chilean addresses (street + number pattern)
+      .replace(/\b[A-Za-zÀ-ÿ\s]+\s+\d+\b/g, "[DIRECCIÓN]")
+      // Social media handles/usernames
+      .replace(/@[A-Za-z0-9._]+/g, "[USUARIO]")
+      // URLs to prevent spam/tracking
+      .replace(/\bhttps?:\/\/[^\s]+/g, "[ENLACE]")
+      // Remove any remaining @mentions just in case
+      .replace(/@\w+/g, "")
+      // Normalize whitespace
+      .replace(/\s+/g, " ")
+      .trim();
+  }
+
+  public sanitizeMessage(message: string): string {
+    return message
+      .replace(/@\w+/g, "") // Remove all mentions
+      .replace(/<[^>]+>/g, "") // Remove HTML
+      .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "[EMAIL]") // Emails
+      .replace(/\bhttps?:\/\/[^\s]+/g, "[ENLACE]") // URLs
+      .replace(/\s+/g, " ")
+      .trim();
+  }
+
+  public validateLength(message: string): { valid: boolean; reason?: string } {
+    const minLength = 10;
+    const maxLength = Number(config.ANONYBOT_MAX_MESSAGE_LENGTH);
+
+    if (!message || message.length < minLength) {
+      return { valid: false, reason: 'El mensaje es muy corto (mínimo 10 caracteres)' };
+    }
+
+    if (message.length > maxLength) {
+      return { valid: false, reason: `El mensaje es muy largo (máximo ${maxLength} caracteres)` };
+    }
+
+    return { valid: true };
+  }
+
+  public detectSensitiveTopics(message: string): string | null {
+    const sensitivePatterns = [
+      { pattern: /\b(muerte|matar|suicidio|autolesión|deprimir|depresi)/i, cw: "Salud mental y bienestar" },
+      { pattern: /\b(violencia|abuso|maltrato|agresi)/i, cw: "Contenido sensible" },
+      { pattern: /\b(enfermedad|cáncer|diabetes|coronavirus|covid)/i, cw: "Salud" },
+      { pattern: /\b(dinero|deuda|préstamo|emergencia económica)/i, cw: "Temas financieros" },
+      { pattern: /\b(sexual|sexo|íntimo|privacidad)/i, cw: "Temas personales" }
+    ];
+
+    for (const { pattern, cw } of sensitivePatterns) {
+      if (pattern.test(message)) {
+        return cw;
+      }
+    }
+
+    return null;
+  }
+}

+ 225 - 0
src/agents/anonybot/index.ts

@@ -0,0 +1,225 @@
+import { createStreamingAPIClient, createRestAPIClient, mastodon } from "masto";
+import { toZonedTime, fromZonedTime, formatInTimeZone } from "date-fns-tz";
+
+import config from "../../config";
+import Emojis from "../../enums/emojis";
+import LogLevels from "../../enums/log-levels";
+import RedisClient from "../../libs/redis-client";
+
+import { ContentFilter } from "./filters";
+import { ContentModerator } from "./moderation";
+import { ProcessedMessage, RateLimitInfo } from "./interfaces";
+
+export default class Anonybot {
+  private readonly _mastodonStreamingClient: mastodon.streaming.Client;
+  private readonly _mastodonRestClient: mastodon.rest.Client;
+  private readonly _redisClient: RedisClient;
+  private readonly _contentFilter: ContentFilter;
+  private readonly _moderator: ContentModerator;
+  private readonly _botUsername: string = "anonybot";
+
+  constructor(accessToken = config.MASTODON_TEST_ACCESS_TOKEN) {
+    this._mastodonStreamingClient = createStreamingAPIClient({
+      streamingApiUrl: config.MASTODON_STREAMING_URL,
+      accessToken
+    });
+    this._mastodonRestClient = createRestAPIClient({
+      url: config.MASTODON_API_URL,
+      accessToken
+    });
+    this._redisClient = new RedisClient();
+    this._contentFilter = new ContentFilter(this._botUsername);
+    this._moderator = new ContentModerator(config.ANONYBOT_MODERATION_LEVEL);
+  }
+
+  private async checkRateLimit(account: string): Promise<RateLimitInfo> {
+    const key = `anonybot:rate:${account}`;
+    const messageCount = await this._redisClient.retrieve(key);
+    const count = messageCount ? Number(messageCount) : 0;
+    const maxMessages = Number(config.ANONYBOT_MAX_MESSAGES_PER_HOUR);
+
+    if (count >= maxMessages) {
+      // Default to 1 hour if we can't get TTL
+      return {
+        allowed: false,
+        messageCount: count,
+        timeRemaining: 3600
+      };
+    }
+
+    return { allowed: true, messageCount: count };
+  }
+
+  private async incrementRateLimit(account: string): Promise<void> {
+    const key = `anonybot:rate:${account}`;
+    const currentCount = await this._redisClient.retrieve(key);
+
+    if (currentCount === null) {
+      await this._redisClient.store(key, "1", { EX: Number(config.ANONYBOT_RATE_LIMIT_HOURS) * 3600 });
+    } else {
+      await this._redisClient.store(key, String(Number(currentCount) + 1), {
+        XX: true,
+        EX: Number(config.ANONYBOT_RATE_LIMIT_HOURS) * 3600
+      });
+    }
+  }
+
+  private async processMessage(content: string): Promise<ProcessedMessage> {
+    try {
+      // Step 1: Extract clean content
+      const cleanContent = this._contentFilter.extractMessageContent(content);
+
+      // Step 2: Basic validation
+      if (!cleanContent) {
+        return { success: false, reason: 'No se pudo extraer contenido del mensaje' };
+      }
+
+      // Step 3: Length validation
+      const lengthCheck = this._contentFilter.validateLength(cleanContent);
+      if (!lengthCheck.valid) {
+        return { success: false, reason: lengthCheck.reason };
+      }
+
+      // Step 4: Remove PII
+      const sanitizedContent = this._contentFilter.detectAndRemovePII(cleanContent);
+
+      // Step 5: Content moderation
+      const moderationResult = await this._moderator.filterContent(sanitizedContent);
+      if (!moderationResult.allowed) {
+        return { success: false, reason: moderationResult.reason };
+      }
+
+      // Step 6: Detect sensitive topics for CW
+      const cw = this._contentFilter.detectSensitiveTopics(sanitizedContent);
+
+      return {
+        success: true,
+        processedMessage: this._contentFilter.sanitizeMessage(sanitizedContent),
+        contentWarning: cw || undefined
+      };
+
+    } catch (error: any) {
+      console.error("Error processing message:", error);
+      return { success: false, reason: 'Error interno al procesar el mensaje' };
+    }
+  }
+
+  private formatAnonymousMessage(message: string, contentWarning?: string): string {
+    let formattedMessage = `🎭 Alguien dijo:\n\n${message}\n\n#confesión`;
+
+    if (contentWarning) {
+      formattedMessage = `${contentWarning}\n\n${formattedMessage}`;
+    }
+
+    return formattedMessage;
+  }
+
+  private async reply(inReplyToId: string | undefined, user: string, visibility: mastodon.v1.StatusVisibility, status: string): Promise<void> {
+    let result: mastodon.v1.Status;
+
+    console.log(`Sending reply to ${user}\n`, status);
+    result = await this._mastodonRestClient.v1.statuses.create({ inReplyToId, status, visibility });
+
+    if (config.LOG_LEVEL === LogLevels.DEBUG) {
+      console.debug("Reply result", result);
+    }
+  }
+
+  private async publishAnonymous(message: string, contentWarning?: string): Promise<void> {
+    const formattedMessage = this.formatAnonymousMessage(message, contentWarning);
+    const publishOptions: any = { status: formattedMessage };
+
+    if (contentWarning) {
+      publishOptions.spoilerText = contentWarning;
+    }
+
+    console.log("Publishing anonymous message:\n", formattedMessage);
+    const result = await this._mastodonRestClient.v1.statuses.create(publishOptions);
+
+    if (config.LOG_LEVEL === LogLevels.DEBUG) {
+      console.debug("Publish result", result);
+    }
+  }
+
+  private formatTimeRemaining(seconds: number): string {
+    const hours = Math.floor(seconds / 3600);
+    const minutes = Math.floor((seconds % 3600) / 60);
+
+    if (hours > 0) {
+      return `${hours}h ${minutes}min`;
+    }
+    return `${minutes}min`;
+  }
+
+  public async subscribe(): Promise<void> {
+    console.log("Anonybot listening to incoming events...");
+
+    for await (const event of this._mastodonStreamingClient.user.notification.subscribe()) {
+      switch (event.event) {
+        case "notification":
+          if (event.payload.type == "mention") {
+            const { id, createdAt, visibility, url, content } = event.payload.status ?? {};
+            const account = event.payload.account.acct;
+
+            if (config.DEVELOP) {
+              console.log("Event received\n", event.payload);
+            } else {
+              console.log("Event received\n", { id, createdAt, visibility, url, content: content || "", account });
+            }
+
+            try {
+              // Check rate limit first
+              const rateLimit = await this.checkRateLimit(account);
+              if (!rateLimit.allowed) {
+                const timeRemaining = this.formatTimeRemaining(rateLimit.timeRemaining || 0);
+                const replyMessage = `@${account} ${Emojis.HOURGLASS} Has alcanzado el límite de ${config.ANONYBOT_MAX_MESSAGES_PER_HOUR} mensajes por hora. Podrás enviar otro mensaje en ${timeRemaining}.`;
+                await this.reply(id, account, visibility, replyMessage);
+              }
+
+              // Process the message
+              const processed = await this.processMessage(content || "");
+
+              if (!processed.success) {
+                let replyMessage = `@${account} ${Emojis.CROSS_MARK} No se pudo publicar tu mensaje anónimo.`;
+
+                if (processed.reason) {
+                  replyMessage += ` Razón: ${processed.reason}`;
+                }
+
+                replyMessage += `\n\n${Emojis.INFO} Si crees que es un error, contacta al administrador del bot.`;
+
+                await this.reply(id, account, visibility, replyMessage);
+              }
+
+              // Publish successfully processed message
+              await this.publishAnonymous(processed.processedMessage!, processed.contentWarning);
+
+              // Increment rate limit
+              await this.incrementRateLimit(account);
+
+              // Send confirmation
+              let confirmMessage = `@${account} ${Emojis.CHECK} Tu mensaje anónimo ha sido publicado exitosamente ${Emojis.PARTY_POPPER}`;
+
+              if (processed.contentWarning) {
+                confirmMessage += `\n\n${Emojis.INFO} Se ha añadido un Content Warning debido al tema sensible del mensaje.`;
+              }
+
+              await this.reply(id, account, visibility, confirmMessage);
+
+            } catch (error: any) {
+              console.error("Error processing notification:", error);
+              const errorMessage = `@${account} ${Emojis.CROSS_MARK} Ha ocurrido un error inesperado. Por favor, inténtalo más tarde.`;
+              await this.reply(id, account, visibility, errorMessage);
+            }
+          }
+          break;
+      }
+    };
+  }
+}
+
+try {
+  new Anonybot(config.DEVELOP ? config.MASTODON_TEST_ACCESS_TOKEN : config.MASTODON_KEY_ANONYBOT).subscribe();
+} catch (error) {
+  console.error(error);
+}

+ 25 - 0
src/agents/anonybot/interfaces.ts

@@ -0,0 +1,25 @@
+export interface ProcessedMessage {
+  success: boolean;
+  processedMessage?: string;
+  contentWarning?: string;
+  reason?: string;
+}
+
+export interface ModerationRule {
+  pattern: RegExp;
+  severity: 'low' | 'medium' | 'high';
+  action: 'warn' | 'block' | 'flag';
+  reason: string;
+}
+
+export interface ModerationResult {
+  allowed: boolean;
+  reason?: string;
+  severity?: string;
+}
+
+export interface RateLimitInfo {
+  allowed: boolean;
+  messageCount: number;
+  timeRemaining?: number;
+}

+ 183 - 0
src/agents/anonybot/moderation.ts

@@ -0,0 +1,183 @@
+import { ModerationRule, ModerationResult } from "./interfaces";
+
+export class ContentModerator {
+  private readonly _moderationLevel: string;
+
+  constructor(moderationLevel = "conservative") {
+    this._moderationLevel = moderationLevel;
+  }
+
+  private getModerationRules(): ModerationRule[] {
+    if (this._moderationLevel === "conservative") {
+      return this.getConservativeRules();
+    } else if (this._moderationLevel === "moderate") {
+      return this.getModerateRules();
+    } else {
+      return this.getLiberalRules();
+    }
+  }
+
+  private getConservativeRules(): ModerationRule[] {
+    return [
+      // Hate speech and discrimination
+      {
+        pattern: /\b(hate|odio|racista|sexista|homófobo|transfóbico|xenófobo|discriminació)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Discurso de odio detectado'
+      },
+      {
+        pattern: /\b(negro|negra|negritos?|indio|indios?|mapuche|mapuches?|peruano|peruanos?|boliviano|bolivianos?|venezolano|venezolanos?|haitiano|haitianos?)\s+(sucio|sucia|fea|feo|malo|inútil|ladron|ladrona)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Racismo o discriminación étnica'
+      },
+      // Violence and self-harm
+      {
+        pattern: /\b(matar|asesinar|asesinato|homicidio|violencia|golpear|pegar|agredir|maltratar)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Contenido violento'
+      },
+      {
+        pattern: /\b(suicidio|autolesión|quitarse\s+la\s+vida|moriendo|muero)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Contenido sobre autolesión'
+      },
+      // Sexual content
+      {
+        pattern: /\b(pornografía|porno|sexo\s+explícito|contenido\s+sexual|follar|coger|coito|orgasmo|masturbació)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Contenido sexualmente explícito'
+      },
+      {
+        pattern: /\b(pene|vagina|ano|nalga|nalgas|senos|pechos|tet[ai]s?|testículos|pene|verga|chocha|concha|coño)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Contenido sexualmente explícito'
+      },
+      // Spam and scams
+      {
+        pattern: /\b(spam|scam|estafa|fraude|phishing|troyano|virus|malware)/i,
+        severity: 'medium',
+        action: 'block',
+        reason: 'Posible spam o estafa'
+      },
+      {
+        pattern: /\b(ganar\s+dinero\s+fácil|dinero\s+gratis|oferta\s+única|hazte\s+rico|millonario)/i,
+        severity: 'medium',
+        action: 'block',
+        reason: 'Posible esquema de estafa'
+      },
+      // Drugs
+      {
+        pattern: /\b(marihuana|cocaína|heroína|droga|narco|pastilla|psicotrópico|alucinógeno)/i,
+        severity: 'medium',
+        action: 'block',
+        reason: 'Contenido sobre drogas ilegales'
+      },
+      // Personal attacks
+      {
+        pattern: /\b(estúpido|idiota|imbécil|burro|inútil|pendejo|culero|weón|aweonao|concha\su\s+madre|hijo\su\s+puta)/i,
+        severity: 'medium',
+        action: 'block',
+        reason: 'Lenguaje ofensivo o ataque personal'
+      }
+    ];
+  }
+
+  private getModerateRules(): ModerationRule[] {
+    return [
+      // Only block hate speech and explicit content
+      {
+        pattern: /\b(hate|odio|racista|homófobo|transfóbico)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Discurso de odio detectado'
+      },
+      {
+        pattern: /\b(pornografía|porno|sexo\s+explícito)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Contenido sexualmente explícito'
+      },
+      {
+        pattern: /\b(suicidio|autolesión|matar)/i,
+        severity: 'high',
+        action: 'block',
+        reason: 'Contenido preocupante'
+      }
+    ];
+  }
+
+  private getLiberalRules(): ModerationRule[] {
+    return [
+      // Only block illegal content
+      {
+        pattern: /\b(spam|scam|phishing|fraude|estafa\s+electrónica)/i,
+        severity: 'medium',
+        action: 'block',
+        reason: 'Contenido ilegal o fraudulento'
+      }
+    ];
+  }
+
+  public async filterContent(message: string): Promise<ModerationResult> {
+    const rules = this.getModerationRules();
+
+    for (const rule of rules) {
+      if (rule.pattern.test(message)) {
+        console.log(`Content blocked by moderation: ${rule.reason}`);
+        
+        if (rule.action === 'block') {
+          return { 
+            allowed: false, 
+            reason: rule.reason,
+            severity: rule.severity 
+          };
+        } else if (rule.action === 'warn') {
+          // For liberal moderation, we might allow with warning
+          return { 
+            allowed: true, 
+            reason: `Advertencia: ${rule.reason}`,
+            severity: rule.severity 
+          };
+        }
+      }
+    }
+
+    // Check for repeated characters (spam indicator)
+    if (/(.)\1{4,}/.test(message)) {
+      return { 
+        allowed: false, 
+        reason: 'Demasiados caracteres repetidos (posible spam)',
+        severity: 'medium'
+      };
+    }
+
+    // Check for excessive caps (shouting)
+    const uppercaseRatio = (message.match(/[A-Z]/g) || []).length / message.length;
+    if (uppercaseRatio > 0.7 && message.length > 20) {
+      return { 
+        allowed: false, 
+        reason: 'Demasiado texto en mayúsculas',
+        severity: 'low'
+      };
+    }
+
+    return { allowed: true };
+  }
+
+  public isSpamPattern(message: string): boolean {
+    const spamPatterns = [
+      /^[A-Z\s]{10,}$/, // All caps
+      /(.)\1{10,}/, // Character repetition
+      /([a-z])\1{5,}/i, // Same letter repeated
+      /[\w\s]{1,3}(\s+\1){4,}/ // Word repetition
+    ];
+
+    return spamPatterns.some(pattern => pattern.test(message));
+  }
+}

+ 1 - 1
src/agents/countdown/index.ts

@@ -168,7 +168,7 @@ try {
     config.DEFAULT_TIMEZONE
   );
 
-  if (config.LOG_LEVEL === LogLevels.DEBUG) {
+  if (config.DEVELOP) {
     countdown.run();
   }
 } catch (error) {