O Codiconomy é um bot Discord, executado por Bun, escrito em TypeScript e
integrado ao Discord por discord.js 14. O ponto de entrada é index.ts. Ele é
o composition root: valida a configuração, abre o SQLite, monta o AppContext
(banco, relógio, RNG, notificador, scheduler), cria o registry de
comandos, registra os comandos na guild e só então faz login.
Princípios:
- validar configuração cedo e falhar sem iniciar parcialmente o cliente;
- registrar comandos da guild configurada antes do login;
- manter o registry explícito e previsível;
- regras de economia puras em
src/shared-domain, sem Discord nem banco; - toda mudança de saldo passa por
postTransaction(livro-razão de partidas dobradas); nunca háUPDATEdireto de saldo; - relógio e RNG sempre injetados no domínio;
Date.now()eMath.random()são proibidos fora das implementações de sistema (systemClock,systemRng); - valores monetários e XP sempre inteiros (centavos / milésimos);
- logs JSON estruturados, sem segredos;
- usar o mínimo de intents (
Guilds).
index.ts
-> interactions/handler.ts -> commands/types.ts (despacho)
-> commands/index.ts -> commands/<comando> -> commands/support
-> jobs/{scheduler,handlers,maintenance}
-> shared/{env,logger,notifier,rate-limit}
-> database/* -> drizzle-orm/bun-sqlite -> bun:sqlite
commands/<x> -> database/<x>.ts, views/*, shared-domain/*
jobs/handlers -> database/*, views/*, shared-domain/*
views/* -> shared-domain/*, discord.js (somente builders)
database/* -> shared-domain/* (regras puras; nunca o contrário)
shared-domain/* -> (sem dependências externas, exceto messages -> discord.js)
O bootstrap orquestra, mas não concentra regras de domínio. SQL, Drizzle e
bun:sqlite ficam restritos a src/database.
- Cria o logger e registra
discord.startup.started. - Lê
DISCORD_TOKEN,DISCORD_CLIENT_ID,DISCORD_GUILD_IDeSQLITE_DATABASE_PATH(nada de segredos nos logs). - Abre uma conexão SQLite (
openDatabase), fazSELECT 1eassertMigrated(falha com instrução clara se as migrations não foram aplicadas combun run db:migrate). - Monta o
AppContextebootstrapDatabase: cria a temporada 1 na primeira execução, agenda o fim da temporada, semeia as manchetes iniciais, garante as notícias da janela atual e o job da próxima virada, e encerra quizzes deixadosactivepor um restart. - Inicia a manutenção (reconciliação do razão, de hora em hora).
- Cria o
ClientcomGatewayIntentBits.Guildse instalaClientReadyeInteractionCreate. - Registra os comandos por REST na guild antes do login.
- Faz login. Em
ClientReady, o scheduler inicia: a primeira varredura executa tudo o que venceu enquanto o bot esteve offline.
SIGINT/SIGTERM e falhas de startup param manutenção e scheduler, fecham o
banco e destroem o cliente de forma idempotente.
src/interactions/handler.ts recebe todo InteractionCreate e roteia:
| Tipo | Destino |
|---|---|
| Slash command | command.execute |
| Autocomplete | command.autocomplete |
| Botão ou modal | command.component, pelo prefixo do customId (<comando>:…) |
Antes de executar, aplica o rate limit por usuário (8 interações / 10 s,
em memória). Registra discord.interaction.received,
discord.command.execution.{started,completed,failed} e
discord.interaction.completed com IDs e durationMs. Falhas respondem com
uma mensagem genérica e efêmera, sem vazar estado interno. O handler de
customId usa a convenção quiz:a:<sessão>:<posição>:<alternativa>,
blackjack:<ação>:<sessão> e admin:qcreate|qedit:<id>.
Um diretório por comando, com createXCommand(ctx: AppContext). O registry
(index.ts) lista todos explicitamente. commands/support concentra o
guard requirePlayer (servidor, temporada ativa, cadastro, preso) e os helpers
de resposta (replyError, readAmount). commands/admin divide o /admin por
grupo (parametro, quiz-categoria, quiz-pergunta, noticia, evento,
canal, temporada, cofre, dia). Comandos respondem pela interação
(reply/editReply/followUp); mensagens fora de interação usam o Notifier.
env.ts (configuração), logger.ts (JSON estruturado), notifier.ts (DMs,
anúncios e edição de mensagens antigas; falhas são logadas, nunca propagadas) e
rate-limit.ts.
Regras puras e testáveis com relógio/RNG falsos:
| Pasta | Conteúdo |
|---|---|
money |
formatação/parse de CD$, percentuais |
rng |
Rng injetável (systemRng, semente, sequência) |
time |
Clock, dia do bot (janelas, virada, mudança pendente), cooldown, duração |
params |
catálogo tipado, grupos, resolvedor admin > evento > notícia > padrão |
ledger |
contas, validação de partida dobrada e construtores de lançamentos |
skills |
barras, curva, XP por ação |
work, quiz, crime, prison, investments, news, season |
regras de cada área |
casino |
roleta, blackjack, caça-níqueis, corrida |
messages |
createEmbed, sendEmbeds, Color, formatDiscordTimestamp |
Builders de embeds e componentes (blackjack, corrida, quiz, notícias) usados
tanto por comandos quanto por jobs. Dependem só do domínio e de discord.js.
scheduler.ts: varredura a cada 15 s +nudge(timer) para jobs que devem disparar perto do horário; as varreduras nunca se sobrepõem.handlers.ts: um handler porkindde job; efeitos de Discord voltam emafter, executados depois do commit.maintenance.ts,race-animation.ts.
Única camada que conhece SQLite e Drizzle. Funções recebem um Executor
(cliente ou transação). runInTransaction abre BEGIN IMMEDIATE (ou savepoint
se já houver transação). Nunca use await nem chame o Discord dentro de uma
transação. Módulos:
| Módulo | Responsabilidade |
|---|---|
index.ts |
conexão única, tipos, runInTransaction, assertMigrated |
ledger.ts |
postTransaction, saldos, extrato, reconcile |
members.ts, users.ts |
identidade e /cadastrar |
seasons.ts, season-lifecycle.ts, ranking.ts |
temporadas, wipe, ranking |
settings.ts, params.ts, cooldowns.ts, audit.ts |
dia, parâmetros, cooldowns genéricos, auditoria |
jobs.ts, day.ts, bootstrap.ts |
scheduler persistente, virada do dia, startup |
bank.ts, work.ts, quiz.ts, crime.ts, prison.ts, invest.ts, casino.ts, race.ts, skills.ts, news.ts |
casos de uso |
Os modelos Drizzle ficam em src/database/models/ e são exportados por
src/database/schema/index.ts. Há uma única migration init.
| Tabela | Notas |
|---|---|
users |
identidade Discord + membro (uma guild), registered_at |
seasons, season_rankings |
no máx. uma active (índice único parcial) |
accounts |
por temporada/dono/tipo; CHECK (balance >= 0) exceto emissao, conversao, cassino; frozen |
ledger_transactions, ledger_entries |
idempotency_key único; amount <> 0; append-only |
skills, skill_events |
CHECK (xp_milli BETWEEN 0 AND 100000) + trilha de auditoria |
cooldowns |
(discord_id, action) -> available_at |
parameter_overrides, admin_audit, settings |
configuração e auditoria |
scheduled_jobs |
kind, due_at, idempotency_key único, done_at, attempts |
quiz_* |
categorias, perguntas, sessões e perguntas da sessão (prazo, resposta, pagamento) |
news_templates, daily_news, events |
notícias (com snapshot por janela) e eventos |
criminal_state, prison_terms |
risco, última janela com tentativa, penas |
investments |
principal, taxa congelada, vencimento, status |
casino_rounds, blackjack_sessions, horse_races, horse_bets |
jogos e auditoria de RTP |
postTransaction({ key, kind, entries, now, … }):
- Se a chave já existe, devolve a transação original (
replayed: true) sem efeito. - Valida inteiros e soma zero por moeda (limpo e sujo nunca se compensam).
- Resolve contas de usuário (devem existir;
seasonIdexplícito por conta só para virada de temporada) e do sistema (criadas sob demanda). - Recusa débito em conta
frozen(excetoallowFrozenDebit, usado na fiança) e saldo insuficiente. - Grava a transação, as linhas e atualiza o cache
accounts.balance, tudo na mesma transação SQLite.
reconcile() verifica soma zero por moeda, por transação e a igualdade entre o
saldo em cache e a soma das linhas de cada conta; roda de hora em hora
(ledger.reconcile.failed no log) e em toda a suíte de testes.
scheduled_jobs guarda o que precisa acontecer no futuro. processDueJobs
executa cada job vencido em uma transação própria: o efeito e o done_at são
gravados juntos, então um job é aplicado exatamente uma vez, mesmo com o bot
offline ou duas varreduras simultâneas. Uma falha reverte o efeito, incrementa
attempts e tenta de novo; após 5 tentativas o job é encerrado com o erro.
| Kind | Chave | Efeito |
|---|---|---|
day.turn |
day:turn:{janela} |
risco, notícias, anúncio, próximo turno |
prison.release |
prison:release:{termo} |
solta e avisa por DM |
investment.mature |
investment:mature:{id} |
paga principal + rendimento |
blackjack.timeout |
blackjack:timeout:{id} |
encerra a mão (parar) |
race.start |
race:start:{id} |
larga a corrida |
season.end |
season:end:{id} |
encerra a temporada |
const registry = createCommandRegistry(context); // AppContextPara adicionar um comando é preciso importá-lo e incluí-lo na lista de
createCommandRegistry. Não há descoberta automática. O REST.put publica a
lista inteira na guild a cada startup, antes do login.
createEmbed normaliza EmbedBuilder/dados em um EmbedBuilder; sendEmbeds
envia a um canal com channel.send. A primeira resposta de um slash command
usa a interação; sendEmbeds não a substitui.
Segredos e IDs vêm do ambiente validado por env.ts. O token é usado só para
REST e login. O único intent é Guilds; não há intents privilegiados nem
listeners de mensagens. DMs usam o REST do discord.js sem intent extra. O
acesso administrativo é calculado de interaction.memberPermissions
(Administrador ou Gerenciar Servidor).
Extensões futuras que exijam novos intents, tipos de interação ou módulos devem ser deliberadas e atualizar este documento.