Skip to content

Repository files navigation

πŸ€– Plazero

A Discord bot for meme communities, built as a pure Effect application

Meme contests Β· community-driven moderation Β· member onboarding Β· the weekly corabastos

TypeScript Effect discord.js PostgreSQL Biome Node


What it does

Feature In one line
πŸ—³οΈ Vote Democratic timeouts: the community votes with πŸ‘/πŸ‘Ž, escalating sanctions apply automatically
🀣 Meme Friday-to-Friday meme & 🦴 contests tracked by laugh reactions, with yearly hall of fame
πŸ‘‹ Welcome Onboards new members, collects LinkedIn/presentation info, moderator approval flow
πŸ‡¨πŸ‡΄ Corabastos Weekly community session: agenda with turnos, emergency sessions, DM reminders
πŸ‘» Departure Notifies admins when a member leaves

Every feature is a vertical slice under src/features/<name>/ β€” its domain model, services, Discord handlers, embeds, and slash commands live together, wired to the app through one exported Layer.


Architecture

The whole application is one dependency graph of Effect layers, composed once in src/main.ts. This diagram isn't aspirational documentation β€” it's the actual MainLive composition, and the compiler rejects the build if an edge is missing:

flowchart TD
    subgraph runtime["🎬 Entry β€” NodeRuntime.runMain(Layer.launch(MainLive))"]
        GATEWAY["GatewayLive<br/><i>discord/Gateway.ts</i><br/>7 gateway listeners β†’ FiberSet"]
        JOBS["ScheduledJobsLive<br/><i>jobs/ScheduledJobs.ts</i><br/>6 recurring fibers"]
    end

    subgraph features["🧩 Features β€” each facade hides its repository"]
        VOTE["VoteFeatureLive<br/>VoteManager βŠ‚ VoteRepository"]
        MEME["MemeFeatureLive<br/>MemeManager βŠ‚ MemeRepository"]
        WELCOME["WelcomeFeatureLive<br/>WelcomeManager βŠ‚ WelcomeRepository"]
        CORA["CorabastosFeatureLive<br/>CorabastosManager βŠ‚ CorabastosRepository"]
    end

    subgraph shared["🀝 Shared domain"]
        USER["UserRepository.layer<br/><i>domain/UserRepository.ts</i>"]
    end

    subgraph infra["βš™οΈ Infrastructure"]
        DISCORD["DiscordClient.layer<br/>login β‡’ ready β‡’ destroy on release"]
        BOOT["Migrations.boot<br/>runs pending migrations<br/><b>gates the Discord login</b>"]
        MIG["Migrations.layer"]
        PG["PgLive<br/>PgClient / SqlClient pool"]
        NODE["NodeServices.layer<br/>fs Β· path Β· runtime"]
    end

    CONFIG[("DotEnvConfigProviderLive<br/>.env β‡’ ConfigProvider")]

    GATEWAY --> VOTE & MEME & WELCOME & CORA
    JOBS --> VOTE & MEME & CORA
    GATEWAY --> DISCORD
    VOTE & MEME & WELCOME & CORA --> USER
    MEME & CORA --> DISCORD
    BOOT -. "must finish first" .-> DISCORD
    BOOT --> MIG
    USER --> PG
    VOTE & MEME & WELCOME & CORA --> PG
    JOBS --> PG
    MIG --> PG
    MIG --> NODE
    PG --> CONFIG
    DISCORD --> CONFIG

    classDef entry stroke:#e74c3c,stroke-width:2px
    classDef feat stroke:#8e44ad,stroke-width:2px
    classDef infra stroke:#2980b9,stroke-width:2px
    classDef cfg stroke:#f39c12,stroke-width:2px
    class GATEWAY,JOBS entry
    class VOTE,MEME,WELCOME,CORA feat
    class DISCORD,BOOT,MIG,PG,NODE,USER infra
    class CONFIG cfg
Loading

Three properties fall out of this graph:

  1. Startup ordering is declarative β€” DiscordClient.layer.pipe(Layer.provide(Migrations.boot)) means the bot cannot log in before the schema is migrated. No init function, no ordering bugs.
  2. Repositories are private β€” each facade does Manager.layer.pipe(Layer.provide(Repository.layer)), so a handler physically cannot reach another feature's tables.
  3. Shutdown is free β€” Layer.launch holds the scope open; on SIGINT the finalizers run in reverse order (stats logged β†’ Discord client destroyed β†’ pg pool closed).

Life of an interaction

sequenceDiagram
    autonumber
    actor U as Member
    participant D as Discord Gateway
    participant G as Gateway.ts<br/>(FiberSet runtime)
    participant H as VoteCommands.ts<br/>(Effect.fn handler)
    participant M as VoteManager
    participant R as VoteRepository
    participant P as PostgreSQL

    U->>D: /vote-timeout @user reason
    D->>G: InteractionCreate
    G->>G: runFork(safeInteraction) β€” a supervised fiber per event
    G->>H: dispatch by command name<br/>(same constant used at registration)
    H->>M: yield* VoteManager
    M->>R: createVote(...)
    R->>P: sql`INSERT ...` β†’ Schema.decode(VoteRow)
    P-->>U: πŸ“Š vote embed + πŸ‘ πŸ‘Ž reactions
    Note over H,M: Failures are values: DiscordError / tagged domain errors<br/>caught with catchTag, rendered as Spanish embeds.<br/>Anything unexpected β†’ catchCause β†’ log + apology reply
Loading

Every discord.js promise crosses into Effect through one four-line helper β€” discordCall(operation, thunk) β€” so there is no floating promise anywhere and every Discord failure is a typed DiscordError carrying the operation name.

Boot sequence

flowchart LR
    A(["npm start"]) --> B["Load .env via<br/>ConfigProvider"]
    B --> C["Connect pg pool<br/>(verified with SELECT 1)"]
    C --> D["Migrations.boot<br/>apply pending SQL"]
    D --> E["Discord login<br/>+ wait for ClientReady"]
    E --> F["Register 7 gateway<br/>listeners + 6 job fibers"]
    F --> G(["🟒 Layer.launch<br/>runs forever"])
    G -. "SIGINT β†’ finalizers<br/>in reverse order" .-> A
Loading

Anatomy of a feature

Every feature follows the same shape (vote shown; the other four are isomorphic):

src/features/vote/
β”œβ”€β”€ Vote.ts              ← the facade: exports VoteFeatureLive (only main.ts imports this)
β”œβ”€β”€ VoteDomain.ts        ← model + constants + helpers (VoteData, thresholds, vote weight)
β”œβ”€β”€ VoteManager.ts       ← business logic service   β€Ί static readonly layer
β”œβ”€β”€ VoteRepository.ts    ← SQL + row schemas        β€Ί static readonly layer
β”œβ”€β”€ VoteCommands.ts      ← slash handlers + the SlashCommandBuilder definitions
β”œβ”€β”€ VoteReactions.ts     ← πŸ‘/πŸ‘Ž/⬜ reaction handlers
β”œβ”€β”€ VoteCompletion.ts    ← the vote-resolution workflow (used by handlers *and* jobs)
β”œβ”€β”€ VoteUpdates.ts       ← live embed refresh
β”œβ”€β”€ VoteEmbeds.ts        ← pure EmbedBuilder functions β€” no Effect, no I/O
└── README.md            ← feature docs

The facade is deliberately boring β€” and that's the point:

// src/features/vote/Vote.ts
export const VoteFeatureLive = VoteManager.layer.pipe(Layer.provide(VoteRepository.layer));

House rules

Rule What it looks like
Services are classes class VoteManager extends Context.Service<VoteManager>()('plazero/VoteManager', { make })
Layers live on the class static readonly layer = Layer.effect(VoteManager)(VoteManager.make)
Named ops are traced Effect.fn('VoteManager.getVote')(function* (id) { ... }) β€” spans for free
Errors are data class InvalidTurnoError extends Schema.TaggedErrorClass<...>()('InvalidTurnoError', { turno: Schema.Number })
Rows are schemas sql\SELECT ...`decoded withSchema.Class` row models at the DB boundary
One promise bridge discordCall('interaction.reply', () => interaction.reply(...)) β†’ typed DiscordError
No barrel files Import the module you mean; the layer graph is the dependency documentation
Commands can't drift .setName(VOTE_TIMEOUT_COMMAND) at registration and case VOTE_TIMEOUT_COMMAND: at dispatch β€” same constant

Background jobs

Six supervised fibers, started by ScheduledJobsLive, all in America/Bogota. A failing tick is logged and the schedule keeps running:

Job Schedule Does
voteSweep every 30 s completes expired votes, refreshes live embeds
contestCompletion startup + 0 * * * * (hourly) closes finished meme contests and recovers a missing weekly contest
turnoNotifications * * * * * (every minute) corabastos turno reminders (channel + DM)
weeklySessionCreation 0 0 * * 6 (Sat 00:00) schedules the week's corabastos session
databaseCleanup every 1 h runs run_all_cleanup() in Postgres
corabastosCleanup every 30 min expires stale emergency requests & notifications

Slash commands

Command Feature Who
/vote-timeout <user> <reason> πŸ—³οΈ vote everyone with One Of Us
/cancel-vote <vote-id> πŸ—³οΈ vote admins
/meme-of-the-year 🀣 meme everyone
/meme-stats 🀣 meme everyone
/meme-contest <type> [duration] 🀣 meme moderators
/meme-complete-contest <contest-id> 🀣 meme admins
/meme-recover-contest 🀣 backfills and announces the latest missing weekly winners admins
/corabastos-agenda agregar <turno> <tema> [descripcion] πŸ‡¨πŸ‡΄ corabastos everyone
/corabastos-agenda ver πŸ‡¨πŸ‡΄ corabastos everyone
/corabastos-emergencia <razon> <paciente> πŸ‡¨πŸ‡΄ corabastos everyone
/corabastos-estado πŸ‡¨πŸ‡΄ corabastos everyone
/corabastos-crear-sesion πŸ‡¨πŸ‡΄ corabastos admins

Quickstart

npm install        # also vendors the Effect repo for reference (scripts/prepare-effect.sh)
npm run setup      # create the database + run all migrations
npm run dev        # migrate β†’ register commands β†’ run the bot with tsx
Script
npm run dev full local loop (migrate, register, run)
npm run build TypeScript 7 native compile β€” the whole app in ~0.5 s
npm run lint / lint:fix Biome: lint + format + import order, one tool
npm run migrate:status migration table vs. src/migrations/*.sql
npm start production: build β†’ migrate β†’ register β†’ run
πŸ” Environment variables
DISCORD_BOT_TOKEN=your_bot_token
CLIENT_ID=your_client_id
GUILD_ID=your_server_id
DATABASE_URL=postgresql://user:password@localhost:5432/plazero_bot
# or individual PG* variables:
PGDATABASE=plazero_bot
PGUSER=postgres
POSTGRES_PASSWORD=your_password

Config is read through Effect's ConfigProvider (see src/config/): .env is layered on top of the process environment, secrets are Config.redacted, and legacy variable spellings are supported via Config.orElse chains.

πŸ—οΈ Required Discord setup β€” channels, roles, permissions, intents

Channels

Channel Used by
🀣︱memes meme contests
πŸ§‘β€βš–οΈοΈ±moderaciΓ³n voting messages
πŸ‘‹οΈ±nuevos welcome flow
πŸ—ΏοΈ±general corabastos announcements
πŸ‡¨πŸ‡΄οΈ±corabastos the session voice channel
🐡︱administración departure notices

Roles β€” One Of Us (vote + granted on welcome approval), Server Booster (2Γ— vote weight), Administrator (vote-immune, can cancel).

Permissions β€” Send Messages, Use Slash Commands, Add Reactions, Read Message History, View Channels, Embed Links, Timeout Members, Manage Messages, Use External Emojis.

Intents (set in code) β€” Guilds, GuildMessages, MessageContent, GuildMessageReactions, GuildMembers.

πŸ—„οΈ Database & migrations

Plain SQL migrations in src/migrations/ with -- UP MIGRATION / -- DOWN MIGRATION sections, applied by an Effect service (db/Migrations.ts) that keeps the production-compatible migrations bookkeeping table.

npm run migrate:up          # apply pending
npm run migrate:status      # show applied vs pending
npm run migrate:rollback 7  # roll back version 7

See DB_SETUP for details.


Stack β€” TypeScript 7 (native compiler) Β· Effect 4 Ξ² Β· discord.js 14 Β· @effect/sql-pg Β· Biome Β· Node β‰₯ 22

MIT Β© laplazadevs

About

Just for fun project about using discord.js API

Resources

Stars

9 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages