Browse Source

added countdown agent

Pablo Barrera Yaksic 2 weeks ago
parent
commit
eae34d4ee3

+ 5 - 0
.env.example

@@ -57,6 +57,11 @@ MASTODON_KEY_THECLINIC = ""
 MASTODON_KEY_FORTUNE = ""
 MASTODON_KEY_REMINDME = ""
 MASTODON_KEY_ANNIVERSARIES = ""
+MASTODON_KEY_ANONYBOT = ""
+MASTODON_KEY_COUNTDOWN = ""
+
+# COUNTDOWN
+COUNTDOWN_EVENTS = ""
 
 # Develop
 DEVELOP = true

+ 1 - 0
serverless.yml

@@ -21,3 +21,4 @@ functions:
   - ${file(./src/portales/emol/definition.yml)}
   - ${file(./src/portales/latercera/definition.yml)}
   - ${file(./src/portales/theclinic/definition.yml)}
+  - ${file(./src/agents/countdown/definition.yml)}

+ 83 - 0
src/agents/countdown/README.md

@@ -0,0 +1,83 @@
+# Countdown Agent
+
+Agente que publica diariamente la cantidad de días que quedan para una fecha determinada.
+
+## Configuración
+
+### 1. Variables de Entorno
+
+Agrega las siguientes variables a tu archivo `.env`:
+
+```env
+MASTODON_KEY_COUNTDOWN=<access-token-del-bot>
+COUNTDOWN_EVENTS=2026-06-15|Cumpleaños Juan;2026-12-25|Navidad
+```
+
+### 2. Formato de COUNTDOWN_EVENTS
+
+El formato es: `YYYY-MM-DD|Mensaje` separado por `;`
+
+Ejemplo:
+```
+COUNTDOWN_EVENTS=\
+2026-06-15|Cumpleaños Juan;\
+2026-12-25|Navidad;\
+2027-01-01|Año Nuevo
+```
+
+### 3. Definición Serverless
+
+El agente ya está incluido en `serverless.yml` con la configuración:
+
+```yaml
+countdown:
+  handler: ./src/agents/countdown/index.handler
+  events:
+    - schedule: rate(1 day)
+```
+
+## Funcionamiento
+
+- **Frecuencia**: Se ejecuta diariamente a las 12:00 PM (hora por defecto: America/Santiago)
+- **Publicación**: Publica un post por cada evento configurado
+- **Mensajes**:
+  - `⏳ Quedan X días para [evento]` (días antes de la fecha)
+  - `🎊 ¡Hoy es el día! [evento] 🎊` (el día exacto, **una sola vez**)
+  - **No publica** para eventos pasados (se ignoran)
+- **Evita duplicados**: Usa Redis para registrar qué eventos publicó cada día
+- **Eventos completados**: Una vez que un evento se cumple, se marca como completado en Redis (permanente) y ya no vuelve a publicarse
+
+## Ejemplo de Post
+
+```
+⏳ Quedan 15 días para Cumpleaños Juan
+
+📆 15 días más para disfrutar este momento especial ✨
+
+🏷️ #Countdown
+```
+
+## Estructura del Código
+
+```
+src/agents/countdown/
+├── index.ts          # Lógica principal del agente
+├── interfaces.ts     # Interfaces TypeScript
+└── definition.yml    # Configuración Serverless
+```
+
+## Implementación
+
+- Usa `cron` para programación diaria
+- Usa `masto` para publicar en Mastodon
+- Usa `RedisClient` para evitar duplicados
+- Soporta múltiples eventos configurables
+- Eventos pasados se marcan como completados y no vuelven a publicarse
+
+## Emojis utilizados
+
+- ⏳ HOURGLASS: Para mostrar días restantes
+- 🎊 TADA: Para el día del evento
+- 📆 CALENDAR: Para referencias de fecha
+- ✨ SPARKLES: Para efectos visuales
+- 🏷️ TAGS: Para hashtags

+ 4 - 0
src/agents/countdown/definition.yml

@@ -0,0 +1,4 @@
+countdown:
+  handler: ./src/agents/countdown/index.handler
+  events:
+    - schedule: rate(1 day)

+ 176 - 0
src/agents/countdown/index.ts

@@ -0,0 +1,176 @@
+import { createRestAPIClient, mastodon } from "masto";
+import { CronJob } from "cron";
+
+import RedisClient from "../../libs/redis-client";
+import Emojis from "../../enums/emojis";
+import LogLevels from "../../enums/log-levels";
+import config from "../../config";
+
+import { type ICountdown } from "./interfaces";
+
+export default class Countdown {
+  private readonly _name: string = "Countdown";
+  private readonly _mastodonRestClient: mastodon.rest.Client;
+  private readonly _redisClient: RedisClient;
+
+  constructor(accessToken = config.MASTODON_TEST_ACCESS_TOKEN) {
+    accessToken = config.DEVELOP ? config.MASTODON_TEST_ACCESS_TOKEN : accessToken;
+    this._mastodonRestClient = createRestAPIClient({ url: config.MASTODON_URL, accessToken });
+    this._redisClient = new RedisClient();
+  }
+
+  private async publish(status: string): Promise<void> {
+    let result: mastodon.v1.Status;
+
+    console.log(`${this._name} | Sending status\n`, status);
+    result = await this._mastodonRestClient.v1.statuses.create({ status });
+
+    if (config.LOG_LEVEL === LogLevels.DEBUG) {
+      console.log(`${this._name} | Result`, result);
+    }
+  }
+
+  private parseCountdownEvents(): ICountdown[] {
+    const eventsString = config.COUNTDOWN_EVENTS;
+    if (!eventsString || eventsString.trim() === "") {
+      return [];
+    }
+
+    const events: ICountdown[] = [];
+    const eventPairs = eventsString.split(";");
+
+    eventPairs.forEach((eventPair, index) => {
+      const [dateStr, message, hashTags] = eventPair.split("|");
+      if (dateStr && message) {
+        const targetDate = new Date(dateStr.trim());
+        const id = `countdown_${index}_${dateStr.trim()}`;
+
+        events.push({
+          id,
+          targetDate,
+          message: message.trim(),
+          hashtags: hashTags.split(",")
+        });
+      }
+    });
+
+    return events;
+  }
+
+  private async hasPublishedToday(eventId: string): Promise<boolean> {
+    const key = `countdown:published:${eventId}:${new Date().toISOString().split("T")[0]}`;
+    const result = await this._redisClient.retrieve(key);
+    return result !== null && result !== "";
+  }
+
+  private async isEventCompleted(eventId: string): Promise<boolean> {
+    const key = `countdown:completed:${eventId}`;
+    const result = await this._redisClient.retrieve(key);
+    return result !== null && result !== "";
+  }
+
+  private async markPublished(eventId: string): Promise<void> {
+    const key = `countdown:published:${eventId}:${new Date().toISOString().split("T")[0]}`;
+    await this._redisClient.store(key, "published", { EX: 60 * 60 * 24 });
+  }
+
+  private async markEventCompleted(eventId: string): Promise<void> {
+    const key = `countdown:completed:${eventId}`;
+    await this._redisClient.store(key, "completed");
+  }
+
+  private calculateDaysRemaining(targetDate: Date): number {
+    const today = new Date();
+    today.setHours(0, 0, 0, 0);
+    const target = new Date(targetDate);
+    target.setHours(0, 0, 0, 0);
+    const diffTime = target.getTime() - today.getTime();
+    return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+  }
+
+  private formatMessage(daysRemaining: number, message: string, hashtags?: string[]): string | null {
+    if (daysRemaining < 0) {
+      return null;
+    }
+
+    let status = `${Emojis.HOURGLASS} Quedan ${daysRemaining} días para ${message}`;
+
+    if (daysRemaining === 0) {
+      status = `${Emojis.TADA} ¡Hoy es el día! ${message} ${Emojis.TADA}`;
+    }
+
+    // status += `\n\n${Emojis.CALENDAR} ${daysRemaining} días más para disfrutar este momento especial ${Emojis.SPARKLES}`;
+
+    if (hashtags && hashtags.length > 0) {
+      status += `\n\n${Emojis.TAGS} ${hashtags.map((h) => `#${h}`).join(" ")}`;
+    }
+
+    return status;
+  }
+
+  public async run(): Promise<void> {
+    try {
+      console.log(`${this._name} | Starting countdown check`);
+
+      const events = this.parseCountdownEvents();
+
+      if (events.length === 0) {
+        console.log(`${this._name} | No events configured`);
+        return;
+      }
+
+      console.log(`${this._name} | Found ${events.length} event(s)`);
+
+      for (const event of events) {
+        const daysRemaining = this.calculateDaysRemaining(event.targetDate);
+
+        if (await this.isEventCompleted(event.id) || await this.hasPublishedToday(event.id)) {
+          console.log(`${this._name} | Event already completed or published today for ${event.id}`);
+          continue;
+        }
+
+        const status = this.formatMessage(daysRemaining, event.message, event.hashtags);
+
+        if (status === null) {
+          console.log(`${this._name} | Event already passed for ${event.id}`);
+          await this.markEventCompleted(event.id);
+          continue;
+        }
+
+        await this.publish(status);
+        await this.markPublished(event.id);
+
+        if (daysRemaining === 0) {
+          await this.markEventCompleted(event.id);
+        }
+      }
+
+      console.log(`${this._name} | Countdown check completed`);
+    } catch (error: any) {
+      console.error(`${this._name} | Error occurred`, error.message);
+    }
+  }
+
+  public getHandler(): import("aws-lambda").Handler {
+    return async () => {
+      await this.run();
+    };
+  }
+}
+
+try {
+  const countdown = new Countdown(config.DEVELOP ? config.MASTODON_TEST_ACCESS_TOKEN : config.MASTODON_KEY_COUNTDOWN);
+  new CronJob(
+    "0 0 12 * * *",
+    () => countdown.run(),
+    null,
+    true,
+    config.DEFAULT_TIMEZONE
+  );
+
+  if (config.LOG_LEVEL === LogLevels.DEBUG) {
+    countdown.run();
+  }
+} catch (error) {
+  console.error(error);
+}

+ 13 - 0
src/agents/countdown/interfaces.ts

@@ -0,0 +1,13 @@
+export interface ICountdown {
+  id: string;
+  targetDate: Date;
+  message: string;
+  hashtags?: string[];
+}
+
+export interface ICountdownPayload {
+  countdownId: string;
+  targetDate: string;
+  message: string;
+  hashtags?: string[];
+}

+ 8 - 0
src/config.ts

@@ -58,6 +58,14 @@ const config = {
   MASTODON_KEY_FORTUNE: process.env.MASTODON_KEY_FORTUNE ?? "",
   MASTODON_KEY_REMINDME: process.env.MASTODON_KEY_REMINDME ?? "",
   MASTODON_KEY_ANNIVERSARIES: process.env.MASTODON_KEY_ANNIVERSARIES ?? "",
+  MASTODON_KEY_ANONYBOT: process.env.MASTODON_KEY_ANONYBOT ?? "",
+  MASTODON_KEY_COUNTDOWN: process.env.MASTODON_KEY_COUNTDOWN ?? "",
+  COUNTDOWN_EVENTS: process.env.COUNTDOWN_EVENTS ?? "",
+  // ANONYBOT Settings
+  ANONYBOT_MAX_MESSAGE_LENGTH: process.env.ANONYBOT_MAX_MESSAGE_LENGTH ?? 500,
+  ANONYBOT_RATE_LIMIT_HOURS: process.env.ANONYBOT_RATE_LIMIT_HOURS ?? 1,
+  ANONYBOT_MAX_MESSAGES_PER_HOUR: process.env.ANONYBOT_MAX_MESSAGES_PER_HOUR ?? 5,
+  ANONYBOT_MODERATION_LEVEL: process.env.ANONYBOT_MODERATION_LEVEL ?? "conservative",
   // Develop
   DEVELOP: !(process.env.DEVELOP === "false"),
   DEV_ACTIVE_PORTALS: process.env.DEV_ACTIVE_PORTALS?.split(";") ?? [],

+ 6 - 1
src/enums/emojis.ts

@@ -1,11 +1,13 @@
 enum Emojis {
   ALARM_CLOCK = "⏰",
   BELL = "🔔",
-  CL_FLAG = "🇨🇱",
   CALENDAR = "📆",
   CAKE = "🎂",
   CHECK = "✔️",
+  CL_FLAG = "🇨🇱",
+  CONFETTI = "🎊",
   CRISTAL_BALL = "🔮",
+  CROSS_MARK = "❌",
   DEPTH = "🕳️",
   EAR = "👂",
   FAIRY = "🧚",
@@ -14,6 +16,7 @@ enum Emojis {
   HEART = "❤️",
   HOURGLASS = "⏳",
   HUG = "🫂",
+  INFO = "ℹ️",
   LINK = "🔗",
   LOCATION = "📍",
   MAGIC_SHELL = "🐚",
@@ -22,6 +25,7 @@ enum Emojis {
   MAGNITUDE = "🎚️",
   NEWS = "📰",
   PADLOCK = "🔓",
+  PARTY_POPPER = "🎉",
   PERSON = "👤",
   PIN = "📍",
   RAINBOW = "🌈",
@@ -32,6 +36,7 @@ enum Emojis {
   SLOT_MACHINE = "🎰",
   SPARKLES = "✨",
   TAGS = "🏷️",
+  TADA = "🎊",
   UNICORN = "🦄",
   WAVE = "🌊",
   WIZARD = "🧙🏼‍♂️💭",