A Discord bot for meme communities, built as a pure Effect application
Meme contests Β· community-driven moderation Β· member onboarding Β· the weekly corabastos
| 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.
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
Three properties fall out of this graph:
- 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. - Repositories are private β each facade does
Manager.layer.pipe(Layer.provide(Repository.layer)), so a handler physically cannot reach another feature's tables. - Shutdown is free β
Layer.launchholds the scope open; on SIGINT the finalizers run in reverse order (stats logged β Discord client destroyed β pg pool closed).
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
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.
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
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));| 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 |
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 |
| 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 |
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_passwordConfig 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 7See DB_SETUP for details.
Stack β TypeScript 7 (native compiler) Β· Effect 4 Ξ² Β· discord.js 14 Β· @effect/sql-pg Β· Biome Β· Node β₯ 22
MIT Β© laplazadevs